azalea/pathfinder/
mod.rs

1//! A pathfinding plugin to make bots able to traverse the world.
2//!
3//! For the new functions on `Client` that the pathfinder adds, see
4//! [`PathfinderClientExt`].
5//!
6//! Note that the pathfinder is highly optimized, but it will be very slow if
7//! it's not compiled with optimizations enabled.
8//!
9//! For smoother and more realistic path execution, also see
10//! [`SimulationPathfinderExecutionPlugin`].
11//!
12//! Much of the pathfinder's code is based on [Baritone](https://github.com/cabaletta/baritone). <3
13//!
14//! [`SimulationPathfinderExecutionPlugin`]: execute::simulation::SimulationPathfinderExecutionPlugin
15
16pub 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, AtomicUsize},
36    },
37    thread,
38    time::{Duration, Instant},
39};
40
41use astar::Edge;
42use azalea_client::{StartWalkEvent, inventory::InventorySystems, movement::MoveEventsSystems};
43use azalea_core::{
44    position::{BlockPos, Vec3},
45    tick::GameTick,
46};
47use azalea_entity::{LocalEntity, Position, inventory::Inventory, metadata::Player};
48use azalea_world::{WorldName, Worlds};
49use bevy_app::{PreUpdate, Update};
50use bevy_ecs::prelude::*;
51use bevy_tasks::{AsyncComputeTaskPool, Task};
52use custom_state::{CustomPathfinderState, CustomPathfinderStateRef};
53use futures_lite::future;
54pub use goto_event::{GotoEvent, PathfinderOpts};
55use parking_lot::RwLock;
56use positions::RelBlockPos;
57use tokio::sync::broadcast::error::RecvError;
58use tracing::{debug, error, info, warn};
59
60use self::{
61    debug::debug_render_path_with_particles, goals::Goal, mining::MiningCache, moves::SuccessorsFn,
62};
63use crate::{
64    Client, WalkDirection,
65    app::{App, Plugin},
66    ecs::{
67        component::Component,
68        entity::Entity,
69        query::{With, Without},
70        system::{Commands, Query, Res},
71    },
72    pathfinder::{
73        astar::a_star, execute::DefaultPathfinderExecutionPlugin, moves::MovesCtx,
74        world::CachedWorld,
75    },
76};
77
78#[derive(Clone, Default)]
79pub struct PathfinderPlugin;
80impl Plugin for PathfinderPlugin {
81    fn build(&self, app: &mut App) {
82        app.add_message::<GotoEvent>()
83            .add_message::<PathFoundEvent>()
84            .add_message::<StopPathfindingEvent>()
85            .add_systems(GameTick, debug_render_path_with_particles)
86            .add_systems(PreUpdate, add_default_pathfinder)
87            .add_systems(
88                Update,
89                (
90                    goto_listener,
91                    handle_tasks,
92                    stop_pathfinding_on_world_change,
93                    path_found_listener,
94                    handle_stop_pathfinding_event,
95                )
96                    .chain()
97                    .before(MoveEventsSystems)
98                    .before(InventorySystems),
99            )
100            .add_plugins(DefaultPathfinderExecutionPlugin);
101    }
102}
103
104/// A component that makes this client able to pathfind.
105#[derive(Clone, Component, Default)]
106#[non_exhaustive]
107pub struct Pathfinder {
108    pub goal: Option<Arc<dyn Goal>>,
109    pub opts: Option<PathfinderOpts>,
110    pub is_calculating: bool,
111    pub goto_id: Arc<AtomicUsize>,
112}
113
114/// A component that's present on clients that are actively following a
115/// pathfinder path.
116#[derive(Clone, Component)]
117pub struct ExecutingPath {
118    pub path: VecDeque<astar::Edge<BlockPos, moves::MoveData>>,
119    pub queued_path: Option<VecDeque<astar::Edge<BlockPos, moves::MoveData>>>,
120    pub last_reached_node: BlockPos,
121    // count ticks instead of using real time to make our timeouts more consistent, in case we lag
122    // and our ticks take a while
123    pub ticks_since_last_node_reached: usize,
124    pub is_path_partial: bool,
125}
126
127#[derive(Clone, Debug, Message)]
128#[non_exhaustive]
129pub struct PathFoundEvent {
130    pub entity: Entity,
131    pub start: BlockPos,
132    pub path: Option<VecDeque<astar::Edge<BlockPos, moves::MoveData>>>,
133    pub is_partial: bool,
134    pub successors_fn: SuccessorsFn,
135    pub allow_mining: bool,
136}
137
138#[allow(clippy::type_complexity)]
139pub fn add_default_pathfinder(
140    mut commands: Commands,
141    mut query: Query<Entity, (Without<Pathfinder>, With<LocalEntity>, With<Player>)>,
142) {
143    for entity in &mut query {
144        commands.entity(entity).insert(Pathfinder::default());
145    }
146}
147
148pub trait PathfinderClientExt {
149    /// Pathfind to the given goal and wait until either the target is reached
150    /// or the pathfinding is canceled.
151    ///
152    /// You can use [`Self::start_goto`] instead if you don't want to wait.
153    ///
154    /// ```
155    /// # use azalea::prelude::*;
156    /// # use azalea::{BlockPos, pathfinder::goals::BlockPosGoal};
157    /// # async fn example(bot: &Client) {
158    /// bot.goto(BlockPosGoal(BlockPos::new(0, 70, 0))).await;
159    /// # }
160    /// ```
161    fn goto(&self, goal: impl Goal + 'static) -> impl Future<Output = ()>;
162    /// Same as [`Self::goto`], but allows you to set custom options for
163    /// pathfinding, including disabling mining and setting custom moves.
164    ///
165    /// ```
166    /// # use azalea::prelude::*;
167    /// # use azalea::{BlockPos, pathfinder::{goals::BlockPosGoal, PathfinderOpts}};
168    /// # async fn example(bot: &Client) {
169    /// bot.goto_with_opts(
170    ///     BlockPosGoal(BlockPos::new(0, 70, 0)),
171    ///     PathfinderOpts::new().allow_mining(false),
172    /// )
173    /// .await;
174    /// # }
175    /// ```
176    fn goto_with_opts(
177        &self,
178        goal: impl Goal + 'static,
179        opts: PathfinderOpts,
180    ) -> impl Future<Output = ()>;
181    /// Start pathfinding to a given goal.
182    ///
183    /// ```
184    /// # use azalea::prelude::*;
185    /// # use azalea::{BlockPos, pathfinder::goals::BlockPosGoal};
186    /// # fn example(bot: &Client) {
187    /// bot.start_goto(BlockPosGoal(BlockPos::new(0, 70, 0)));
188    /// # }
189    /// ```
190    fn start_goto(&self, goal: impl Goal + 'static);
191    /// Same as [`Self::start_goto`], but allows you to set custom
192    /// options for pathfinding, including disabling mining and setting custom
193    /// moves.
194    ///
195    /// Also see [`Self::goto_with_opts`].
196    fn start_goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts);
197    /// Stop calculating a path, and stop moving once the current movement is
198    /// finished.
199    ///
200    /// This behavior exists to prevent the bot from taking damage if
201    /// `stop_pathfinding` was called while executing a parkour jump, but if
202    /// it's undesirable then you may want to consider using
203    /// [`Self::force_stop_pathfinding`] instead.
204    fn stop_pathfinding(&self);
205    /// Stop calculating a path and stop executing the current movement
206    /// immediately.
207    fn force_stop_pathfinding(&self);
208    /// Waits forever until the bot no longer has a pathfinder goal.
209    fn wait_until_goto_target_reached(&self) -> impl Future<Output = ()>;
210    /// Returns true if the pathfinder has no active goal and isn't calculating
211    /// a path.
212    fn is_goto_target_reached(&self) -> bool;
213    /// Whether the pathfinder is currently following a path.
214    ///
215    /// Also see [`Self::is_calculating_path`] and
216    /// [`Self::is_goto_target_reached`].
217    fn is_executing_path(&self) -> bool;
218    /// Whether the pathfinder is currently calculating a path.
219    ///
220    /// Also see [`Self::is_executing_path`] and
221    /// [`Self::is_goto_target_reached`].
222    fn is_calculating_path(&self) -> bool;
223}
224
225impl PathfinderClientExt for Client {
226    async fn goto(&self, goal: impl Goal + 'static) {
227        self.goto_with_opts(goal, PathfinderOpts::new()).await;
228    }
229    async fn goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts) {
230        self.start_goto_with_opts(goal, opts);
231        self.wait_until_goto_target_reached().await;
232    }
233    fn start_goto(&self, goal: impl Goal + 'static) {
234        self.start_goto_with_opts(goal, PathfinderOpts::new());
235    }
236    fn start_goto_with_opts(&self, goal: impl Goal + 'static, opts: PathfinderOpts) {
237        self.ecs
238            .write()
239            .write_message(GotoEvent::new(self.entity, goal, opts));
240    }
241    fn stop_pathfinding(&self) {
242        self.ecs.write().write_message(StopPathfindingEvent {
243            entity: self.entity,
244            force: false,
245        });
246    }
247    fn force_stop_pathfinding(&self) {
248        self.ecs.write().write_message(StopPathfindingEvent {
249            entity: self.entity,
250            force: true,
251        });
252    }
253
254    async fn wait_until_goto_target_reached(&self) {
255        // we do this to make sure the event got handled before we start checking
256        // is_goto_target_reached
257        self.wait_updates(1).await;
258
259        let mut tick_broadcaster = self.get_tick_broadcaster();
260        while !self.is_goto_target_reached() {
261            // check every tick
262            match tick_broadcaster.recv().await {
263                Ok(_) => (),
264                Err(RecvError::Closed) => return,
265                Err(err) => warn!("{err}"),
266            };
267        }
268    }
269    fn is_goto_target_reached(&self) -> bool {
270        self.get_component::<Pathfinder>()
271            .is_none_or(|p| p.goal.is_none() && !p.is_calculating)
272    }
273    fn is_executing_path(&self) -> bool {
274        self.get_component::<ExecutingPath>().is_some()
275    }
276    fn is_calculating_path(&self) -> bool {
277        self.get_component::<Pathfinder>()
278            .is_some_and(|p| p.is_calculating)
279    }
280}
281
282#[derive(Component)]
283pub struct ComputePath(Task<Option<PathFoundEvent>>);
284
285#[allow(clippy::type_complexity)]
286pub fn goto_listener(
287    mut commands: Commands,
288    mut events: MessageReader<GotoEvent>,
289    mut query: Query<(
290        &mut Pathfinder,
291        Option<&mut ExecutingPath>,
292        &Position,
293        &WorldName,
294        &Inventory,
295        Option<&CustomPathfinderState>,
296    )>,
297    worlds: Res<Worlds>,
298) {
299    let thread_pool = AsyncComputeTaskPool::get();
300
301    for event in events.read() {
302        let Ok((mut pathfinder, executing_path, position, world_name, inventory, custom_state)) =
303            query.get_mut(event.entity)
304        else {
305            warn!("got goto event for an entity that can't pathfind");
306            continue;
307        };
308
309        let cur_pos = player_pos_to_block_pos(**position);
310
311        if event.goal.success(cur_pos) {
312            // we're already at the goal, nothing to do
313            pathfinder.goal = None;
314            pathfinder.opts = None;
315            pathfinder.is_calculating = false;
316            debug!("already at goal, not pathfinding");
317            continue;
318        }
319
320        // we store the goal so it can be recalculated later if necessary
321        pathfinder.goal = Some(event.goal.clone());
322        pathfinder.opts = Some(event.opts.clone());
323        pathfinder.is_calculating = true;
324
325        let start = if let Some(mut executing_path) = executing_path
326            && { !executing_path.path.is_empty() }
327        {
328            // if we're currently pathfinding and got a goto event, start a little ahead
329
330            let executing_path_limit = 50;
331            // truncate the executing path so we can cleanly combine the two paths later
332            executing_path.path.truncate(executing_path_limit);
333
334            executing_path
335                .path
336                .back()
337                .expect("path was just checked to not be empty")
338                .movement
339                .target
340        } else {
341            cur_pos
342        };
343
344        if start == cur_pos {
345            info!("got goto {:?}, starting from {start:?}", event.goal);
346        } else {
347            info!(
348                "got goto {:?}, starting from {start:?} (currently at {cur_pos:?})",
349                event.goal,
350            );
351        }
352
353        let world_lock = worlds
354            .get(world_name)
355            .expect("Entity tried to pathfind but the entity isn't in a valid world");
356
357        let goal = event.goal.clone();
358        let entity = event.entity;
359
360        let goto_id_atomic = pathfinder.goto_id.clone();
361
362        let allow_mining = event.opts.allow_mining;
363        let mining_cache = MiningCache::new(if allow_mining {
364            Some(inventory.inventory_menu.clone())
365        } else {
366            None
367        });
368
369        let custom_state = custom_state.cloned().unwrap_or_default();
370        let opts = event.opts.clone();
371        let task = thread_pool.spawn(async move {
372            calculate_path(CalculatePathCtx {
373                entity,
374                start,
375                goal,
376                world_lock,
377                goto_id_atomic,
378                mining_cache,
379                custom_state,
380                opts,
381            })
382        });
383
384        commands.entity(event.entity).insert(ComputePath(task));
385    }
386}
387
388/// Convert a player position to a block position, used internally in the
389/// pathfinder.
390///
391/// This is almost the same as `BlockPos::from(position)`, except that non-full
392/// blocks are handled correctly.
393#[inline]
394pub fn player_pos_to_block_pos(position: Vec3) -> BlockPos {
395    // 0.5 to account for non-full blocks
396    BlockPos::from(position.up(0.5))
397}
398
399pub struct CalculatePathCtx {
400    pub entity: Entity,
401    pub start: BlockPos,
402    pub goal: Arc<dyn Goal>,
403    pub world_lock: Arc<RwLock<azalea_world::World>>,
404    pub goto_id_atomic: Arc<AtomicUsize>,
405    pub mining_cache: MiningCache,
406    pub custom_state: CustomPathfinderState,
407
408    pub opts: PathfinderOpts,
409}
410
411/// Calculate the [`PathFoundEvent`] for the given pathfinder options.
412///
413/// You usually want to just use [`PathfinderClientExt::goto`] or send a
414/// [`GotoEvent`] instead of calling this directly.
415///
416/// You are expected to immediately send the `PathFoundEvent` you received after
417/// calling this function. `None` will be returned if the pathfinding was
418/// interrupted by another path calculation.
419pub fn calculate_path(ctx: CalculatePathCtx) -> Option<PathFoundEvent> {
420    debug!("start: {:?}", ctx.start);
421
422    let goto_id = ctx.goto_id_atomic.fetch_add(1, atomic::Ordering::SeqCst) + 1;
423
424    let origin = ctx.start;
425    let cached_world = CachedWorld::new(ctx.world_lock, origin);
426    let successors = |pos: RelBlockPos| {
427        call_successors_fn(
428            &cached_world,
429            &ctx.mining_cache,
430            &ctx.custom_state.0.read(),
431            ctx.opts.successors_fn,
432            pos,
433        )
434    };
435
436    let start_time = Instant::now();
437
438    let astar::Path {
439        movements,
440        is_partial,
441        cost,
442    } = a_star(
443        RelBlockPos::get_origin(origin),
444        |n| ctx.goal.heuristic(n.apply(origin)),
445        successors,
446        |n| ctx.goal.success(n.apply(origin)),
447        ctx.opts.min_timeout,
448        ctx.opts.max_timeout,
449    );
450    let end_time = Instant::now();
451    debug!("partial: {is_partial:?}, cost: {cost}");
452    let duration = end_time - start_time;
453    if is_partial {
454        if movements.is_empty() {
455            info!("Pathfinder took {duration:?} (empty path)");
456        } else {
457            info!("Pathfinder took {duration:?} (incomplete path)");
458        }
459        // wait a bit so it's not a busy loop
460        thread::sleep(Duration::from_millis(100));
461    } else {
462        info!("Pathfinder took {duration:?}");
463    }
464
465    debug!("Path:");
466    for movement in &movements {
467        debug!("  {}", movement.target.apply(origin));
468    }
469
470    let path = movements.into_iter().collect::<VecDeque<_>>();
471
472    let goto_id_now = ctx.goto_id_atomic.load(atomic::Ordering::SeqCst);
473    if goto_id != goto_id_now {
474        // we must've done another goto while calculating this path, so throw it away
475        warn!("finished calculating a path, but it's outdated");
476        return None;
477    }
478
479    if path.is_empty() && is_partial {
480        debug!("this path is empty, we might be stuck :(");
481    }
482
483    let mut mapped_path = VecDeque::with_capacity(path.len());
484    let mut current_position = RelBlockPos::get_origin(origin);
485    for movement in path {
486        let mut found_edge = None;
487        for edge in successors(current_position) {
488            if edge.movement.target == movement.target {
489                found_edge = Some(edge);
490                break;
491            }
492        }
493
494        let found_edge = found_edge.expect(
495            "path should always still be possible because we're using the same world cache",
496        );
497        current_position = found_edge.movement.target;
498
499        // we don't just clone the found_edge because we're using BlockPos instead of
500        // RelBlockPos as the target type
501        mapped_path.push_back(Edge {
502            movement: astar::Movement {
503                target: movement.target.apply(origin),
504                data: movement.data,
505            },
506            cost: found_edge.cost,
507        });
508    }
509
510    Some(PathFoundEvent {
511        entity: ctx.entity,
512        start: ctx.start,
513        path: Some(mapped_path),
514        is_partial,
515        successors_fn: ctx.opts.successors_fn,
516        allow_mining: ctx.opts.allow_mining,
517    })
518}
519
520// poll the tasks and send the PathFoundEvent if they're done
521pub fn handle_tasks(
522    mut commands: Commands,
523    mut transform_tasks: Query<(Entity, &mut ComputePath)>,
524    mut path_found_events: MessageWriter<PathFoundEvent>,
525) {
526    for (entity, mut task) in &mut transform_tasks {
527        if let Some(optional_path_found_event) = future::block_on(future::poll_once(&mut task.0)) {
528            if let Some(path_found_event) = optional_path_found_event {
529                path_found_events.write(path_found_event);
530            }
531
532            // Task is complete, so remove task component from entity
533            commands.entity(entity).remove::<ComputePath>();
534        }
535    }
536}
537
538// set the path for the target entity when we get the PathFoundEvent
539#[allow(clippy::type_complexity)]
540pub fn path_found_listener(
541    mut events: MessageReader<PathFoundEvent>,
542    mut query: Query<(
543        &mut Pathfinder,
544        Option<&mut ExecutingPath>,
545        &WorldName,
546        &Inventory,
547        Option<&CustomPathfinderState>,
548    )>,
549    worlds: Res<Worlds>,
550    mut commands: Commands,
551) {
552    for event in events.read() {
553        let Ok((mut pathfinder, executing_path, world_name, inventory, custom_state)) =
554            query.get_mut(event.entity)
555        else {
556            debug!("got path found event for an entity that can't pathfind");
557            continue;
558        };
559        if let Some(path) = &event.path {
560            if let Some(mut executing_path) = executing_path {
561                let mut new_path = VecDeque::new();
562
563                // combine the old and new paths if the first node of the new path is a
564                // successor of the last node of the old path
565                if let Some(last_node_of_current_path) = executing_path.path.back() {
566                    let world_lock = worlds
567                        .get(world_name)
568                        .expect("Entity tried to pathfind but the entity isn't in a valid world");
569                    let origin = event.start;
570                    let successors_fn: moves::SuccessorsFn = event.successors_fn;
571                    let cached_world = CachedWorld::new(world_lock, origin);
572                    let mining_cache = MiningCache::new(if event.allow_mining {
573                        Some(inventory.inventory_menu.clone())
574                    } else {
575                        None
576                    });
577                    let custom_state = custom_state.cloned().unwrap_or_default();
578                    let custom_state_ref = custom_state.0.read();
579                    let successors = |pos: RelBlockPos| {
580                        call_successors_fn(
581                            &cached_world,
582                            &mining_cache,
583                            &custom_state_ref,
584                            successors_fn,
585                            pos,
586                        )
587                    };
588
589                    if let Some(first_node_of_new_path) = path.front() {
590                        let last_target_of_current_path = RelBlockPos::from_origin(
591                            origin,
592                            last_node_of_current_path.movement.target,
593                        );
594                        let first_target_of_new_path = RelBlockPos::from_origin(
595                            origin,
596                            first_node_of_new_path.movement.target,
597                        );
598
599                        if successors(last_target_of_current_path)
600                            .iter()
601                            .any(|edge| edge.movement.target == first_target_of_new_path)
602                        {
603                            debug!("combining old and new paths");
604                            debug!(
605                                "old path: {:?}",
606                                executing_path.path.iter().collect::<Vec<_>>()
607                            );
608                            debug!("new path: {:?}", path.iter().take(10).collect::<Vec<_>>());
609                            new_path.extend(executing_path.path.iter().cloned());
610                        }
611                    } else {
612                        new_path.extend(executing_path.path.iter().cloned());
613                    }
614                }
615
616                new_path.extend(path.to_owned());
617
618                debug!(
619                    "set queued path to {:?}",
620                    new_path.iter().take(10).collect::<Vec<_>>()
621                );
622                executing_path.queued_path = Some(new_path);
623                executing_path.is_path_partial = event.is_partial;
624            } else if path.is_empty() {
625                debug!("calculated path is empty, so didn't add ExecutingPath");
626                if !pathfinder.opts.as_ref().is_some_and(|o| o.retry_on_no_path) {
627                    debug!("retry_on_no_path is set to false, removing goal");
628                    pathfinder.goal = None;
629                }
630            } else {
631                commands.entity(event.entity).insert(ExecutingPath {
632                    path: path.to_owned(),
633                    queued_path: None,
634                    last_reached_node: event.start,
635                    ticks_since_last_node_reached: 0,
636                    is_path_partial: event.is_partial,
637                });
638                debug!("set path to {:?}", path.iter().take(10).collect::<Vec<_>>());
639                debug!("partial: {}", event.is_partial);
640            }
641        } else {
642            error!("No path found");
643            if let Some(mut executing_path) = executing_path {
644                // set the queued path so we don't stop in the middle of a move
645                executing_path.queued_path = Some(VecDeque::new());
646            } else {
647                // wasn't executing a path, don't need to do anything
648            }
649        }
650        pathfinder.is_calculating = false;
651    }
652}
653
654#[derive(Message)]
655pub struct StopPathfindingEvent {
656    pub entity: Entity,
657    /// Whether we should stop moving immediately without waiting for the
658    /// current movement to finish.
659    ///
660    /// This is usually set to false, since it might cause the bot to fall if it
661    /// was in the middle of parkouring.
662    pub force: bool,
663}
664
665pub fn handle_stop_pathfinding_event(
666    mut events: MessageReader<StopPathfindingEvent>,
667    mut query: Query<(&mut Pathfinder, &mut ExecutingPath)>,
668    mut walk_events: MessageWriter<StartWalkEvent>,
669    mut commands: Commands,
670) {
671    for event in events.read() {
672        // stop computing any path that's being computed
673        commands.entity(event.entity).remove::<ComputePath>();
674
675        let Ok((mut pathfinder, mut executing_path)) = query.get_mut(event.entity) else {
676            continue;
677        };
678        pathfinder.goal = None;
679        if event.force {
680            executing_path.path.clear();
681            executing_path.queued_path = None;
682        } else {
683            // switch to an empty path as soon as it can
684            executing_path.queued_path = Some(VecDeque::new());
685            // make sure it doesn't recalculate
686            executing_path.is_path_partial = false;
687        }
688
689        if executing_path.path.is_empty() {
690            walk_events.write(StartWalkEvent {
691                entity: event.entity,
692                direction: WalkDirection::None,
693            });
694            commands.entity(event.entity).remove::<ExecutingPath>();
695        }
696    }
697}
698
699pub fn stop_pathfinding_on_world_change(
700    mut query: Query<(Entity, &mut ExecutingPath), Changed<WorldName>>,
701    mut stop_pathfinding_events: MessageWriter<StopPathfindingEvent>,
702) {
703    for (entity, mut executing_path) in &mut query {
704        if !executing_path.path.is_empty() {
705            debug!("world changed, clearing path");
706            executing_path.path.clear();
707            stop_pathfinding_events.write(StopPathfindingEvent {
708                entity,
709                force: true,
710            });
711        }
712    }
713}
714
715pub fn call_successors_fn(
716    cached_world: &CachedWorld,
717    mining_cache: &MiningCache,
718    custom_state: &CustomPathfinderStateRef,
719    successors_fn: SuccessorsFn,
720    pos: RelBlockPos,
721) -> Vec<astar::Edge<RelBlockPos, moves::MoveData>> {
722    let mut edges = Vec::with_capacity(16);
723    let mut ctx = MovesCtx {
724        edges: &mut edges,
725        world: cached_world,
726        mining_cache,
727        custom_state,
728    };
729    successors_fn(&mut ctx, pos);
730    edges
731}