screeps_pathfinding/algorithms/
astar.rs

1// https://en.wikipedia.org/wiki/A*_search_algorithm
2
3// Sample code pulled (and modified) from: https://doc.rust-lang.org/nightly/std/collections/binary_heap/index.html#examples
4
5use std::cmp::Ordering;
6use std::collections::{
7    hash_map::Entry,
8    {BinaryHeap, HashMap},
9};
10use std::hash::Hash;
11
12use num::CheckedAdd;
13
14use screeps::constants::Direction;
15use screeps::{Position, RoomXY};
16
17use crate::common::traits::AddDirection;
18use crate::utils::goals::goal_exact_node_multigoal;
19use crate::utils::heuristics::heuristic_get_range_to_multigoal;
20
21/// A simple trait encapsulating what other traits are needed
22/// for a type to be usable in the A* Algorithm.
23pub trait AStarNode: Eq + Hash + Copy + Ord {}
24impl<T> AStarNode for T where T: Eq + Hash + Copy + Ord {}
25
26/// Extends the AStarNode trait to also include the requirement for adding a direction, which is
27/// sufficient to define the 8-connected grid that we use in Screeps.
28pub trait AStarGridNode: AStarNode + AddDirection + std::fmt::Debug {}
29impl<T> AStarGridNode for T where T: AStarNode + AddDirection + std::fmt::Debug {}
30
31/// Helper trait for defining how we use generic costs in A*, allowing users to bring their own
32/// costs without being locked into e.g. u32.
33pub trait AStarCost:
34    std::ops::Add<Self, Output = Self>
35    + CheckedAdd
36    + Copy
37    + Eq
38    + Sized
39    + std::cmp::Ord
40    + std::fmt::Debug
41{
42}
43impl<T> AStarCost for T where
44    T: std::ops::Add<Self, Output = Self>
45        + CheckedAdd
46        + Copy
47        + Eq
48        + Sized
49        + std::cmp::Ord
50        + std::fmt::Debug
51{
52}
53
54#[derive(Debug)]
55pub struct AStarSearchResults<T, O>
56where
57    T: AStarNode,
58    O: AStarCost,
59{
60    ops_used: u32,
61    cost: Option<O>,
62    incomplete: bool,
63    path: Vec<T>,
64}
65
66impl<T: AStarNode, O: AStarCost> AStarSearchResults<T, O> {
67    /// The number of expand node operations used
68    pub fn ops(&self) -> u32 {
69        self.ops_used
70    }
71
72    /// The movement cost of the result path
73    pub fn cost(&self) -> Option<O> {
74        self.cost
75    }
76
77    /// Whether the path contained is incomplete
78    pub fn incomplete(&self) -> bool {
79        self.incomplete
80    }
81
82    /// A shortest path from the start node to the goal node
83    pub fn path(&self) -> &[T] {
84        &self.path
85    }
86}
87
88/// A helper function to compare costs that are wrapped as Options.
89///
90/// If both are None, they're considered equal.
91/// If one is Some, that is considered the smaller value (None == Infinity).
92/// If both are Some, compare them directly.
93fn compare_option_scores<O: AStarCost>(a: Option<O>, b: Option<O>) -> Ordering {
94    match (a, b) {
95        (None, None) => Ordering::Equal,
96        (Some(_), None) => Ordering::Less,
97        (None, Some(_)) => Ordering::Greater,
98        (Some(l), Some(r)) => l.cmp(&r),
99    }
100}
101
102#[derive(Debug, Copy, Clone, Eq, PartialEq)]
103struct State<T, O>
104where
105    T: Ord,
106    O: AStarCost,
107{
108    /// cost to reach this position (the g_score in A* terminology)
109    g_score: O,
110    /// f_score is the sum of the known cost to reach this position (the g_score) plus the estimated cost remaining from this position in the best possible case
111    f_score: O,
112    /// the actual position
113    position: T,
114}
115
116// The priority queue depends on `Ord`.
117// Explicitly implement the trait so the queue becomes a min-heap
118// instead of a max-heap.
119impl<T, O> Ord for State<T, O>
120where
121    T: Ord,
122    O: AStarCost,
123{
124    fn cmp(&self, other: &Self) -> Ordering {
125        // Notice that we flip the ordering on costs for f-scores, to convert the queue into a min-heap
126        // instead of a max-heap.
127        other.f_score.cmp(&self.f_score).then_with(|| {
128            // If the f-scores are equal, then we should tie-break with their g-scores;
129            // this doesn't affect optimality, but it's a well-known optimization in the
130            // literature to prefer the node with the higher g-score, since that one is almost
131            // certainly closer to the target (same f && higher g -> lower h).
132            self.g_score.cmp(&other.g_score).then_with(|| {
133                // If the f and g scores are all equal, then we compare the positions of the nodes;
134                // this gives us a guaranteed total ordering, which makes the implementations of
135                // `PartialEq` and `Ord` consistent.
136                self.position.cmp(&other.position)
137            })
138        })
139    }
140}
141
142// `PartialOrd` needs to be implemented as well.
143impl<T, O> PartialOrd for State<T, O>
144where
145    T: Ord,
146    O: AStarCost,
147{
148    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
149        Some(self.cmp(other))
150    }
151}
152
153#[derive(Debug, Copy, Clone, Eq, PartialEq)]
154struct GridState<T, O>
155where
156    T: Ord,
157    O: AStarCost,
158{
159    /// cost to reach this position (the g_score in A* terminology)
160    g_score: Option<O>,
161    /// f_score is the sum of the known cost to reach this position (the g_score) plus the estimated cost remaining from this position in the best possible case
162    f_score: Option<O>,
163    /// track the direction this entry was opened from, so that we're able to check
164    /// only optimal moves (3 or 5 positions instead of all 8)
165    open_direction: Option<Direction>,
166    position: T,
167}
168
169// The priority queue depends on `Ord`.
170// Explicitly implement the trait so the queue becomes a min-heap
171// instead of a max-heap.
172impl<T, O> Ord for GridState<T, O>
173where
174    T: Ord,
175    O: AStarCost,
176{
177    fn cmp(&self, other: &Self) -> Ordering {
178        // Notice that we flip the ordering on costs for f-scores, to convert the queue into a min-heap
179        // instead of a max-heap.
180        compare_option_scores(other.f_score, self.f_score).then_with(|| {
181            // If the f-scores are equal, then we should tie-break with their g-scores;
182            // this doesn't affect optimality, but it's a well-known optimization in the
183            // literature to prefer the node with the higher g-score, since that one is almost
184            // certainly closer to the target (same f && higher g -> lower h).
185            compare_option_scores(self.g_score, other.g_score).then_with(|| {
186                // If the f and g scores are all equal, then we compare the positions of the nodes;
187                // this gives us a guaranteed total ordering, which makes the implementations of
188                // `PartialEq` and `Ord` consistent.
189                self.position.cmp(&other.position)
190            })
191        })
192    }
193}
194
195// `PartialOrd` needs to be implemented as well.
196impl<T, O> PartialOrd for GridState<T, O>
197where
198    T: Ord,
199    O: AStarCost,
200{
201    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
202        Some(self.cmp(other))
203    }
204}
205
206/// Handles node expansion into the open set for graph-search A*.
207///
208/// `start` is the node that is currently being expanded
209/// `g_score` is the cost to get to the `start` node so far during our search
210/// `neighbors` is the new nodes being considered for addition to the open set, as well as their f and g scores
211/// `heap` this is the open set of nodes that are candidates for exploration/expansion
212/// `parents` this is a lookup table from nodes to their parents
213fn expand_neighbors<T: AStarNode, O: AStarCost>(
214    start: T,
215    neighbors: &[(T, O, O)],
216    heap: &mut BinaryHeap<State<T, O>>,
217    parents: &mut HashMap<T, T>,
218) {
219    for (neighbor, next_f_score, next_g_score) in neighbors {
220        if let Entry::Vacant(v) = parents.entry(*neighbor) {
221            v.insert(start);
222            heap.push(State {
223                g_score: *next_g_score,
224                f_score: *next_f_score,
225                position: *neighbor,
226            });
227        }
228    }
229}
230
231/// Handles node expansion into the open set for grid-search A*.
232///
233/// `start` is the node that is currently being expanded
234/// `g_score` is the cost to get to the `start` node so far during our search
235/// `neighbors` is the new nodes being considered for addition to the open set, paired with the direction of movement from `start` to the neighbor
236/// `cost_fn` is a function that takes a pair of nodes and returns the cost to move from the first node to the second node, or None if the transition is invalid
237/// `heuristic_fn` is a function that takes a node and returns the estimated cost to move from that node to the final goal node
238/// `heap` this is the open set of nodes that are candidates for exploration/expansion
239/// `parents` this is a lookup table from nodes to their parents
240#[allow(clippy::too_many_arguments)]
241fn expand_grid_neighbors<T: AStarGridNode, G, F, O>(
242    start: T,
243    g_score: O,
244    neighbors: &[(T, Direction)],
245    cost_fn: G,
246    heuristic_fn: F,
247    heap: &mut BinaryHeap<GridState<T, O>>,
248    lowest_seen_g_scores: &mut HashMap<T, O>,
249    parents: &mut HashMap<T, T>,
250) where
251    G: Fn(T, T) -> Option<O>,
252    F: Fn(T) -> O,
253    O: AStarCost,
254{
255    // let debug_print = true;
256    // if (start.x.u8() == 25) & (start.y.u8() == 10) {
257    //     // Divergence node: RoomXY { x: RoomCoordinate(25), y: RoomCoordinate(10) },
258    //     println!("Expanding neighbors for divergence node");
259    //     debug_print = true;
260    // }
261    // println!("Expanding neighbors for: {:?}", start);
262
263    for (neighbor, direction) in neighbors {
264        // Do this as a match statement, with this as the branch for vacant; the occupied branch
265        // needs to do g-score checking, and then if the g-score is smaller than the lowest seen,
266        // add that node to the heap
267        if let Some(next_tile_cost) = cost_fn(start, *neighbor) {
268            if let Some(next_g_score) = g_score.checked_add(&next_tile_cost) {
269                let add_state_to_heap: bool = match parents.entry(*neighbor) {
270                    Entry::Occupied(mut v) => {
271                        // Check the g_score of the neighbor we're examining against the smallest
272                        // we've seen for that node; if the current g_score is smaller, then we
273                        // want to add an updated copy of the node to the heap with the new
274                        // g_score.
275                        //
276                        // We *could* modify the existing entry in the heap, but that would require
277                        // us to convert the heap into an iterable, modify the entry there, and
278                        // rebuild the heap. For a once-in-a-blue-moon thing, not terrible, but
279                        // this can happen regularly, so we want to avoid all that work. The
280                        // trade-off is that we have to filter those higher g_score nodes when
281                        // we're popping those node entries off of the heap to explore the
282                        // frontier.
283                        match lowest_seen_g_scores.entry(*neighbor) {
284                            Entry::Vacant(score_entry) => {
285                                // This should never happen, but for safety and sanity, just add the
286                                // node to the frontier heap anyway.
287                                score_entry.insert(next_g_score);
288                                v.insert(start);
289                                true
290                            }
291                            Entry::Occupied(mut score_entry) => {
292                                if next_g_score < *score_entry.get() {
293                                    // We've arrived at the node via a shorter path, adjust its
294                                    // lowest score entry and parent pointer
295                                    let _ = score_entry.insert(next_g_score);
296                                    let _ = v.insert(start);
297                                    true
298                                } else {
299                                    // We've arrived at the node via a same-length or longer path,
300                                    // this can't ever be part of a shorter path, don't add it to
301                                    // the frontier
302                                    false
303                                }
304                            }
305                        }
306                    }
307                    Entry::Vacant(v) => {
308                        // We've never seen this node before, add it to the frontier
309                        lowest_seen_g_scores.insert(*neighbor, next_g_score);
310                        v.insert(start);
311                        true
312                    }
313                };
314
315                if add_state_to_heap {
316                    let raw_h_score = heuristic_fn(*neighbor);
317                    let h_score = raw_h_score;
318                    if let Some(f_score) = next_g_score.checked_add(&h_score) {
319                        // if debug_print {
320                        //     println!("Node: {:?}, g: {:?}, h: {:?}, f: {:?}", neighbor, next_g_score, h_score, f_score);
321                        // }
322
323                        heap.push(GridState {
324                            g_score: Some(next_g_score),
325                            f_score: Some(f_score),
326                            position: *neighbor,
327                            open_direction: Some(*direction),
328                        });
329                    } else {
330                        // We've saturated the cost add, skip this neighbor
331                        continue;
332                    }
333                }
334            } else {
335                // The g_score cost add has saturated, skip this neighbor
336                continue;
337            }
338        } else {
339            // No cost function output, skip this neighbor
340            continue;
341        }
342    }
343}
344
345// fn generate_neighbors_direction_flatten_heuristic<T, H, O>(start: T, raw_node_heuristic: H) -> impl Fn(T) -> O
346// where
347//   T: AStarGridNode,
348//   H: Fn(T) -> O,
349//   O: AStarCost + std::ops::Sub<u32, Output = O>,
350// {
351//     let all_directions_iter = Direction::iter();
352//     let diagonal_neighbors: Vec<T> = all_directions_iter
353//         .filter(|d| d.is_diagonal())
354//         .flat_map(|d| start.checked_add_direction(*d))
355//         .collect();
356//
357//     move |node: T| {
358//         // We always need to calculate the f_score for a node
359//         let raw_f_score = raw_node_heuristic(node);
360//         match diagonal_neighbors.contains(|n| *n == node) {
361//             true => raw_f_score - 1, // If we find the node is a diagonal move, return the f_score slightly downgraded
362//             false => raw_f_score, // The node isn't a diagonal move, so return the f_score unchanged
363//         }
364//     }
365// }
366
367/// Highly-generic implementation of A* search algorithm on a grid.
368///
369/// Allows multiple starting nodes, a generic goal function,
370/// generic cost and heuristic functions, control over
371/// the maximum operations performed while searching,
372/// and the maximum travel cost the final path can have.
373///
374/// Generally, you should not need to use this directly; use
375/// one of the convenience functions instead:
376/// [shortest_path_roomxy_single_goal] and [shortest_path_roomxy_multiple_goals]
377///
378/// Parameters:
379///
380/// - `start`: A slice of the starting nodes for the search
381/// - `goal_fn`: A predicate function that takes a node and returns true if the node is a goal node
382/// - `cost_fn`: A function that takes in the current node and a neighbor node and returns the cost of moving to that neighbor node, or None if the transition is invalid
383/// - `heuristic_fn`: A function that takes a node and returns the estimated cost of moving to the goal node from that node
384/// - `max_ops`: The maximum number of expand operations to perform before stopping search
385/// - `max_cost`: The maximum cost to allow for the final path before stopping search
386/// - `initial_cost`: The initial cost to start the search with
387///
388/// Note: This function assumes that the heuristic function is consistent, and optimizes
389/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
390/// suboptimal paths, just like if your heuristic function is not admissible.
391pub fn shortest_path_generic_grid<T: AStarGridNode, P, G, F, O>(
392    start: &[T],
393    goal_fn: &P,
394    cost_fn: G,
395    heuristic_fn: F,
396    max_ops: u32,
397    max_cost: O,
398    initial_cost: O,
399) -> AStarSearchResults<T, O>
400where
401    P: Fn(T) -> bool,
402    G: Fn(T, T) -> Option<O>,
403    F: Fn(T) -> O,
404    O: AStarCost,
405{
406    // let all_directions_iter = Direction::iter();
407    // let diagonal_neighbors: Vec<T> = all_directions_iter
408    //     .filter(|d| d.is_diagonal())
409    //     .flat_map(|d| start.checked_add_direction(*d))
410    //     .collect();
411
412    let mut remaining_ops: u32 = max_ops;
413    let mut best_reached = start[0];
414    let mut best_reached_f_score: Option<O> = None;
415
416    // Build this once, instead of repeatedly inside the node expansions loop
417    let all_directions_iter = Direction::iter();
418    let all_directions = all_directions_iter.as_slice();
419
420    // This is a tree of parent pointers, used to reconstruct the final path
421    let mut parents: HashMap<T, T> = HashMap::new();
422
423    // This is the open set, the frontier of nodes we have yet to explore in our search
424    let mut heap = BinaryHeap::new();
425
426    // This is a lookup table of the lowest g-score we've seen for any particular node, used to
427    // determine whether we need to re-explore a node or not
428    let mut lowest_seen_g_scores: HashMap<T, O> = HashMap::new();
429
430    for s in start.iter().copied() {
431        let initial_open_entry = GridState {
432            g_score: Some(initial_cost),
433            f_score: Some(heuristic_fn(s)),
434            position: s,
435            open_direction: None,
436        };
437        heap.push(initial_open_entry);
438
439        lowest_seen_g_scores.insert(s, initial_cost);
440    }
441
442    // Examine the frontier with lower cost nodes first (min-heap)
443    while let Some(GridState {
444        g_score: g_score_opt,
445        position,
446        f_score: f_score_opt,
447        open_direction,
448    }) = heap.pop()
449    {
450        // We found the goal state, return the search results
451        if goal_fn(position) {
452            let path_opt = get_path_from_parents(&parents, position);
453            return AStarSearchResults {
454                ops_used: max_ops - remaining_ops,
455                cost: g_score_opt,
456                incomplete: false,
457                path: path_opt.unwrap_or_else(|| Vec::new()),
458            };
459        }
460
461        if g_score_opt.is_none() {
462            // We've saturated the cost, skip this node
463            continue;
464        }
465
466        let g_score = g_score_opt.unwrap();
467
468        // Don't evaluate children if we're beyond the maximum cost
469        if g_score >= max_cost {
470            continue;
471        }
472
473        let should_skip_node: bool = match lowest_seen_g_scores.entry(position) {
474            Entry::Vacant(_) => {
475                // This should never happen, but for safety and sanity, expand the node
476                false
477            }
478            Entry::Occupied(score_entry) => {
479                if g_score > *score_entry.get() {
480                    // If the g-score for this node is higher than the lowest that we've seen, then
481                    // this is part of a suboptimal path and should be skipped
482                    true
483                } else {
484                    false
485                }
486            }
487        };
488
489        if should_skip_node {
490            continue;
491        }
492
493        if f_score_opt.is_none() {
494            // We've saturated the heuristic cost, skip this node
495            continue;
496        }
497
498        let f_score = f_score_opt.unwrap();
499
500        // if this is the most promising path yet, mark it as the point to use for rebuild
501        // if we have to return incomplete
502        // Safety: we can unwrap here because we already know it's not None
503        if best_reached_f_score.is_none_or(|brfs| f_score < brfs) {
504            best_reached = position;
505            best_reached_f_score = Some(f_score);
506        }
507
508        remaining_ops -= 1;
509
510        // Stop searching if we've run out of remaining ops we're allowed to perform
511        if remaining_ops == 0 {
512            break;
513        }
514
515        let directions: &[Direction] = if let Some(open_direction) = open_direction {
516            // we know what direction this tile was opened from; only explore the tiles
517            // that might potentially be optimal moves
518            if open_direction.is_diagonal() {
519                // diagonal move; 2 rotations away might be optimal, while other moves would always
520                // be more efficiently reached without traversing this tile:
521                // ↖↑↗
522                // ←●
523                // ↙ ↖
524                &[
525                    open_direction,
526                    open_direction.multi_rot(1),
527                    open_direction.multi_rot(-1),
528                    open_direction.multi_rot(2),
529                    open_direction.multi_rot(-2),
530                ]
531            } else {
532                // orthogonal move; only continuing straight or turning 45 degrees can be optimal
533                // and should be explored; 90 degree moves would be more efficient as diagonal
534                // moves from the parent tile without traversing this tile:
535                //   ↗
536                // →●→
537                //   ↘
538                &[
539                    open_direction,
540                    open_direction.multi_rot(1),
541                    open_direction.multi_rot(-1),
542                ]
543            }
544        } else {
545            // didn't start with a direction, this is probably the start tile; check all directions
546            all_directions
547        };
548
549        let neighbors: Vec<(T, Direction)> = directions
550            .iter()
551            .map(|d| (position.checked_add_direction(*d), *d))
552            .filter(|(opt, _)| opt.is_some())
553            .map(|(opt, d)| (opt.unwrap(), d))
554            .collect();
555
556        expand_grid_neighbors(
557            position,
558            g_score,
559            &neighbors,
560            &cost_fn,
561            &heuristic_fn,
562            &mut heap,
563            &mut lowest_seen_g_scores,
564            &mut parents,
565        );
566    }
567
568    // Goal not reachable
569    let path_opt = get_path_from_parents(&parents, best_reached);
570    AStarSearchResults {
571        ops_used: max_ops - remaining_ops,
572        cost: best_reached_f_score,
573        incomplete: true,
574        path: path_opt.unwrap_or_else(|| Vec::new()),
575    }
576}
577
578/// Highly-generic implementation of A* search algorithm on a graph.
579///
580/// Allows multiple starting nodes, a generic goal function,
581/// generic cost and heuristic functions, control over
582/// the maximum operations performed while searching,
583/// and the maximum travel cost the final path can have.
584///
585/// Parameters:
586///
587/// - `start`: A slice of the starting nodes for the search
588/// - `goal_fn`: A predicate function that takes a node and returns true if the node is a goal node
589/// - `neighbors_fn`: A function that takes in the current node and its g score and returns its neighbors, as well as their f and g scores
590/// - `max_ops`: The maximum number of expand operations to perform before stopping search
591/// - `max_cost`: The maximum cost to allow for the final path before stopping search
592/// - `initial_cost`: The initial cost to start the search with; this is the g_score
593/// - `initial_estimate`: The initial estimate to start the search with; this is the f_score
594///
595/// Note: This function assumes that the heuristic function is consistent, and optimizes
596/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
597/// suboptimal paths, just like if your heuristic function is not admissible.
598pub fn shortest_path_generic_graph<T: AStarNode, P, N, G, F, O>(
599    start: &[T],
600    goal_fn: &P,
601    neighbors_fn: &N,
602    max_ops: u32,
603    max_cost: O,
604    initial_cost: O,
605    initial_estimate: O,
606) -> AStarSearchResults<T, O>
607where
608    P: Fn(T) -> bool,
609    N: Fn(T, O) -> Vec<(T, O, O)>,
610    O: AStarCost,
611{
612    let mut remaining_ops: u32 = max_ops;
613    let mut best_reached = start[0];
614    let mut best_reached_f_score = initial_estimate;
615
616    let mut parents: HashMap<T, T> = HashMap::new();
617    let mut heap = BinaryHeap::new();
618
619    for s in start.iter().copied() {
620        let initial_open_entry = State {
621            g_score: initial_cost,
622            f_score: initial_estimate,
623            position: s,
624        };
625        heap.push(initial_open_entry);
626    }
627
628    // Examine the frontier with lower cost nodes first (min-heap)
629    while let Some(State {
630        g_score,
631        f_score,
632        position,
633    }) = heap.pop()
634    {
635        // We found the goal state, return the search results
636        if goal_fn(position) {
637            let path_opt = get_path_from_parents(&parents, position);
638            return AStarSearchResults {
639                ops_used: max_ops - remaining_ops,
640                cost: Some(g_score),
641                incomplete: false,
642                path: path_opt.unwrap_or_else(|| Vec::new()),
643            };
644        }
645
646        // Don't evaluate children if we're beyond the maximum cost
647        if g_score >= max_cost {
648            continue;
649        }
650
651        // if this is the most promising path yet, mark it as the point to use for rebuild
652        // if we have to return incomplete
653        // Safety: we can unwrap here because we already know it's not None
654        if f_score < best_reached_f_score {
655            best_reached = position;
656            best_reached_f_score = f_score;
657        }
658
659        remaining_ops -= 1;
660
661        // Stop searching if we've run out of remaining ops we're allowed to perform
662        if remaining_ops == 0 {
663            break;
664        }
665
666        let neighbors: Vec<(T, O, O)> = neighbors_fn(position, g_score);
667
668        expand_neighbors(position, &neighbors, &mut heap, &mut parents);
669    }
670
671    // Goal not reachable
672    let path_opt = get_path_from_parents(&parents, best_reached);
673    AStarSearchResults {
674        ops_used: max_ops - remaining_ops,
675        cost: Some(best_reached_f_score),
676        incomplete: true,
677        path: path_opt.unwrap_or_else(|| Vec::new()),
678    }
679}
680
681fn get_path_from_parents<T: AStarNode>(parents: &HashMap<T, T>, end: T) -> Option<Vec<T>> {
682    let mut path = Vec::new();
683
684    let mut current_pos = end;
685
686    path.push(end);
687
688    let mut parent_opt = parents.get(&current_pos);
689    while parent_opt.is_some() {
690        let parent = parent_opt.unwrap();
691        path.push(*parent);
692        current_pos = *parent;
693        parent_opt = parents.get(&current_pos);
694    }
695
696    Some(path.into_iter().rev().collect())
697}
698
699/// Convenience function for the common use-case of searching
700/// from a single starting node to a single goal node.
701///
702/// Uses sane default values for maximum operations and travel costs.
703/// For more fine-grained control, see: [shortest_path_generic_grid]
704///
705/// # Example
706/// ```rust
707/// use screeps::{LocalRoomTerrain, RoomXY};
708///
709/// let start = RoomXY::checked_new(24, 18).unwrap();
710/// let goal = RoomXY::checked_new(34, 40).unwrap();
711/// let room_terrain = LocalRoomTerrain::new_from_bits(Box::new([0; 2500])); // Terrain that's all plains
712/// let plain_cost = 1;
713/// let swamp_cost = 5;
714/// let costs = screeps_pathfinding::utils::movement_costs::get_movement_cost_lcm_from_terrain(&room_terrain, plain_cost, swamp_cost);
715/// let costs_fn = screeps_pathfinding::utils::movement_costs::astar_movement_costs_from_lcm(&costs);
716///
717/// let search_results = screeps_pathfinding::algorithms::astar::shortest_path_roomxy_single_goal(
718///     start,
719///     goal,
720///     costs_fn,
721/// );
722///
723/// if !search_results.incomplete() {
724///   let path = search_results.path();
725///   println!("Path: {:?}", path);
726/// }
727/// else {
728///   println!("Could not find A* shortest path.");
729///   println!("Search Results: {:?}", search_results);
730/// }
731/// ```
732pub fn shortest_path_roomxy_single_goal<G>(
733    start: RoomXY,
734    goal: RoomXY,
735    cost_fn: G,
736) -> AStarSearchResults<RoomXY, u32>
737where
738    G: Fn(RoomXY) -> Option<u32>,
739{
740    shortest_path_roomxy_multiple_goals(start, &[goal], cost_fn)
741}
742
743/// Convenience function for the common use-case of searching
744/// from a single starting node to multiple goal nodes.
745///
746/// Uses sane default values for maximum operations and travel costs.
747/// For more fine-grained control, see: [shortest_path_generic_grid]
748///
749/// # Example
750/// ```rust
751/// use screeps::{LocalRoomTerrain, RoomXY};
752///
753/// let start = RoomXY::checked_new(24, 18).unwrap();
754/// let goal_a = RoomXY::checked_new(34, 40).unwrap();
755/// let goal_b = RoomXY::checked_new(34, 45).unwrap();
756/// let room_terrain = LocalRoomTerrain::new_from_bits(Box::new([0; 2500])); // Terrain that's all plains
757/// let plain_cost = 1;
758/// let swamp_cost = 5;
759/// let costs = screeps_pathfinding::utils::movement_costs::get_movement_cost_lcm_from_terrain(&room_terrain, plain_cost, swamp_cost);
760/// let costs_fn = screeps_pathfinding::utils::movement_costs::astar_movement_costs_from_lcm(&costs);
761///
762/// let search_results = screeps_pathfinding::algorithms::astar::shortest_path_roomxy_multiple_goals(
763///     start,
764///     &[goal_a, goal_b],
765///     costs_fn,
766/// );
767///
768/// if !search_results.incomplete() {
769///   let path = search_results.path();
770///   println!("Path: {:?}", path);
771/// }
772/// else {
773///   println!("Could not find A* shortest path.");
774///   println!("Search Results: {:?}", search_results);
775/// }
776/// ```
777pub fn shortest_path_roomxy_multiple_goals<G>(
778    start: RoomXY,
779    goals: &[RoomXY],
780    cost_fn: G,
781) -> AStarSearchResults<RoomXY, u32>
782where
783    G: Fn(RoomXY) -> Option<u32>,
784{
785    shortest_path_roomxy_multistart(
786        &[start],
787        &goal_exact_node_multigoal(goals),
788        cost_fn,
789        &heuristic_get_range_to_multigoal(goals),
790    )
791}
792
793/// Convenience method for running single-start A* with default costs
794/// while still retaining control over the heuristic function used.
795///
796/// Note: This function assumes that the heuristic function is consistent, and optimizes
797/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
798/// suboptimal paths, just like if your heuristic function is not admissible.
799pub fn shortest_path_roomxy<P, G, F>(
800    start: RoomXY,
801    goal_fn: &P,
802    cost_fn: G,
803    heuristic_fn: &F,
804) -> AStarSearchResults<RoomXY, u32>
805where
806    P: Fn(RoomXY) -> bool,
807    G: Fn(RoomXY) -> Option<u32>,
808    F: Fn(RoomXY) -> u32,
809{
810    shortest_path_roomxy_multistart(&[start], goal_fn, cost_fn, heuristic_fn)
811}
812
813/// Convenience method for running multi-start A* with default costs
814/// while still retaining control over the heuristic function used.
815///
816/// Note: This function assumes that the heuristic function is consistent, and optimizes
817/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
818/// suboptimal paths, just like if your heuristic function is not admissible.
819pub fn shortest_path_roomxy_multistart<P, G, F>(
820    start_nodes: &[RoomXY],
821    goal_fn: &P,
822    cost_fn: G,
823    heuristic_fn: &F,
824) -> AStarSearchResults<RoomXY, u32>
825where
826    P: Fn(RoomXY) -> bool,
827    G: Fn(RoomXY) -> Option<u32>,
828    F: Fn(RoomXY) -> u32,
829{
830    let max_ops = 2000;
831    let max_cost = 2000;
832    let new_cost_fn = ignore_first_param_cost_fn(cost_fn);
833    shortest_path_generic_grid(
834        start_nodes,
835        goal_fn,
836        new_cost_fn,
837        heuristic_fn,
838        max_ops,
839        max_cost,
840        0,
841    )
842}
843
844/// Convenience function for the common use-case of searching
845/// from a single starting node to a single goal node.
846///
847/// Uses sane default values for maximum operations and travel costs.
848/// For more fine-grained control, see: [shortest_path_generic_grid]
849///
850/// # Example
851/// ```rust
852/// use screeps::{LocalRoomTerrain, Position, RoomCoordinate};
853///
854/// fn new_position(room_name: &str, x: u8, y: u8) -> Position {
855///        Position::new(
856///            RoomCoordinate::try_from(x).unwrap(),
857///            RoomCoordinate::try_from(y).unwrap(),
858///            room_name.parse().unwrap(),
859///        )
860///    }
861///
862/// let room_name = "E5N6";
863/// let start = new_position(room_name, 24, 18);
864/// let goal = new_position(room_name, 34, 40);
865/// let room_terrain = LocalRoomTerrain::new_from_bits(Box::new([0; 2500])); // Terrain that's all plains
866/// let plain_cost = 1;
867/// let swamp_cost = 5;
868/// let costs = screeps_pathfinding::utils::movement_costs::get_movement_cost_lcm_from_terrain(&room_terrain, plain_cost, swamp_cost);
869/// let costs_fn = screeps_pathfinding::utils::movement_costs::astar_movement_costs_from_lcm(&costs);
870///
871/// let search_results = screeps_pathfinding::algorithms::astar::shortest_path_position_single_goal(
872///     start,
873///     goal,
874///     costs_fn,
875/// );
876///
877/// if !search_results.incomplete() {
878///   let path = search_results.path();
879///   println!("Path: {:?}", path);
880/// }
881/// else {
882///   println!("Could not find A* shortest path.");
883///   println!("Search Results: {:?}", search_results);
884/// }
885/// ```
886pub fn shortest_path_position_single_goal<G>(
887    start: Position,
888    goal: Position,
889    cost_fn: G,
890) -> AStarSearchResults<Position, u32>
891where
892    G: Fn(Position) -> Option<u32>,
893{
894    shortest_path_position_multiple_goals(start, &[goal], cost_fn)
895}
896
897/// Convenience function for the common use-case of searching
898/// from a single starting node to multiple goal nodes.
899///
900/// Uses sane default values for maximum operations and travel costs.
901/// For more fine-grained control, see: [shortest_path_generic_grid]
902///
903/// # Example
904/// ```rust
905/// use screeps::{LocalRoomTerrain, Position, RoomCoordinate};
906///
907/// fn new_position(room_name: &str, x: u8, y: u8) -> Position {
908///        Position::new(
909///            RoomCoordinate::try_from(x).unwrap(),
910///            RoomCoordinate::try_from(y).unwrap(),
911///            room_name.parse().unwrap(),
912///        )
913///    }
914///
915/// let room_name = "E5N6";
916/// let start = new_position(room_name, 24, 18);
917/// let goal_a = new_position(room_name, 34, 40);
918/// let goal_b = new_position(room_name, 34, 45);
919/// let room_terrain = LocalRoomTerrain::new_from_bits(Box::new([0; 2500])); // Terrain that's all plains
920/// let plain_cost = 1;
921/// let swamp_cost = 5;
922/// let costs = screeps_pathfinding::utils::movement_costs::get_movement_cost_lcm_from_terrain(&room_terrain, plain_cost, swamp_cost);
923/// let costs_fn = screeps_pathfinding::utils::movement_costs::astar_movement_costs_from_lcm(&costs);
924///
925/// let search_results = screeps_pathfinding::algorithms::astar::shortest_path_position_multiple_goals(
926///     start,
927///     &[goal_a, goal_b],
928///     costs_fn,
929/// );
930///
931/// if !search_results.incomplete() {
932///   let path = search_results.path();
933///   println!("Path: {:?}", path);
934/// }
935/// else {
936///   println!("Could not find A* shortest path.");
937///   println!("Search Results: {:?}", search_results);
938/// }
939/// ```
940pub fn shortest_path_position_multiple_goals<G>(
941    start: Position,
942    goals: &[Position],
943    cost_fn: G,
944) -> AStarSearchResults<Position, u32>
945where
946    G: Fn(Position) -> Option<u32>,
947{
948    shortest_path_position_multistart(
949        &[start],
950        &goal_exact_node_multigoal(goals),
951        cost_fn,
952        &heuristic_get_range_to_multigoal(goals),
953    )
954}
955
956/// Convenience method for running single-start A* with default costs
957/// while still retaining control over the heuristic function used.
958///
959/// Note: This function assumes that the heuristic function is consistent, and optimizes
960/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
961/// suboptimal paths, just like if your heuristic function is not admissible.
962pub fn shortest_path_position<P, G, F>(
963    start: Position,
964    goal_fn: &P,
965    cost_fn: G,
966    heuristic_fn: &F,
967) -> AStarSearchResults<Position, u32>
968where
969    P: Fn(Position) -> bool,
970    G: Fn(Position) -> Option<u32>,
971    F: Fn(Position) -> u32,
972{
973    shortest_path_position_multistart(&[start], goal_fn, cost_fn, heuristic_fn)
974}
975
976/// Convenience method for running multi-start A* with default costs
977/// while still retaining control over the heuristic function used.
978///
979/// Note: This function assumes that the heuristic function is consistent, and optimizes
980/// accordingly. If your heuristic function is admissible but not consistent, then you risk getting
981/// suboptimal paths, just like if your heuristic function is not admissible.
982pub fn shortest_path_position_multistart<P, G, F>(
983    start_nodes: &[Position],
984    goal_fn: &P,
985    cost_fn: G,
986    heuristic_fn: &F,
987) -> AStarSearchResults<Position, u32>
988where
989    P: Fn(Position) -> bool,
990    G: Fn(Position) -> Option<u32>,
991    F: Fn(Position) -> u32,
992{
993    let max_ops = 2000;
994    let max_cost = 2000;
995
996    let new_cost_fn = ignore_first_param_cost_fn(cost_fn);
997    shortest_path_generic_grid(
998        start_nodes,
999        goal_fn,
1000        new_cost_fn,
1001        heuristic_fn,
1002        max_ops,
1003        max_cost,
1004        0,
1005    )
1006}
1007
1008fn ignore_first_param_cost_fn<G, T, O>(cost_fn: G) -> impl Fn(T, T) -> O
1009where
1010    G: Fn(T) -> O,
1011{
1012    move |_, p| cost_fn(p)
1013}
1014
1015#[allow(dead_code)]
1016fn optionize_cost_fn_results<G, T, O>(cost_fn: G) -> impl Fn(T, T) -> Option<O>
1017where
1018    G: Fn(T, T) -> O,
1019{
1020    move |a, b| Some(cost_fn(a, b))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    use crate::utils::heuristics::heuristic_get_range_to;
1028    use screeps::constants::Terrain;
1029    use screeps::local::{Position, RoomCoordinate, RoomName, RoomXY};
1030
1031    use crate::common::data::load_all_room_terrains_from_map;
1032
1033    use itertools::Itertools;
1034
1035    // Helper Functions
1036
1037    fn new_position(room_name: &str, x: u8, y: u8) -> Position {
1038        Position::new(
1039            RoomCoordinate::try_from(x).unwrap(),
1040            RoomCoordinate::try_from(y).unwrap(),
1041            room_name.parse().unwrap(),
1042        )
1043    }
1044
1045    fn all_tiles_are_plains_costs<T>(_prev: T, _node: T) -> Option<u32> {
1046        Some(1)
1047    }
1048
1049    fn all_tiles_are_swamps_costs<T>(_prev: T, _node: T) -> Option<u32> {
1050        Some(5)
1051    }
1052
1053    fn is_goal_fn<T: std::cmp::PartialEq>(goal: T) -> impl Fn(T) -> bool {
1054        move |node: T| node == goal
1055    }
1056
1057    // Testing function where all tiles are reachable except for (10, 12)
1058    fn roomxy_unreachable_tile_costs(_prev: RoomXY, node: RoomXY) -> Option<u32> {
1059        if node.x.u8() == 10 && node.y.u8() == 12 {
1060            None
1061        } else {
1062            Some(1)
1063        }
1064    }
1065
1066    // Testing function where all tiles are reachable except for (10, 12)
1067    fn position_unreachable_tile_costs(_prev: Position, node: Position) -> Option<u32> {
1068        if node.x().u8() == 10 && node.y().u8() == 12 {
1069            None
1070        } else {
1071            Some(1)
1072        }
1073    }
1074
1075    // Test Cases
1076
1077    #[test]
1078    fn compare_option_scores_orders_properly() {
1079        // Test the None, None case
1080        let res = compare_option_scores::<u8>(None, None);
1081        assert_eq!(res, Ordering::Equal);
1082
1083        for i in 0..u8::MAX {
1084            let a = i;
1085            let b = i + 1;
1086
1087            // Test the Some, None cases
1088            let res = compare_option_scores(Some(a), None);
1089            assert_eq!(res, Ordering::Less);
1090            let res = compare_option_scores(None, Some(a));
1091            assert_eq!(res, Ordering::Greater);
1092
1093            // Test the < case
1094            let res = compare_option_scores(Some(a), Some(b));
1095            assert_eq!(res, Ordering::Less);
1096
1097            // Test the > case
1098            let res = compare_option_scores(Some(b), Some(a));
1099            assert_eq!(res, Ordering::Greater);
1100
1101            // Test the = case
1102            let res = compare_option_scores(Some(a), Some(a));
1103            assert_eq!(res, Ordering::Equal);
1104        }
1105    }
1106
1107    #[test]
1108    fn state_orders_comparisons_by_f_score_descending() {
1109        // Test that f-scores always sort in descending order (greater values return
1110        // Ordering::Less), regardless of g-score or position
1111        let large_g_score: u8 = 100;
1112        let small_g_score: u8 = 5;
1113        let large_position: u8 = 40;
1114        let small_position: u8 = 4;
1115
1116        let irrelevant_score_orderings = [
1117            (small_g_score, large_g_score, small_position, large_position),
1118            (small_g_score, large_g_score, small_position, large_position),
1119            (small_g_score, large_g_score, large_position, small_position),
1120            (large_g_score, small_g_score, large_position, large_position),
1121            (large_g_score, small_g_score, large_position, small_position),
1122            (large_g_score, small_g_score, large_position, large_position),
1123            (large_g_score, large_g_score, large_position, large_position),
1124            (large_g_score, large_g_score, large_position, small_position),
1125            (large_g_score, large_g_score, large_position, large_position),
1126        ];
1127
1128        for i in 0..u8::MAX {
1129            let low_f_score = i;
1130            let high_f_score = i + 1;
1131
1132            for (a_g_score, b_g_score, a_pos, b_pos) in irrelevant_score_orderings {
1133                let a = State {
1134                    g_score: a_g_score,
1135                    f_score: low_f_score,
1136                    position: a_pos,
1137                };
1138                let b = State {
1139                    g_score: b_g_score,
1140                    f_score: high_f_score,
1141                    position: b_pos,
1142                };
1143
1144                let res = a.cmp(&b);
1145                // Remember, f_score orderings are intended to be reversed/descending, so since a.f_score < b.f_score, a > b
1146                assert_eq!(res, Ordering::Greater);
1147
1148                let res = b.cmp(&a);
1149                assert_eq!(res, Ordering::Less);
1150            }
1151        }
1152    }
1153
1154    #[test]
1155    fn state_orders_comparisons_by_g_score_descending_for_equal_f_scores() {
1156        // Test that g-score tie-breaking always sorts in ascending order (lesser values return
1157        // Ordering::Less), regardless of position
1158        let f_score: u8 = 50;
1159        let large_position: u8 = 40;
1160        let small_position: u8 = 4;
1161
1162        let irrelevant_score_orderings = [
1163            (small_position, large_position),
1164            (large_position, small_position),
1165            (large_position, large_position),
1166        ];
1167
1168        for i in 0..u8::MAX {
1169            let low_g_score = i;
1170            let high_g_score = i + 1;
1171
1172            for (a_pos, b_pos) in irrelevant_score_orderings {
1173                let a = State {
1174                    g_score: low_g_score,
1175                    f_score: f_score,
1176                    position: a_pos,
1177                };
1178                let b = State {
1179                    g_score: high_g_score,
1180                    f_score: f_score,
1181                    position: b_pos,
1182                };
1183
1184                let res = a.cmp(&b);
1185                assert_eq!(res, Ordering::Less);
1186
1187                let res = b.cmp(&a);
1188                assert_eq!(res, Ordering::Greater);
1189            }
1190        }
1191    }
1192
1193    #[test]
1194    fn state_orders_comparisons_by_position_descending_for_equal_f_and_g_scores() {
1195        // Test that position tie-breaking always sorts in ascending order (lesser values return
1196        // Ordering::Less)
1197        let f_score: u8 = 50;
1198        let g_score: u8 = 5;
1199
1200        for i in 0..u8::MAX {
1201            let low_pos = i;
1202            let high_pos = i + 1;
1203
1204            let a = State {
1205                g_score: g_score,
1206                f_score: f_score,
1207                position: low_pos,
1208            };
1209            let b = State {
1210                g_score: g_score,
1211                f_score: f_score,
1212                position: high_pos,
1213            };
1214
1215            let res = a.cmp(&b);
1216            assert_eq!(res, Ordering::Less);
1217
1218            let res = b.cmp(&a);
1219            assert_eq!(res, Ordering::Greater);
1220        }
1221    }
1222
1223    #[test]
1224    fn gridstate_orders_comparisons_by_f_score_descending() {
1225        // Test that f-scores always sort in descending order (greater values return
1226        // Ordering::Less), regardless of g-score or position
1227        let large_g_score: u8 = 100;
1228        let small_g_score: u8 = 5;
1229        let large_position: u8 = 40;
1230        let small_position: u8 = 4;
1231
1232        let irrelevant_score_orderings = [
1233            (small_g_score, large_g_score, small_position, large_position),
1234            (small_g_score, large_g_score, small_position, large_position),
1235            (small_g_score, large_g_score, large_position, small_position),
1236            (large_g_score, small_g_score, large_position, large_position),
1237            (large_g_score, small_g_score, large_position, small_position),
1238            (large_g_score, small_g_score, large_position, large_position),
1239            (large_g_score, large_g_score, large_position, large_position),
1240            (large_g_score, large_g_score, large_position, small_position),
1241            (large_g_score, large_g_score, large_position, large_position),
1242        ];
1243
1244        for i in 0..u8::MAX {
1245            let low_f_score = i;
1246            let high_f_score = i + 1;
1247
1248            for (a_g_score, b_g_score, a_pos, b_pos) in irrelevant_score_orderings {
1249                let a = GridState {
1250                    g_score: Some(a_g_score),
1251                    f_score: Some(low_f_score),
1252                    position: a_pos,
1253                    open_direction: None,
1254                };
1255                let b = GridState {
1256                    g_score: Some(b_g_score),
1257                    f_score: Some(high_f_score),
1258                    position: b_pos,
1259                    open_direction: None,
1260                };
1261
1262                let res = a.cmp(&b);
1263                // Remember, f_score orderings are intended to be reversed/descending, so since a.f_score < b.f_score, a > b
1264                assert_eq!(res, Ordering::Greater);
1265
1266                let res = b.cmp(&a);
1267                assert_eq!(res, Ordering::Less);
1268            }
1269        }
1270    }
1271
1272    #[test]
1273    fn gridstate_orders_comparisons_by_g_score_descending_for_equal_f_scores() {
1274        // Test that g-score tie-breaking always sorts in ascending order (lesser values return
1275        // Ordering::Less), regardless of position
1276        let f_score: u8 = 50;
1277        let large_position: u8 = 40;
1278        let small_position: u8 = 4;
1279
1280        let irrelevant_score_orderings = [
1281            (small_position, large_position),
1282            (large_position, small_position),
1283            (large_position, large_position),
1284        ];
1285
1286        for i in 0..u8::MAX {
1287            let low_g_score = i;
1288            let high_g_score = i + 1;
1289
1290            for (a_pos, b_pos) in irrelevant_score_orderings {
1291                let a = GridState {
1292                    g_score: Some(low_g_score),
1293                    f_score: Some(f_score),
1294                    position: a_pos,
1295                    open_direction: None,
1296                };
1297                let b = GridState {
1298                    g_score: Some(high_g_score),
1299                    f_score: Some(f_score),
1300                    position: b_pos,
1301                    open_direction: None,
1302                };
1303
1304                let res = a.cmp(&b);
1305                assert_eq!(res, Ordering::Less);
1306
1307                let res = b.cmp(&a);
1308                assert_eq!(res, Ordering::Greater);
1309            }
1310        }
1311    }
1312
1313    #[test]
1314    fn gridstate_orders_comparisons_by_position_ascending_for_equal_f_and_g_scores() {
1315        // Test that position tie-breaking always sorts in ascending order (lesser values return
1316        // Ordering::Less)
1317        let f_score: u8 = 50;
1318        let g_score: u8 = 5;
1319
1320        for i in 0..u8::MAX {
1321            let low_pos = i;
1322            let high_pos = i + 1;
1323
1324            let a = GridState {
1325                g_score: Some(g_score),
1326                f_score: Some(f_score),
1327                position: low_pos,
1328                open_direction: None,
1329            };
1330            let b = GridState {
1331                g_score: Some(g_score),
1332                f_score: Some(f_score),
1333                position: high_pos,
1334                open_direction: None,
1335            };
1336
1337            let res = a.cmp(&b);
1338            assert_eq!(res, Ordering::Less);
1339
1340            let res = b.cmp(&a);
1341            assert_eq!(res, Ordering::Greater);
1342        }
1343    }
1344
1345    #[test]
1346    fn binary_heap_orders_state_by_f_score_descending() {
1347        // Test that f-scores always sort in descending order (greater values return
1348        // first), regardless of g-score or position
1349        let large_g_score: u8 = 100;
1350        let small_g_score: u8 = 5;
1351        let large_position: u8 = 40;
1352        let small_position: u8 = 4;
1353
1354        let irrelevant_score_orderings = [
1355            (small_g_score, large_g_score, small_position, large_position),
1356            (small_g_score, large_g_score, small_position, large_position),
1357            (small_g_score, large_g_score, large_position, small_position),
1358            (large_g_score, small_g_score, large_position, large_position),
1359            (large_g_score, small_g_score, large_position, small_position),
1360            (large_g_score, small_g_score, large_position, large_position),
1361            (large_g_score, large_g_score, large_position, large_position),
1362            (large_g_score, large_g_score, large_position, small_position),
1363            (large_g_score, large_g_score, large_position, large_position),
1364        ];
1365
1366        for i in 0..u8::MAX {
1367            let low_f_score = i;
1368            let high_f_score = i + 1;
1369
1370            for (a_g_score, b_g_score, a_pos, b_pos) in irrelevant_score_orderings {
1371                let a = State {
1372                    g_score: a_g_score,
1373                    f_score: low_f_score,
1374                    position: a_pos,
1375                };
1376                let b = State {
1377                    g_score: b_g_score,
1378                    f_score: high_f_score,
1379                    position: b_pos,
1380                };
1381
1382                let mut heap = BinaryHeap::from([a, b]);
1383
1384                assert_eq!(heap.pop(), Some(a));
1385                assert_eq!(heap.pop(), Some(b));
1386            }
1387        }
1388    }
1389
1390    #[test]
1391    fn binary_heap_orders_state_by_g_score_descending_for_equal_f_scores() {
1392        // Test that g-score tie-breaking always sorts in descending order (greater values return
1393        // first), regardless of position
1394        let f_score: u8 = 50;
1395        let large_position: u8 = 40;
1396        let small_position: u8 = 4;
1397
1398        let irrelevant_score_orderings = [
1399            (small_position, large_position),
1400            (large_position, small_position),
1401            (large_position, large_position),
1402        ];
1403
1404        for i in 0..u8::MAX {
1405            let low_g_score = i;
1406            let high_g_score = i + 1;
1407
1408            for (a_pos, b_pos) in irrelevant_score_orderings {
1409                let a = State {
1410                    g_score: low_g_score,
1411                    f_score: f_score,
1412                    position: a_pos,
1413                };
1414                let b = State {
1415                    g_score: high_g_score,
1416                    f_score: f_score,
1417                    position: b_pos,
1418                };
1419
1420                let mut heap = BinaryHeap::from([a, b]);
1421
1422                assert_eq!(heap.pop(), Some(b));
1423                assert_eq!(heap.pop(), Some(a));
1424            }
1425        }
1426    }
1427
1428    #[test]
1429    fn binary_heap_orders_state_by_position_ascending_for_equal_f_and_g_scores() {
1430        // Test that position tie-breaking always sorts in ascending order (lesser values return
1431        // first)
1432        let f_score: u8 = 50;
1433        let g_score: u8 = 5;
1434
1435        for i in 0..u8::MAX {
1436            let low_pos = i;
1437            let high_pos = i + 1;
1438
1439            let a = State {
1440                g_score: g_score,
1441                f_score: f_score,
1442                position: low_pos,
1443            };
1444            let b = State {
1445                g_score: g_score,
1446                f_score: f_score,
1447                position: high_pos,
1448            };
1449
1450            let mut heap = BinaryHeap::from([a, b]);
1451
1452            assert_eq!(heap.pop(), Some(b));
1453            assert_eq!(heap.pop(), Some(a));
1454        }
1455    }
1456
1457    #[test]
1458    fn binary_heap_orders_gridstate_by_f_score_descending() {
1459        // Test that f-scores always sort in descending order (greater values return
1460        // first), regardless of g-score or position
1461        let large_g_score: u8 = 100;
1462        let small_g_score: u8 = 5;
1463        let large_position: u8 = 40;
1464        let small_position: u8 = 4;
1465
1466        let irrelevant_score_orderings = [
1467            (small_g_score, large_g_score, small_position, large_position),
1468            (small_g_score, large_g_score, small_position, large_position),
1469            (small_g_score, large_g_score, large_position, small_position),
1470            (large_g_score, small_g_score, large_position, large_position),
1471            (large_g_score, small_g_score, large_position, small_position),
1472            (large_g_score, small_g_score, large_position, large_position),
1473            (large_g_score, large_g_score, large_position, large_position),
1474            (large_g_score, large_g_score, large_position, small_position),
1475            (large_g_score, large_g_score, large_position, large_position),
1476        ];
1477
1478        for i in 0..u8::MAX {
1479            let low_f_score = i;
1480            let high_f_score = i + 1;
1481
1482            for (a_g_score, b_g_score, a_pos, b_pos) in irrelevant_score_orderings {
1483                let a = GridState {
1484                    g_score: Some(a_g_score),
1485                    f_score: Some(low_f_score),
1486                    position: a_pos,
1487                    open_direction: None,
1488                };
1489                let b = GridState {
1490                    g_score: Some(b_g_score),
1491                    f_score: Some(high_f_score),
1492                    position: b_pos,
1493                    open_direction: None,
1494                };
1495
1496                let mut heap = BinaryHeap::from([a, b]);
1497
1498                assert_eq!(heap.pop(), Some(a));
1499                assert_eq!(heap.pop(), Some(b));
1500            }
1501        }
1502    }
1503
1504    #[test]
1505    fn binary_heap_orders_gridstate_by_g_score_descending_for_equal_f_scores() {
1506        // Test that g-score tie-breaking always sorts in descending order (greater values return
1507        // first), regardless of position
1508        let f_score: u8 = 50;
1509        let large_position: u8 = 40;
1510        let small_position: u8 = 4;
1511
1512        let irrelevant_score_orderings = [
1513            (small_position, large_position),
1514            (large_position, small_position),
1515            (large_position, large_position),
1516        ];
1517
1518        for i in 0..u8::MAX {
1519            let low_g_score = i;
1520            let high_g_score = i + 1;
1521
1522            for (a_pos, b_pos) in irrelevant_score_orderings {
1523                let a = GridState {
1524                    g_score: Some(low_g_score),
1525                    f_score: Some(f_score),
1526                    position: a_pos,
1527                    open_direction: None,
1528                };
1529                let b = GridState {
1530                    g_score: Some(high_g_score),
1531                    f_score: Some(f_score),
1532                    position: b_pos,
1533                    open_direction: None,
1534                };
1535
1536                let mut heap = BinaryHeap::from([a, b]);
1537
1538                assert_eq!(heap.pop(), Some(b));
1539                assert_eq!(heap.pop(), Some(a));
1540            }
1541        }
1542    }
1543
1544    #[test]
1545    fn binary_heap_orders_gridstate_by_position_ascending_for_equal_f_and_g_scores() {
1546        // Test that position tie-breaking always sorts in ascending order (lesser values return
1547        // first)
1548        let f_score: u8 = 50;
1549        let g_score: u8 = 5;
1550
1551        for i in 0..u8::MAX {
1552            let low_pos = i;
1553            let high_pos = i + 1;
1554
1555            let a = GridState {
1556                g_score: Some(g_score),
1557                f_score: Some(f_score),
1558                position: low_pos,
1559                open_direction: None,
1560            };
1561            let b = GridState {
1562                g_score: Some(g_score),
1563                f_score: Some(f_score),
1564                position: high_pos,
1565                open_direction: None,
1566            };
1567
1568            let mut heap = BinaryHeap::from([a, b]);
1569
1570            assert_eq!(heap.pop(), Some(b));
1571            assert_eq!(heap.pop(), Some(a));
1572        }
1573    }
1574
1575    #[test]
1576    fn simple_linear_path_roomxy() {
1577        let start = unsafe { RoomXY::unchecked_new(10, 10) };
1578        let goal = unsafe { RoomXY::unchecked_new(10, 12) };
1579        let search_results = shortest_path_generic_grid(
1580            &[start],
1581            &is_goal_fn(goal),
1582            all_tiles_are_plains_costs,
1583            heuristic_get_range_to(goal),
1584            2000,
1585            2000,
1586            0,
1587        );
1588
1589        assert_eq!(search_results.incomplete(), false);
1590        assert!(search_results.cost().is_some());
1591        assert_eq!(search_results.cost().unwrap(), 2);
1592        assert_eq!(search_results.ops() < 2000, true);
1593
1594        let path = search_results.path();
1595
1596        assert_eq!(path.len(), 3);
1597
1598        // All three of these nodes are on a shortest path, so we
1599        // can't guarantee that we'll get any specific one of them
1600        let middle_node_1 = unsafe { RoomXY::unchecked_new(10, 11) };
1601        let middle_node_2 = unsafe { RoomXY::unchecked_new(11, 11) };
1602        let middle_node_3 = unsafe { RoomXY::unchecked_new(11, 10) };
1603
1604        assert_eq!(path.contains(&start), true);
1605        assert_eq!(path.contains(&goal), true);
1606
1607        let contains_a_middle_node = path.contains(&middle_node_1)
1608            | path.contains(&middle_node_2)
1609            | path.contains(&middle_node_3);
1610        assert_eq!(contains_a_middle_node, true);
1611    }
1612
1613    #[test]
1614    fn simple_linear_path_position() {
1615        let room_name = "E5N6";
1616        let start = new_position(room_name, 10, 10);
1617        let goal = new_position(room_name, 10, 12);
1618        let search_results = shortest_path_generic_grid(
1619            &[start],
1620            &is_goal_fn(goal),
1621            all_tiles_are_plains_costs,
1622            heuristic_get_range_to(goal),
1623            2000,
1624            2000,
1625            0,
1626        );
1627
1628        assert_eq!(search_results.incomplete(), false);
1629        assert!(search_results.cost().is_some());
1630        assert_eq!(search_results.cost().unwrap(), 2);
1631        assert_eq!(search_results.ops() < 2000, true);
1632
1633        let path = search_results.path();
1634
1635        assert_eq!(path.len(), 3);
1636
1637        // All three of these nodes are on a shortest path, so we
1638        // can't guarantee that we'll get any specific one of them
1639        let middle_node_1 = new_position(room_name, 10, 11);
1640        let middle_node_2 = new_position(room_name, 11, 11);
1641        let middle_node_3 = new_position(room_name, 11, 10);
1642
1643        assert_eq!(path.contains(&start), true);
1644        assert_eq!(path.contains(&goal), true);
1645
1646        let contains_a_middle_node = path.contains(&middle_node_1)
1647            | path.contains(&middle_node_2)
1648            | path.contains(&middle_node_3);
1649        assert_eq!(contains_a_middle_node, true);
1650    }
1651
1652    #[test]
1653    fn unreachable_target_roomxy() {
1654        let start = unsafe { RoomXY::unchecked_new(10, 10) };
1655        let goal = unsafe { RoomXY::unchecked_new(10, 12) };
1656        let search_results = shortest_path_generic_grid(
1657            &[start],
1658            &is_goal_fn(goal),
1659            roomxy_unreachable_tile_costs,
1660            heuristic_get_range_to(goal),
1661            2000,
1662            2000,
1663            0,
1664        );
1665
1666        println!("{:?}", search_results);
1667
1668        assert_eq!(search_results.incomplete(), true);
1669        assert!(search_results.cost().is_some());
1670        assert_eq!(search_results.cost().unwrap() > 0, true);
1671        assert_eq!(search_results.ops() == 2000, true);
1672    }
1673
1674    #[test]
1675    fn unreachable_target_position() {
1676        let room_name = "E5N6";
1677        let start = new_position(room_name, 10, 10);
1678        let goal = new_position(room_name, 10, 12);
1679        let search_results = shortest_path_generic_grid(
1680            &[start],
1681            &is_goal_fn(goal),
1682            position_unreachable_tile_costs,
1683            heuristic_get_range_to(goal),
1684            2000,
1685            2000,
1686            0,
1687        );
1688
1689        println!("{:?}", search_results);
1690
1691        assert_eq!(search_results.incomplete(), true);
1692        assert!(search_results.cost().is_some());
1693        assert_eq!(search_results.cost().unwrap() > 0, true);
1694        assert_eq!(search_results.ops() > 0, true);
1695    }
1696
1697    #[test]
1698    fn max_ops_halt_roomxy() {
1699        let max_ops_failure = 5;
1700        let max_ops_success = 100;
1701        let start = unsafe { RoomXY::unchecked_new(10, 10) };
1702        let goal = unsafe { RoomXY::unchecked_new(30, 30) }; // This target generally takes ~20 ops to find
1703
1704        // Failure case
1705        let search_results = shortest_path_generic_grid(
1706            &[start],
1707            &is_goal_fn(goal),
1708            all_tiles_are_plains_costs,
1709            heuristic_get_range_to(goal),
1710            max_ops_failure,
1711            2000,
1712            0,
1713        );
1714
1715        assert_eq!(search_results.incomplete(), true);
1716        assert!(search_results.cost().is_some());
1717        assert_eq!(search_results.cost().unwrap() > 0, true);
1718        assert_eq!(search_results.ops() == max_ops_failure, true);
1719
1720        // Success case
1721        let search_results = shortest_path_generic_grid(
1722            &[start],
1723            &is_goal_fn(goal),
1724            all_tiles_are_plains_costs,
1725            heuristic_get_range_to(goal),
1726            max_ops_success,
1727            2000,
1728            0,
1729        );
1730
1731        assert_eq!(search_results.incomplete(), false);
1732        assert!(search_results.cost().is_some());
1733        assert_eq!(search_results.cost().unwrap() > 0, true);
1734        assert_eq!(search_results.ops() < max_ops_success, true);
1735
1736        let path = search_results.path();
1737
1738        assert_eq!(path.len(), 21);
1739    }
1740
1741    #[test]
1742    fn max_ops_halt_position() {
1743        let max_ops_failure = 5;
1744        let max_ops_success = 100;
1745        let room_name = "E5N6";
1746        let start = new_position(room_name, 10, 10);
1747        let goal = new_position(room_name, 30, 30); // This target generally takes ~20 ops to find
1748
1749        // Failure case
1750        let search_results = shortest_path_generic_grid(
1751            &[start],
1752            &is_goal_fn(goal),
1753            all_tiles_are_plains_costs,
1754            heuristic_get_range_to(goal),
1755            max_ops_failure,
1756            2000,
1757            0,
1758        );
1759
1760        assert_eq!(search_results.incomplete(), true);
1761        assert_eq!(search_results.cost().unwrap() > 0, true);
1762        assert_eq!(search_results.ops() == max_ops_failure, true);
1763
1764        // Success case
1765        let search_results = shortest_path_generic_grid(
1766            &[start],
1767            &is_goal_fn(goal),
1768            all_tiles_are_plains_costs,
1769            heuristic_get_range_to(goal),
1770            max_ops_success,
1771            2000,
1772            0,
1773        );
1774
1775        assert_eq!(search_results.incomplete(), false);
1776        assert!(search_results.cost().is_some());
1777        assert_eq!(search_results.cost().unwrap() > 0, true);
1778        assert_eq!(search_results.ops() < max_ops_success, true);
1779
1780        let path = search_results.path();
1781
1782        assert_eq!(path.len(), 21);
1783    }
1784
1785    #[test]
1786    fn max_cost_halt_roomxy() {
1787        let max_cost_failure = 5;
1788        let max_cost_success = 100;
1789        let start = unsafe { RoomXY::unchecked_new(10, 10) };
1790        let goal = unsafe { RoomXY::unchecked_new(10, 12) }; // This target will cost 10 to move to
1791
1792        // Failure case
1793        let search_results = shortest_path_generic_grid(
1794            &[start],
1795            &is_goal_fn(goal),
1796            all_tiles_are_swamps_costs,
1797            heuristic_get_range_to(goal),
1798            2000,
1799            max_cost_failure,
1800            0,
1801        );
1802
1803        assert_eq!(search_results.incomplete(), true);
1804        assert_eq!(search_results.ops() < 2000, true);
1805
1806        // Success case
1807        let search_results = shortest_path_generic_grid(
1808            &[start],
1809            &is_goal_fn(goal),
1810            all_tiles_are_swamps_costs,
1811            heuristic_get_range_to(goal),
1812            2000,
1813            max_cost_success,
1814            0,
1815        );
1816
1817        assert_eq!(search_results.incomplete(), false);
1818        assert!(search_results.cost().is_some());
1819        assert_eq!(search_results.cost().unwrap() < max_cost_success, true);
1820        assert_eq!(search_results.ops() < 2000, true);
1821
1822        let path = search_results.path();
1823
1824        assert_eq!(path.len(), 3);
1825    }
1826
1827    #[test]
1828    fn max_cost_halt_position() {
1829        let max_cost_failure = 5;
1830        let max_cost_success = 100;
1831        let room_name = "E5N6";
1832        let start = new_position(room_name, 10, 10);
1833        let goal = new_position(room_name, 10, 12); // This target will cost 10 to move to
1834
1835        // Failure case
1836        let search_results = shortest_path_generic_grid(
1837            &[start],
1838            &is_goal_fn(goal),
1839            all_tiles_are_swamps_costs,
1840            heuristic_get_range_to(goal),
1841            2000,
1842            max_cost_failure,
1843            0,
1844        );
1845        println!("{:?}", search_results);
1846        assert_eq!(search_results.incomplete(), true);
1847        assert_eq!(search_results.ops() < 2000, true);
1848
1849        // Success case
1850        let search_results = shortest_path_generic_grid(
1851            &[start],
1852            &is_goal_fn(goal),
1853            all_tiles_are_swamps_costs,
1854            heuristic_get_range_to(goal),
1855            2000,
1856            max_cost_success,
1857            0,
1858        );
1859
1860        assert_eq!(search_results.incomplete(), false);
1861        assert!(search_results.cost().is_some());
1862        assert_eq!(search_results.cost().unwrap() < max_cost_success, true);
1863        assert_eq!(search_results.ops() < 2000, true);
1864
1865        let path = search_results.path();
1866
1867        assert_eq!(path.len(), 3);
1868    }
1869
1870    #[test]
1871    fn validate_path_is_optimal_for_mmo_shard3_W49N34() {
1872        let terrains_map = load_all_room_terrains_from_map("test-rooms.json");
1873        let room_name_str = "W49N34";
1874
1875        let room_name = RoomName::new(room_name_str).unwrap();
1876
1877        let terrain_data = terrains_map.get(&room_name).unwrap();
1878
1879        let start = unsafe { RoomXY::unchecked_new(6, 0) }; // Top-left exit tile
1880        let goal = unsafe { RoomXY::unchecked_new(40, 49) }; // Bottom-right exit tile
1881
1882        let plain_cost = 1;
1883        let swamp_cost = 5;
1884        let costs = crate::utils::movement_costs::get_movement_cost_lcm_from_terrain(
1885            &terrain_data,
1886            plain_cost,
1887            swamp_cost,
1888        );
1889        let costs_fn = crate::utils::movement_costs::movement_costs_from_lcm(&costs);
1890        let neighbors_fn = crate::utils::neighbors::room_xy_neighbors;
1891        let max_ops = 2000;
1892        let max_cost = 2000;
1893
1894        let dijkstra_search_results = crate::algorithms::dijkstra::shortest_path_generic(
1895            &[start],
1896            &is_goal_fn(goal),
1897            &costs_fn,
1898            neighbors_fn,
1899            max_ops,
1900            max_cost,
1901        );
1902
1903        assert_eq!(dijkstra_search_results.incomplete(), false);
1904        let dijkstra_path = dijkstra_search_results.path();
1905
1906        let new_cost_fn = optionize_cost_fn_results(ignore_first_param_cost_fn(costs_fn));
1907
1908        let astar_search_results = shortest_path_generic_grid(
1909            &[start],
1910            &is_goal_fn(goal),
1911            new_cost_fn,
1912            heuristic_get_range_to(goal),
1913            max_ops,
1914            max_cost,
1915            0,
1916        );
1917
1918        assert_eq!(astar_search_results.incomplete(), false);
1919        let astar_path = astar_search_results.path();
1920
1921        assert_eq!(
1922            astar_search_results.cost().unwrap(),
1923            dijkstra_search_results.cost()
1924        );
1925    }
1926
1927    #[test]
1928    fn validate_path_is_optimal_for_mmo_shard3_W49N34_known_edge_case() {
1929        let terrains_map = load_all_room_terrains_from_map("test-rooms.json");
1930        let room_name_str = "W49N34";
1931
1932        let room_name = RoomName::new(room_name_str).unwrap();
1933
1934        let terrain_data = terrains_map.get(&room_name).unwrap();
1935
1936        let start = unsafe { RoomXY::unchecked_new(6, 0) };
1937        let goal = unsafe { RoomXY::unchecked_new(32, 5) };
1938
1939        let plain_cost = 1;
1940        let swamp_cost = 5;
1941        let costs = crate::utils::movement_costs::get_movement_cost_lcm_from_terrain(
1942            &terrain_data,
1943            plain_cost,
1944            swamp_cost,
1945        );
1946        let costs_fn = crate::utils::movement_costs::movement_costs_from_lcm(&costs);
1947        let neighbors_fn = crate::utils::neighbors::room_xy_neighbors;
1948        let max_ops = 2000;
1949        let max_cost = 2000;
1950
1951        let dijkstra_search_results = crate::algorithms::dijkstra::shortest_path_generic(
1952            &[start],
1953            &is_goal_fn(goal),
1954            &costs_fn,
1955            neighbors_fn,
1956            max_ops,
1957            max_cost,
1958        );
1959
1960        assert_eq!(dijkstra_search_results.incomplete(), false);
1961        let dijkstra_path = dijkstra_search_results.path();
1962
1963        let new_cost_fn = optionize_cost_fn_results(ignore_first_param_cost_fn(costs_fn));
1964
1965        let astar_search_results = shortest_path_generic_grid(
1966            &[start],
1967            &is_goal_fn(goal),
1968            new_cost_fn,
1969            heuristic_get_range_to(goal),
1970            max_ops,
1971            max_cost,
1972            0,
1973        );
1974
1975        assert_eq!(astar_search_results.incomplete(), false);
1976        let astar_path = astar_search_results.path();
1977
1978        assert_eq!(
1979            astar_search_results.cost().unwrap(),
1980            dijkstra_search_results.cost()
1981        );
1982    }
1983
1984    #[test]
1985    #[ignore]
1986    fn validate_path_is_optimal_for_mmo_shard3_arbitrary_rooms() {
1987        let terrains_map = load_all_room_terrains_from_map("test-rooms.json");
1988
1989        let mut skipped_because_plains = 0;
1990        let mut num_rooms = 0;
1991        let room_name_str = "W49N34";
1992        let room_name = RoomName::new(room_name_str).unwrap();
1993        let tmp = vec![terrains_map.get(&room_name).unwrap()];
1994        for terrain_data in tmp {
1995            //for terrain_data in terrains_map.values() {
1996            num_rooms += 1;
1997
1998            let plains_tiles: Vec<RoomXY> = (0..50)
1999                .cartesian_product(0..50)
2000                .map(|(y, x)| unsafe { RoomXY::unchecked_new(x, y) })
2001                .filter(|pos| match terrain_data.get_xy(*pos) {
2002                    Terrain::Plain => true,
2003                    _ => false,
2004                })
2005                .collect();
2006
2007            if plains_tiles.is_empty() {
2008                // No Plains terrain, skip this room
2009                skipped_because_plains += 1;
2010                continue;
2011            }
2012
2013            let total_length = plains_tiles.len();
2014            let second_half_start = total_length / 2;
2015
2016            let start_positions = &plains_tiles[0..second_half_start];
2017            let goal_positions = &plains_tiles[second_half_start..total_length];
2018
2019            for (start_ref, goal_ref) in start_positions.iter().cartesian_product(goal_positions) {
2020                let start = *start_ref;
2021                let goal = *goal_ref;
2022                if start == goal {
2023                    continue;
2024                }
2025
2026                let plain_cost = 1;
2027                let swamp_cost = 5;
2028                let costs = crate::utils::movement_costs::get_movement_cost_lcm_from_terrain(
2029                    &terrain_data,
2030                    plain_cost,
2031                    swamp_cost,
2032                );
2033                let costs_fn = crate::utils::movement_costs::movement_costs_from_lcm(&costs);
2034                let neighbors_fn = crate::utils::neighbors::room_xy_neighbors;
2035                let max_ops = 2000;
2036                let max_cost = 2000;
2037
2038                let dijkstra_search_results = crate::algorithms::dijkstra::shortest_path_generic(
2039                    &[start],
2040                    &is_goal_fn(goal),
2041                    &costs_fn,
2042                    neighbors_fn,
2043                    max_ops,
2044                    max_cost,
2045                );
2046
2047                assert_eq!(dijkstra_search_results.incomplete(), false);
2048                let dijkstra_path = dijkstra_search_results.path();
2049
2050                let new_cost_fn = optionize_cost_fn_results(ignore_first_param_cost_fn(costs_fn));
2051                let always_one_heuristic = |_| 1;
2052
2053                let astar_search_results = shortest_path_generic_grid(
2054                    &[start],
2055                    &is_goal_fn(goal),
2056                    new_cost_fn,
2057                    always_one_heuristic,
2058                    //heuristic_get_range_to(goal),
2059                    max_ops,
2060                    max_cost,
2061                    0,
2062                );
2063
2064                assert_eq!(astar_search_results.incomplete(), false);
2065                let astar_path = astar_search_results.path();
2066
2067                //assert_eq!(astar_path.len(), dijkstra_path.len(), "Dijkstra: {:?}\nA*: {:?}", dijkstra_path, astar_path);
2068                assert_eq!(
2069                    astar_search_results.cost().unwrap(),
2070                    dijkstra_search_results.cost()
2071                );
2072            }
2073        }
2074
2075        // Assert we didn't skip all of the rooms
2076        assert!(skipped_because_plains < num_rooms);
2077    }
2078}