Skip to main content

azalea/pathfinder/execute/
patching.rs

1use std::{
2    cmp,
3    collections::VecDeque,
4    ops::RangeInclusive,
5    sync::Arc,
6    time::{Duration, Instant},
7};
8
9use azalea_core::position::BlockPos;
10use azalea_entity::inventory::Inventory;
11use azalea_world::{WorldName, Worlds};
12use bevy_ecs::{
13    entity::Entity,
14    system::{Query, Res},
15};
16use parking_lot::RwLock;
17use tracing::{debug, error, warn};
18
19use crate::pathfinder::{
20    CalculatePathCtx, ExecutingPath, Pathfinder, PathfinderOpts,
21    astar::{self, PathfinderTimeout},
22    calculate_path, call_successors_fn,
23    custom_state::CustomPathfinderState,
24    goals::BlockPosGoal,
25    mining::MiningCache,
26    moves,
27    positions::RelBlockPos,
28    world::CachedWorld,
29};
30
31#[allow(clippy::type_complexity)]
32pub fn check_for_path_obstruction(
33    mut query: Query<(
34        Entity,
35        &mut Pathfinder,
36        &mut ExecutingPath,
37        &WorldName,
38        &Inventory,
39        Option<&CustomPathfinderState>,
40    )>,
41    worlds: Res<Worlds>,
42) {
43    query.par_iter_mut().for_each(|(entity, mut pathfinder, mut executing_path, world_name, inventory, custom_state)| {
44        let Some(opts) = pathfinder.opts.clone() else {
45            return;
46        };
47
48        let world_lock = worlds
49            .get(world_name)
50            .expect("Entity tried to pathfind but the entity isn't in a valid world");
51
52        // obstruction check (the path we're executing isn't possible anymore)
53        let origin = executing_path.last_reached_node;
54        let cached_world = CachedWorld::new(world_lock, origin);
55        let mining_cache = MiningCache::new(if opts.allow_mining {
56            Some(inventory.inventory_menu.clone())
57        } else {
58            None
59        });
60        let custom_state = custom_state.cloned().unwrap_or_default();
61        let custom_state_ref = custom_state.0.read();
62        let successors = |pos: RelBlockPos| {
63            call_successors_fn(
64                &cached_world,
65                &mining_cache,
66                &custom_state_ref,
67                opts.successors_fn,
68                pos,
69            )
70        };
71
72        // don't bother spending more than 10ms per tick on this
73        let timeout = Duration::from_millis(10);
74
75        let Some(obstructed_index) = check_path_obstructed(
76            origin,
77            RelBlockPos::from_origin(origin, executing_path.last_reached_node),
78            &executing_path.path,
79            successors,
80            timeout
81        ) else {
82            return;
83        };
84
85        drop(custom_state_ref);
86
87        warn!(
88            "path obstructed at index {obstructed_index} (starting at {:?})",
89            executing_path.last_reached_node,
90        );
91        debug!("obstructed path: {:?}", executing_path.path);
92        // if it's near the end, don't bother recalculating a patch, just truncate and
93        // mark it as partial
94        if obstructed_index + 5 > executing_path.path.len() {
95            debug!(
96                "obstruction is near the end of the path, truncating and marking path as partial"
97            );
98            executing_path.path.truncate(obstructed_index);
99            executing_path.is_path_partial = true;
100            return;
101        }
102
103        let Some(opts) = pathfinder.opts.clone() else {
104            error!("got PatchExecutingPathEvent but the bot has no pathfinder opts");
105            return;
106        };
107
108        let world_lock = worlds
109            .get(world_name)
110            .expect("Entity tried to pathfind but the entity isn't in a valid world");
111
112        // patch up to 20 nodes
113        let patch_end_index = cmp::min(obstructed_index + 20, executing_path.path.len() - 1);
114
115        patch_path(
116            obstructed_index..=patch_end_index,
117            &mut executing_path,
118            &mut pathfinder,
119            inventory,
120            entity,
121            world_lock,
122            custom_state.clone(),
123            opts,
124        );
125    });
126}
127
128/// Update the given [`ExecutingPath`] to recalculate the path of the nodes in
129/// the given index range.
130///
131/// You should avoid making the range too large, since the timeout for the A*
132/// calculation is very low. About 20 nodes is a good amount.
133#[allow(clippy::too_many_arguments)]
134pub fn patch_path(
135    patch_nodes: RangeInclusive<usize>,
136    executing_path: &mut ExecutingPath,
137    pathfinder: &mut Pathfinder,
138    inventory: &Inventory,
139    entity: Entity,
140    world_lock: Arc<RwLock<azalea_world::World>>,
141    custom_state: CustomPathfinderState,
142    opts: PathfinderOpts,
143) {
144    let patch_start = if *patch_nodes.start() == 0 {
145        executing_path.last_reached_node
146    } else {
147        executing_path.path[*patch_nodes.start() - 1]
148            .movement
149            .target
150    };
151
152    let patch_end = executing_path.path[*patch_nodes.end()].movement.target;
153
154    // this doesn't override the main goal, it's just the goal for this A*
155    // calculation
156    let goal = Arc::new(BlockPosGoal(patch_end));
157
158    let goto_id_atomic = pathfinder.goto_id.clone();
159    let allow_mining = opts.allow_mining;
160
161    let mining_cache = MiningCache::new(if allow_mining {
162        Some(inventory.inventory_menu.clone())
163    } else {
164        None
165    });
166
167    // the timeout is small enough that this doesn't need to be async
168    let path_found_event = calculate_path(CalculatePathCtx {
169        entity,
170        start: patch_start,
171        goal,
172        world_lock,
173        goto_id_atomic,
174        mining_cache,
175        custom_state,
176        opts: PathfinderOpts {
177            min_timeout: PathfinderTimeout::Nodes(10_000),
178            max_timeout: PathfinderTimeout::Nodes(10_000),
179            ..opts
180        },
181    });
182
183    // this is necessary in case we interrupted another ongoing path calculation
184    pathfinder.is_calculating = false;
185
186    debug!("obstruction patch: {path_found_event:?}");
187
188    let mut new_path = VecDeque::new();
189    if *patch_nodes.start() > 0 {
190        new_path.extend(
191            executing_path
192                .path
193                .iter()
194                .take(*patch_nodes.start())
195                .cloned(),
196        );
197    }
198
199    let mut is_patch_complete = false;
200    if let Some(path_found_event) = path_found_event {
201        if let Some(found_path_patch) = path_found_event.path
202            && !found_path_patch.is_empty()
203        {
204            new_path.extend(found_path_patch);
205
206            if !path_found_event.is_partial {
207                new_path.extend(executing_path.path.iter().skip(*patch_nodes.end()).cloned());
208                is_patch_complete = true;
209                debug!("the patch is not partial :)");
210            } else {
211                debug!("the patch is partial, throwing away rest of path :(");
212            }
213        }
214    } else {
215        // no path found, rip
216    }
217
218    executing_path.path = new_path;
219    if !is_patch_complete {
220        executing_path.is_path_partial = true;
221    }
222}
223
224/// Checks whether the path has been obstructed, and returns Some(index) if it
225/// has been.
226///
227/// The index is of the first obstructed node.
228pub fn check_path_obstructed<SuccessorsFn>(
229    origin: BlockPos,
230    mut current_position: RelBlockPos,
231    path: &VecDeque<astar::Edge<BlockPos, moves::MoveData>>,
232    successors_fn: SuccessorsFn,
233    timeout: Duration,
234) -> Option<usize>
235where
236    SuccessorsFn: Fn(RelBlockPos) -> Vec<astar::Edge<RelBlockPos, moves::MoveData>>,
237{
238    let start_time = Instant::now();
239
240    for (i, edge) in path.iter().enumerate() {
241        if start_time.elapsed() > timeout {
242            break;
243        }
244
245        let movement_target = RelBlockPos::from_origin(origin, edge.movement.target);
246
247        let mut found_edge = None;
248        for candidate_edge in successors_fn(current_position) {
249            if candidate_edge.movement.target == movement_target {
250                found_edge = Some(candidate_edge);
251                break;
252            }
253        }
254
255        current_position = movement_target;
256        // if found_edge is None or the cost increased, then return the index
257        if found_edge
258            .map(|found_edge| found_edge.cost > edge.cost)
259            .unwrap_or(true)
260        {
261            // if the node that we're currently executing was obstructed then it's often too
262            // late to change the path, so it's usually better to just ignore this case :/
263            if i == 0 {
264                warn!("path obstructed at index 0 ({edge:?}), ignoring");
265                continue;
266            }
267
268            return Some(i);
269        }
270    }
271
272    None
273}