1pub mod astar;
17pub mod costs;
18pub mod custom_state;
19pub mod debug;
20pub mod execute;
21pub mod goals;
22mod goto_event;
23pub mod mining;
24pub mod moves;
25pub mod positions;
26pub mod simulation;
27#[cfg(test)]
28mod tests;
29pub mod world;
30
31use std::{
32 collections::VecDeque,
33 sync::{
34 Arc,
35 atomic::{self, AtomicBool, AtomicUsize},
36 },
37 time::Instant,
38};
39
40use astar::Edge;
41use azalea_client::{StartWalkEvent, inventory::InventorySystems, movement::MoveEventsSystems};
42use azalea_core::{
43 position::{BlockPos, Vec3},
44 tick::GameTick,
45};
46use azalea_entity::{LocalEntity, Position, inventory::Inventory, metadata::Player};
47use azalea_world::{WorldName, Worlds};
48use bevy_app::{PreUpdate, Update};
49use bevy_ecs::prelude::*;
50use bevy_tasks::{AsyncComputeTaskPool, Task};
51use custom_state::{CustomPathfinderState, CustomPathfinderStateRef};
52use futures_lite::future;
53pub use goto_event::{GotoEvent, PathfinderOpts};
54use parking_lot::RwLock;
55use positions::RelBlockPos;
56use tokio::sync::broadcast::error::RecvError;
57use tracing::{debug, error, info, warn};
58
59use self::{
60 debug::debug_render_path_with_particles, goals::Goal, mining::MiningCache, moves::SuccessorsFn,
61};
62use crate::{
63 Client, WalkDirection,
64 app::{App, Plugin},
65 ecs::{
66 component::Component,
67 entity::Entity,
68 query::{With, Without},
69 system::{Commands, Query, Res},
70 },
71 pathfinder::{
72 astar::{PathfinderTimeout, a_star},
73 execute::{DefaultPathfinderExecutionPlugin, simulation::SimulatingPathState},
74 moves::MovesCtx,
75 world::CachedWorld,
76 },
77};
78
79#[derive(Clone, Default)]
80pub struct PathfinderPlugin;
81impl Plugin for PathfinderPlugin {
82 fn build(&self, app: &mut App) {
83 app.add_message::<GotoEvent>()
84 .add_message::<PathFoundEvent>()
85 .add_message::<StopPathfindingEvent>()
86 .add_systems(
87 GameTick,
88 debug_render_path_with_particles.in_set(PathfinderSystems),
89 )
90 .add_systems(PreUpdate, add_default_pathfinder.in_set(PathfinderSystems))
91 .add_systems(
92 Update,
93 (
94 goto_listener,
95 handle_tasks,
96 stop_pathfinding_on_world_change,
97 path_found_listener,
98 handle_stop_pathfinding_event,
99 )
100 .chain()
101 .before(MoveEventsSystems)
102 .before(InventorySystems)
103 .in_set(PathfinderSystems),
104 )
105 .add_plugins(DefaultPathfinderExecutionPlugin);
106 }
107}
108
109#[derive(Clone, Debug, Eq, Hash, PartialEq, SystemSet)]
110pub struct PathfinderSystems;
111
112#[derive(Clone, Component, Default)]
114#[non_exhaustive]
115pub struct Pathfinder {
116 pub goal: Option<Arc<dyn Goal>>,
117 pub opts: Option<PathfinderOpts>,
118 pub is_calculating: bool,
119 pub goto_id: Arc<AtomicUsize>,
120}
121
122#[derive(Clone, Component)]
125pub struct ExecutingPath {
126 pub path: VecDeque<astar::Edge<BlockPos, moves::MoveData>>,
127 pub queued_path: Option<VecDeque<astar::Edge<BlockPos, moves::MoveData>>>,
128 pub last_reached_node: BlockPos,
129 pub ticks_since_last_node_reached: usize,
132 pub is_path_partial: bool,
133}
134impl ExecutingPath {
135 pub fn is_empty_queued_path(&self) -> bool {
136 self.queued_path.is_none() || self.queued_path.as_ref().is_some_and(|p| p.is_empty())
137 }
138}
139
140#[derive(Clone, Debug, Message)]
141#[non_exhaustive]
142pub struct PathFoundEvent {
143 pub entity: Entity,
144 pub start: BlockPos,
145 pub path: Option<VecDeque<astar::Edge<BlockPos, moves::MoveData>>>,
146 pub is_partial: bool,
147 pub successors_fn: SuccessorsFn,
148 pub allow_mining: bool,
149}
150
151#[allow(clippy::type_complexity)]
152pub fn add_default_pathfinder(
153 mut commands: Commands,
154 mut query: Query<Entity, (Without<Pathfinder>, With<LocalEntity>, With<Player>)>,
155) {
156 for entity in &mut query {
157 commands.entity(entity).insert(Pathfinder::default());
158 }
159}
160
161pub trait PathfinderClientExt {
162 fn goto(&self, goal: impl Goal + 'static) -> impl Future<Output = ()>;
175 fn goto_with_opts(
190 &self,
191 goal: impl Goal + 'static,
192 opts: PathfinderOpts,
193 ) -> impl Future<Output = ()>;
194 fn start_goto(&self, goal: impl Goal + 'static);
204 fn start_goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts);
210 fn stop_pathfinding(&self);
218 fn force_stop_pathfinding(&self);
221 fn wait_until_goto_target_reached(&self) -> impl Future<Output = ()>;
223 fn is_goto_target_reached(&self) -> bool;
226 fn is_executing_path(&self) -> bool;
231 fn is_calculating_path(&self) -> bool;
236}
237
238impl PathfinderClientExt for Client {
239 async fn goto(&self, goal: impl Goal + 'static) {
240 self.goto_with_opts(goal, PathfinderOpts::new()).await;
241 }
242 async fn goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts) {
243 self.start_goto_with_opts(goal, opts);
244 self.wait_until_goto_target_reached().await;
245 }
246 fn start_goto(&self, goal: impl Goal + 'static) {
247 self.start_goto_with_opts(goal, PathfinderOpts::new());
248 }
249 fn start_goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts) {
250 self.ecs
251 .write()
252 .write_message(GotoEvent::new(self.entity, goal, opts));
253 }
254 fn stop_pathfinding(&self) {
255 self.ecs.write().write_message(StopPathfindingEvent {
256 entity: self.entity,
257 force: false,
258 });
259 }
260 fn force_stop_pathfinding(&self) {
261 self.ecs.write().write_message(StopPathfindingEvent {
262 entity: self.entity,
263 force: true,
264 });
265 }
266
267 async fn wait_until_goto_target_reached(&self) {
268 self.wait_updates(1).await;
271
272 let mut tick_broadcaster = self.get_tick_broadcaster();
273 while !self.is_goto_target_reached() {
274 match tick_broadcaster.recv().await {
276 Ok(_) => (),
277 Err(RecvError::Closed) => return,
278 Err(err) => warn!("{err}"),
279 };
280 }
281 }
282 fn is_goto_target_reached(&self) -> bool {
283 self.component::<Pathfinder>()
284 .ok()
285 .is_none_or(|p| p.goal.is_none() && !p.is_calculating)
286 }
287 fn is_executing_path(&self) -> bool {
288 self.component::<ExecutingPath>().is_ok()
289 }
290 fn is_calculating_path(&self) -> bool {
291 self.component::<Pathfinder>()
292 .is_ok_and(|p| p.is_calculating)
293 }
294}
295
296#[derive(Component)]
297pub struct ComputePath(Task<Option<PathFoundEvent>>);
298
299#[allow(clippy::type_complexity)]
300pub fn goto_listener(
301 mut commands: Commands,
302 mut events: MessageReader<GotoEvent>,
303 mut path_found_events: MessageWriter<PathFoundEvent>,
304 mut query: Query<(
305 &mut Pathfinder,
306 Option<&mut ExecutingPath>,
307 Option<&SimulatingPathState>,
308 &Position,
309 &WorldName,
310 &Inventory,
311 Option<&CustomPathfinderState>,
312 )>,
313 worlds: Res<Worlds>,
314) {
315 let thread_pool = AsyncComputeTaskPool::get();
316
317 for event in events.read() {
318 let Ok((
319 mut pathfinder,
320 executing_path,
321 simulating_path_state,
322 position,
323 world_name,
324 inventory,
325 custom_state,
326 )) = query.get_mut(event.entity)
327 else {
328 warn!("got goto event for an entity that can't pathfind");
329 continue;
330 };
331
332 if env!("OPT_LEVEL") == "0" {
334 static WARNED: AtomicBool = AtomicBool::new(false);
335 if !WARNED.swap(true, atomic::Ordering::Relaxed) {
336 warn!(
337 "Azalea was compiled with no optimizations, which may result in significantly reduced pathfinding performance. Consider following the steps at https://azalea.matdoes.dev/azalea/#optimization for faster performance in debug mode."
338 )
339 }
340 }
341
342 let cur_pos = player_pos_to_block_pos(**position);
343
344 if event.goal.success(cur_pos) {
345 pathfinder.goal = None;
347 pathfinder.opts = None;
348 pathfinder.is_calculating = false;
349 debug!("already at goal, not pathfinding");
350 continue;
351 }
352
353 pathfinder.goal = Some(event.goal.clone());
355 pathfinder.opts = Some(event.opts.clone());
356 pathfinder.is_calculating = true;
357
358 let world_lock = worlds
359 .get(world_name)
360 .expect("Entity tried to pathfind but the entity isn't in a valid world");
361
362 let goal = event.goal.clone();
363 let entity = event.entity;
364
365 let goto_id_atomic = pathfinder.goto_id.clone();
366
367 let allow_mining = event.opts.allow_mining;
368 let inventory_menu = if allow_mining {
369 Some(inventory.inventory_menu.clone())
370 } else {
371 None
372 };
373
374 let custom_state = custom_state.cloned().unwrap_or_default();
375 let opts = event.opts.clone();
376
377 let mut start = cur_pos;
379
380 if let Some(mut executing_path) = executing_path {
381 let instant_path_start = simulating_path_state
386 .and_then(|s| s.as_simulated().map(|s| s.target))
387 .unwrap_or_else(|| {
388 executing_path
389 .path
390 .iter()
391 .next()
392 .map(|e| e.movement.target)
393 .unwrap_or(cur_pos)
394 });
395
396 let path_found_event = calculate_path(CalculatePathCtx {
397 entity,
398 start: instant_path_start,
399 goal: goal.clone(),
400 world_lock: world_lock.clone(),
401 goto_id_atomic: goto_id_atomic.clone(),
402 mining_cache: MiningCache::new(inventory_menu.clone()),
403 custom_state: custom_state.clone(),
404 opts: PathfinderOpts {
405 min_timeout: PathfinderTimeout::Nodes(2_000),
406 max_timeout: PathfinderTimeout::Nodes(2_000),
407 ..opts
408 },
409 });
410
411 if let Some(path_found_event) = path_found_event
412 && !path_found_event.is_partial
413 {
414 debug!("Found path instantly!");
415
416 let instant_path_start_index = executing_path
419 .path
420 .iter()
421 .position(|e| e.movement.target == instant_path_start);
422 if let Some(instant_path_start_index) = instant_path_start_index {
423 let truncate_to_len = instant_path_start_index + 1;
424 debug!("truncating to {truncate_to_len} for instant path");
425 executing_path.path.truncate(truncate_to_len);
426
427 path_found_events.write(path_found_event);
428
429 continue;
431 } else {
432 warn!(
433 "we just calculated an instant path, but the start of it isn't in the current path? instant_path_start: {instant_path_start:?}, simulating_path_state: {simulating_path_state:?}, executing_path.path: {:?}",
434 executing_path.path
435 )
436 }
437 } else {
438 debug!("Couldn't find path instantly...");
439 }
440
441 if !executing_path.path.is_empty() {
442 let executing_path_limit = 50;
445
446 executing_path.path.truncate(executing_path_limit);
448
449 start = executing_path
453 .path
454 .back()
455 .expect("path was just checked to not be empty")
456 .movement
457 .target;
458 }
459 }
460
461 if start == cur_pos {
462 info!("got goto {:?}, starting from {start:?}", event.goal);
463 } else {
464 info!(
465 "got goto {:?}, starting from {start:?} (currently at {cur_pos:?})",
466 event.goal,
467 );
468 }
469
470 let mining_cache = MiningCache::new(inventory_menu);
471 let task = thread_pool.spawn(async move {
472 calculate_path(CalculatePathCtx {
473 entity,
474 start,
475 goal,
476 world_lock,
477 goto_id_atomic,
478 mining_cache,
479 custom_state,
480 opts,
481 })
482 });
483
484 commands.entity(event.entity).insert(ComputePath(task));
485 }
486}
487
488#[inline]
494pub fn player_pos_to_block_pos(position: Vec3) -> BlockPos {
495 BlockPos::from(position.up(0.5))
497}
498
499pub struct CalculatePathCtx {
500 pub entity: Entity,
501 pub start: BlockPos,
502 pub goal: Arc<dyn Goal>,
503 pub world_lock: Arc<RwLock<azalea_world::World>>,
504 pub goto_id_atomic: Arc<AtomicUsize>,
505 pub mining_cache: MiningCache,
506 pub custom_state: CustomPathfinderState,
507
508 pub opts: PathfinderOpts,
509}
510
511pub fn calculate_path(ctx: CalculatePathCtx) -> Option<PathFoundEvent> {
520 debug!("start: {}", ctx.start);
521
522 let goto_id = ctx.goto_id_atomic.fetch_add(1, atomic::Ordering::SeqCst) + 1;
523
524 let origin = ctx.start;
525 let cached_world = CachedWorld::new(ctx.world_lock, origin);
526 let successors = |pos: RelBlockPos| {
527 call_successors_fn(
528 &cached_world,
529 &ctx.mining_cache,
530 &ctx.custom_state.0.read(),
531 ctx.opts.successors_fn,
532 pos,
533 )
534 };
535
536 let start_time = Instant::now();
537
538 let astar::Path {
539 movements,
540 is_partial,
541 cost,
542 } = a_star(
543 RelBlockPos::get_origin(origin),
544 |n| ctx.goal.heuristic(n.apply(origin)),
545 successors,
546 |n| ctx.goal.success(n.apply(origin)),
547 ctx.opts.min_timeout,
548 ctx.opts.max_timeout,
549 );
550 let end_time = Instant::now();
551 debug!("partial: {is_partial:?}, cost: {cost}");
552 let duration = end_time - start_time;
553 if is_partial {
554 if movements.is_empty() {
555 info!("Pathfinder took {duration:?} (empty path)");
556 } else {
557 info!("Pathfinder took {duration:?} (incomplete path)");
558 }
559 } else {
560 info!("Pathfinder took {duration:?}");
561 }
562
563 debug!("Path:");
564 for movement in &movements {
565 debug!(" {}", movement.target.apply(origin));
566 }
567
568 let path = movements.into_iter().collect::<VecDeque<_>>();
569
570 let goto_id_now = ctx.goto_id_atomic.load(atomic::Ordering::SeqCst);
571 if goto_id != goto_id_now {
572 warn!("finished calculating a path, but it's outdated");
574 return None;
575 }
576
577 if path.is_empty() && is_partial {
578 debug!("this path is empty, we might be stuck :(");
579 }
580
581 let mut mapped_path = VecDeque::with_capacity(path.len());
582 let mut current_position = RelBlockPos::get_origin(origin);
583 for movement in path {
584 let mut found_edge = None;
585 for edge in successors(current_position) {
586 if edge.movement.target == movement.target {
587 found_edge = Some(edge);
588 break;
589 }
590 }
591
592 let found_edge = found_edge.expect(
593 "path should always still be possible because we're using the same world cache",
594 );
595 current_position = found_edge.movement.target;
596
597 mapped_path.push_back(Edge {
600 movement: astar::Movement {
601 target: movement.target.apply(origin),
602 data: movement.data,
603 },
604 cost: found_edge.cost,
605 });
606 }
607
608 Some(PathFoundEvent {
609 entity: ctx.entity,
610 start: ctx.start,
611 path: Some(mapped_path),
612 is_partial,
613 successors_fn: ctx.opts.successors_fn,
614 allow_mining: ctx.opts.allow_mining,
615 })
616}
617
618pub fn handle_tasks(
620 mut commands: Commands,
621 mut transform_tasks: Query<(Entity, &mut ComputePath)>,
622 mut path_found_events: MessageWriter<PathFoundEvent>,
623) {
624 for (entity, mut task) in &mut transform_tasks {
625 if let Some(optional_path_found_event) = future::block_on(future::poll_once(&mut task.0)) {
626 if let Some(path_found_event) = optional_path_found_event {
627 path_found_events.write(path_found_event);
628 }
629
630 commands.entity(entity).remove::<ComputePath>();
632 }
633 }
634}
635
636#[allow(clippy::type_complexity)]
638pub fn path_found_listener(
639 mut events: MessageReader<PathFoundEvent>,
640 mut query: Query<(
641 &mut Pathfinder,
642 Option<&mut ExecutingPath>,
643 &WorldName,
644 &Inventory,
645 Option<&CustomPathfinderState>,
646 )>,
647 worlds: Res<Worlds>,
648 mut commands: Commands,
649) {
650 for event in events.read() {
651 let Ok((mut pathfinder, executing_path, world_name, inventory, custom_state)) =
652 query.get_mut(event.entity)
653 else {
654 debug!("got path found event for an entity that can't pathfind");
655 continue;
656 };
657 if let Some(found_path) = &event.path {
658 if let Some(mut executing_path) = executing_path {
659 let mut new_path = VecDeque::new();
660
661 if let Some(last_node_of_current_path) = executing_path.path.back() {
664 let world_lock = worlds
665 .get(world_name)
666 .expect("Entity tried to pathfind but the entity isn't in a valid world");
667 let origin = event.start;
668 let successors_fn: moves::SuccessorsFn = event.successors_fn;
669 let cached_world = CachedWorld::new(world_lock, origin);
670 let mining_cache = MiningCache::new(if event.allow_mining {
671 Some(inventory.inventory_menu.clone())
672 } else {
673 None
674 });
675 let custom_state = custom_state.cloned().unwrap_or_default();
676 let custom_state_ref = custom_state.0.read();
677 let successors = |pos: RelBlockPos| {
678 call_successors_fn(
679 &cached_world,
680 &mining_cache,
681 &custom_state_ref,
682 successors_fn,
683 pos,
684 )
685 };
686
687 if let Some(first_node_of_new_path) = found_path.front() {
688 let last_target_of_current_path = RelBlockPos::from_origin(
689 origin,
690 last_node_of_current_path.movement.target,
691 );
692 let first_target_of_new_path = RelBlockPos::from_origin(
693 origin,
694 first_node_of_new_path.movement.target,
695 );
696
697 let successors = successors(last_target_of_current_path);
698 if successors
699 .iter()
700 .any(|edge| edge.movement.target == first_target_of_new_path)
701 {
702 debug!("combining old and new paths");
703 debug!(
704 "old path: {:?}",
705 executing_path.path.iter().collect::<Vec<_>>()
706 );
707 debug!(
708 "new path: {:?}",
709 found_path.iter().take(10).collect::<Vec<_>>()
710 );
711 new_path.extend(executing_path.path.iter().cloned());
712 } else {
713 debug!(
714 "failed to combine old and new paths. first_target_of_new_path: {first_target_of_new_path:?}, successors: {successors:?}"
715 )
716 }
717 } else {
718 new_path.extend(executing_path.path.iter().cloned());
719 }
720 }
721
722 new_path.extend(found_path.to_owned());
723
724 debug!(
725 "set queued path to {:?}",
726 new_path.iter().take(10).collect::<Vec<_>>()
727 );
728 executing_path.queued_path = Some(new_path);
729 executing_path.is_path_partial = event.is_partial;
730 } else if found_path.is_empty() {
731 debug!("calculated path is empty, so didn't add ExecutingPath");
732 if !pathfinder.opts.as_ref().is_some_and(|o| o.retry_on_no_path) {
733 debug!("retry_on_no_path is set to false, removing goal");
734 pathfinder.goal = None;
735 }
736 } else {
737 commands.entity(event.entity).insert(ExecutingPath {
738 path: found_path.to_owned(),
739 queued_path: None,
740 last_reached_node: event.start,
741 ticks_since_last_node_reached: 0,
742 is_path_partial: event.is_partial,
743 });
744 debug!(
745 "set path to {:?}",
746 found_path.iter().take(10).collect::<Vec<_>>()
747 );
748 debug!("partial: {}", event.is_partial);
749 }
750 } else {
751 error!("No path found");
752 if let Some(mut executing_path) = executing_path {
753 executing_path.queued_path = Some(VecDeque::new());
755 } else {
756 }
758 }
759 pathfinder.is_calculating = false;
760 }
761}
762
763#[derive(Message)]
764pub struct StopPathfindingEvent {
765 pub entity: Entity,
766 pub force: bool,
772}
773
774pub fn handle_stop_pathfinding_event(
775 mut events: MessageReader<StopPathfindingEvent>,
776 mut query: Query<(&mut Pathfinder, &mut ExecutingPath)>,
777 mut walk_events: MessageWriter<StartWalkEvent>,
778 mut commands: Commands,
779) {
780 for event in events.read() {
781 commands.entity(event.entity).remove::<ComputePath>();
783
784 let Ok((mut pathfinder, mut executing_path)) = query.get_mut(event.entity) else {
785 continue;
786 };
787 pathfinder.goal = None;
788 if event.force {
789 executing_path.path.clear();
790 executing_path.queued_path = None;
791 } else {
792 executing_path.queued_path = Some(VecDeque::new());
794 executing_path.is_path_partial = false;
796 }
797
798 if executing_path.path.is_empty() {
799 walk_events.write(StartWalkEvent {
800 entity: event.entity,
801 direction: WalkDirection::None,
802 });
803 commands.entity(event.entity).remove::<ExecutingPath>();
804 }
805 }
806}
807
808pub fn stop_pathfinding_on_world_change(
809 mut query: Query<(Entity, &mut ExecutingPath), Changed<WorldName>>,
810 mut stop_pathfinding_events: MessageWriter<StopPathfindingEvent>,
811) {
812 for (entity, mut executing_path) in &mut query {
813 if !executing_path.path.is_empty() {
814 debug!("world changed, clearing path");
815 executing_path.path.clear();
816 stop_pathfinding_events.write(StopPathfindingEvent {
817 entity,
818 force: true,
819 });
820 }
821 }
822}
823
824pub fn call_successors_fn(
825 cached_world: &CachedWorld,
826 mining_cache: &MiningCache,
827 custom_state: &CustomPathfinderStateRef,
828 successors_fn: SuccessorsFn,
829 pos: RelBlockPos,
830) -> Vec<astar::Edge<RelBlockPos, moves::MoveData>> {
831 let mut edges = Vec::with_capacity(16);
832 let mut ctx = MovesCtx {
833 edges: &mut edges,
834 world: cached_world,
835 mining_cache,
836 custom_state,
837 };
838 successors_fn(&mut ctx, pos);
839 edges
840}