1use std::{cmp::Ordering, collections::BinaryHeap};
10
11use crate::{
12 algorithms::jps_cardinal::{
13 Direction, PointKind, classify_point_kind, manhattan_distance, reconstruct_jump_path, step,
14 },
15 grid::Grid,
16 path::Path,
17 point::Point,
18 search::{Pathfinder, SearchRequest, SearchResult},
19};
20
21#[derive(Debug, Default, Clone, Copy)]
27pub struct JumpPointSearch;
28
29#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
33pub struct JumpPointSearchDiagnostics {
34 pub frontier_pushes: usize,
36 pub frontier_pops: usize,
38 pub stale_pops_skipped: usize,
40 pub peak_frontier_len: usize,
42 pub scan_attempts: usize,
44 pub blocked_scans: usize,
46 pub successful_scans: usize,
48 pub relaxation_attempts: usize,
50 pub relaxations_accepted: usize,
52 pub total_jump_length: usize,
54 pub max_jump_length: usize,
56 pub jump_length_1_stops: usize,
58 pub jump_length_2_to_4_stops: usize,
60 pub jump_length_5_plus_stops: usize,
62 pub goal_stops: usize,
64 pub branch_stops: usize,
66 pub elbow_turn_stops: usize,
68 pub dead_end_stops: usize,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct JumpPointSearchInspection {
75 pub result: SearchResult,
77 pub diagnostics: JumpPointSearchDiagnostics,
79}
80
81impl JumpPointSearch {
82 #[must_use]
86 pub fn inspect(&self, grid: &Grid, request: SearchRequest) -> JumpPointSearchInspection {
87 if let Err(error) = crate::search::validate_request(grid, request) {
88 return JumpPointSearchInspection {
89 result: Err(error),
90 diagnostics: JumpPointSearchDiagnostics::default(),
91 };
92 }
93 let execution = search_impl::<true>(grid, request);
94 JumpPointSearchInspection {
95 result: execution.result,
96 diagnostics: execution.diagnostics,
97 }
98 }
99}
100
101impl Pathfinder for JumpPointSearch {
102 fn name(&self) -> &'static str {
103 "jump-point-search"
104 }
105
106 fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
107 crate::search::validate_request(grid, request)?;
108 search_impl::<false>(grid, request).result
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113struct SearchExecution {
114 result: SearchResult,
115 diagnostics: JumpPointSearchDiagnostics,
116}
117
118fn search_impl<const TRACK_DIAGNOSTICS: bool>(
119 grid: &Grid,
120 request: SearchRequest,
121) -> SearchExecution {
122 let mut diagnostics = JumpPointSearchDiagnostics::default();
123
124 let Some(start_index) = grid.index_of(request.start) else {
125 return SearchExecution {
126 result: crate::search::not_found(0),
127 diagnostics,
128 };
129 };
130 let Some(goal_index) = grid.index_of(request.goal) else {
131 return SearchExecution {
132 result: crate::search::not_found(0),
133 diagnostics,
134 };
135 };
136
137 if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
138 return SearchExecution {
139 result: crate::search::not_found(0),
140 diagnostics,
141 };
142 }
143
144 if !grid.is_reachable(request.start, request.goal) {
145 return SearchExecution {
146 result: crate::search::not_found(0),
147 diagnostics,
148 };
149 }
150
151 if request.start == request.goal {
152 return SearchExecution {
153 result: crate::search::found(
154 Path::from_steps(vec![request.start]).expect("path contains at least one point"),
155 1,
156 ),
157 diagnostics,
158 };
159 }
160
161 let initial_heuristic = manhattan_distance(request.start, request.goal);
162 let mut frontier = BinaryHeap::from([FrontierEntry {
163 estimated_total_cost: initial_heuristic,
164 heuristic_cost: initial_heuristic,
165 cost_so_far: 0,
166 index: start_index,
167 }]);
168 let mut best_costs = vec![None; grid.cell_count()];
169 let mut parents = vec![None; grid.cell_count()];
170 let mut visited_nodes = 0usize;
171 let watch = crate::search::BudgetWatch::start(request.budget);
172
173 best_costs[start_index] = Some(0);
174
175 if TRACK_DIAGNOSTICS {
176 diagnostics.frontier_pushes = 1;
177 diagnostics.peak_frontier_len = 1;
178 }
179
180 while let Some(entry) = frontier.pop() {
181 if TRACK_DIAGNOSTICS {
182 diagnostics.frontier_pops += 1;
183 }
184
185 if best_costs[entry.index] != Some(entry.cost_so_far) {
186 if TRACK_DIAGNOSTICS {
187 diagnostics.stale_pops_skipped += 1;
188 }
189 continue;
190 }
191
192 visited_nodes += 1;
193 if entry.index == goal_index {
194 break;
195 }
196
197 if let Err(reason) = watch.check(visited_nodes) {
198 return SearchExecution {
199 result: Err(crate::search::budget_error(reason)),
200 diagnostics,
201 };
202 }
203
204 let current = grid.point_from_index(entry.index);
205 for direction in Direction::ALL {
206 if TRACK_DIAGNOSTICS {
207 diagnostics.scan_attempts += 1;
208 }
209
210 let Some(scan_stop) =
211 jump_in_direction(grid, current, entry.index, direction, goal_index)
212 else {
213 if TRACK_DIAGNOSTICS {
214 diagnostics.blocked_scans += 1;
215 }
216 continue;
217 };
218
219 if TRACK_DIAGNOSTICS {
220 diagnostics.successful_scans += 1;
221 diagnostics.total_jump_length += scan_stop.edge_cost;
222 diagnostics.max_jump_length = diagnostics.max_jump_length.max(scan_stop.edge_cost);
223 match scan_stop.edge_cost {
224 0 => {}
225 1 => diagnostics.jump_length_1_stops += 1,
226 2..=4 => diagnostics.jump_length_2_to_4_stops += 1,
227 _ => diagnostics.jump_length_5_plus_stops += 1,
228 }
229
230 match scan_stop.reason {
231 StopReason::Goal => diagnostics.goal_stops += 1,
232 StopReason::Branch => diagnostics.branch_stops += 1,
233 StopReason::ElbowTurn => diagnostics.elbow_turn_stops += 1,
234 StopReason::DeadEnd => diagnostics.dead_end_stops += 1,
235 }
236 diagnostics.relaxation_attempts += 1;
237 }
238
239 let Some(next_cost) = entry.cost_so_far.checked_add(scan_stop.edge_weight) else {
240 continue;
241 };
242 if best_costs[scan_stop.target_index].is_some_and(|best_cost| next_cost >= best_cost) {
243 continue;
244 }
245
246 best_costs[scan_stop.target_index] = Some(next_cost);
247 parents[scan_stop.target_index] = Some(entry.index);
248 let target = grid.point_from_index(scan_stop.target_index);
249 let heuristic_cost = manhattan_distance(target, request.goal);
250 frontier.push(FrontierEntry {
251 estimated_total_cost: next_cost.saturating_add(heuristic_cost),
252 heuristic_cost,
253 cost_so_far: next_cost,
254 index: scan_stop.target_index,
255 });
256
257 if TRACK_DIAGNOSTICS {
258 diagnostics.relaxations_accepted += 1;
259 diagnostics.frontier_pushes += 1;
260 diagnostics.peak_frontier_len = diagnostics.peak_frontier_len.max(frontier.len());
261 }
262 }
263 }
264
265 let result = if let Some(goal_cost) = best_costs[goal_index] {
266 crate::search::found(
267 reconstruct_jump_path(grid, &parents, start_index, goal_index, goal_cost),
268 visited_nodes,
269 )
270 } else {
271 crate::search::not_found(visited_nodes)
272 };
273
274 SearchExecution {
275 result,
276 diagnostics,
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281struct ScanStop {
282 target_index: usize,
283 edge_cost: usize,
284 edge_weight: usize,
285 reason: StopReason,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum StopReason {
290 Goal,
291 Branch,
292 ElbowTurn,
293 DeadEnd,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297struct FrontierEntry {
298 estimated_total_cost: usize,
299 heuristic_cost: usize,
300 cost_so_far: usize,
301 index: usize,
302}
303
304impl Ord for FrontierEntry {
305 fn cmp(&self, other: &Self) -> Ordering {
306 other
307 .estimated_total_cost
308 .cmp(&self.estimated_total_cost)
309 .then_with(|| other.heuristic_cost.cmp(&self.heuristic_cost))
310 .then_with(|| self.cost_so_far.cmp(&other.cost_so_far))
311 .then_with(|| other.index.cmp(&self.index))
312 }
313}
314
315impl PartialOrd for FrontierEntry {
316 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
317 Some(self.cmp(other))
318 }
319}
320
321fn jump_in_direction(
322 grid: &Grid,
323 start: Point,
324 start_index: usize,
325 direction: Direction,
326 goal_index: usize,
327) -> Option<ScanStop> {
328 let mut point = start;
329 let mut index = start_index;
330 let mut edge_cost = 0usize;
331 let mut edge_weight = 0usize;
332
333 loop {
334 let (next_point, next_index) = step(grid, point, index, direction)?;
335 point = next_point;
336 index = next_index;
337 edge_cost = edge_cost.checked_add(1)?;
338 edge_weight = edge_weight.checked_add(grid.traversal_cost(point).unwrap_or(1))?;
339
340 if index == goal_index {
341 return Some(ScanStop {
342 target_index: index,
343 edge_cost,
344 edge_weight,
345 reason: StopReason::Goal,
346 });
347 }
348
349 let point_kind = classify_point_kind(grid, point);
350 if point_kind != PointKind::StraightCorridor {
351 return Some(ScanStop {
352 target_index: index,
353 edge_cost,
354 edge_weight,
355 reason: match point_kind {
356 PointKind::StraightCorridor => unreachable!("straight corridor handled above"),
357 PointKind::Branch => StopReason::Branch,
358 PointKind::ElbowTurn => StopReason::ElbowTurn,
359 PointKind::DeadEnd => StopReason::DeadEnd,
360 },
361 });
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use crate::{
369 algorithms::jps_cardinal::{PointKind, classify_point_kind},
370 algorithms::jump_point_search::JumpPointSearch,
371 grid::{Cell, Grid},
372 point::Point,
373 search::{Pathfinder, SearchRequest},
374 };
375
376 #[test]
377 fn finds_a_shortest_path_through_the_only_gap() {
378 let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
379 for y in 0..5 {
380 if y != 2 {
381 grid.set_cell(Point::new(2, y), Cell::Blocked)
382 .expect("valid grid edit");
383 }
384 }
385
386 let jps = JumpPointSearch;
387 let result = jps.search(
388 &grid,
389 SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
390 );
391
392 assert!(result.as_ref().expect("valid search request").is_found());
393 assert_eq!(
394 result.as_ref().expect("valid search request").cost(),
395 Some(8)
396 );
397 let path = result
398 .as_ref()
399 .expect("valid search request")
400 .path()
401 .expect("path should exist");
402 assert!(path.steps().contains(&Point::new(2, 2)));
403 }
404
405 #[test]
406 fn reports_when_no_path_exists() {
407 let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
408 for x in 0..3 {
409 grid.set_cell(Point::new(x, 1), Cell::Blocked)
410 .expect("valid grid edit");
411 }
412
413 let jps = JumpPointSearch;
414 let result = jps.search(
415 &grid,
416 SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
417 );
418
419 assert!(!result.as_ref().expect("valid search request").is_found());
420 assert_eq!(result.as_ref().expect("valid search request").cost(), None);
421 assert!(
422 result
423 .as_ref()
424 .expect("valid search request")
425 .stats()
426 .visited_nodes
427 > 0
428 );
429 }
430
431 #[test]
432 fn skips_a_straight_corridor() {
433 let grid = Grid::new(8, 1).expect("grid dimensions are valid");
434 let jps = JumpPointSearch;
435 let result = jps.search(
436 &grid,
437 SearchRequest::new(Point::new(0, 0), Point::new(7, 0)),
438 );
439
440 assert!(result.as_ref().expect("valid search request").is_found());
441 assert_eq!(
442 result.as_ref().expect("valid search request").cost(),
443 Some(7)
444 );
445 assert_eq!(
446 result
447 .as_ref()
448 .expect("valid search request")
449 .stats()
450 .visited_nodes,
451 2
452 );
453 let path = result
454 .as_ref()
455 .expect("valid search request")
456 .path()
457 .expect("path should exist");
458 assert_eq!(path.len(), 8);
459 }
460
461 #[test]
462 fn charges_per_cell_traversal_cost_matching_dijkstra() {
463 let mut grid = Grid::new(8, 1).expect("grid dimensions are valid");
464 assert_eq!(grid.set_traversal_cost(Point::new(4, 0), 5), Ok(()));
465
466 let request = SearchRequest::new(Point::new(0, 0), Point::new(7, 0));
467 let jps_cost = JumpPointSearch
468 .search(&grid, request)
469 .as_ref()
470 .expect("valid search request")
471 .cost();
472 let dijkstra_cost = crate::Dijkstra
473 .search(&grid, request)
474 .as_ref()
475 .expect("valid search request")
476 .cost();
477
478 assert_eq!(jps_cost, Some(11));
479 assert_eq!(jps_cost, dijkstra_cost);
480 }
481
482 #[test]
483 fn supports_maximum_single_edge_cost() {
484 let mut grid = Grid::new(2, 1).expect("grid dimensions are valid");
485 assert_eq!(
486 grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
487 Ok(())
488 );
489
490 let result = JumpPointSearch.search(
491 &grid,
492 SearchRequest::new(Point::new(0, 0), Point::new(1, 0)),
493 );
494
495 assert!(result.as_ref().expect("valid search request").is_found());
496 assert_eq!(
497 result.as_ref().expect("valid search request").cost(),
498 Some(usize::MAX)
499 );
500 assert_eq!(
501 result
502 .as_ref()
503 .expect("valid search request")
504 .path()
505 .expect("path should exist")
506 .cost(),
507 usize::MAX
508 );
509 }
510
511 #[test]
512 fn skips_overflowing_jump_costs_matching_dijkstra() {
513 let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
514 assert_eq!(
515 grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
516 Ok(())
517 );
518
519 let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 0));
520 let jps = JumpPointSearch.search(&grid, request);
521 let dijkstra = crate::Dijkstra.search(&grid, request);
522
523 assert!(!jps.as_ref().expect("valid search request").is_found());
524 assert_eq!(jps.as_ref().expect("valid search request").cost(), None);
525 assert_eq!(
526 jps.as_ref().expect("valid search request").cost(),
527 dijkstra.as_ref().expect("valid search request").cost()
528 );
529 }
530
531 #[test]
532 fn stops_at_goal_inside_a_straight_corridor() {
533 let grid = Grid::new(8, 1).expect("grid dimensions are valid");
534 let jps = JumpPointSearch;
535 let result = jps.search(
536 &grid,
537 SearchRequest::new(Point::new(0, 0), Point::new(4, 0)),
538 );
539
540 assert!(result.as_ref().expect("valid search request").is_found());
541 assert_eq!(
542 result.as_ref().expect("valid search request").cost(),
543 Some(4)
544 );
545 assert_eq!(
546 result
547 .as_ref()
548 .expect("valid search request")
549 .stats()
550 .visited_nodes,
551 2
552 );
553 let path = result
554 .as_ref()
555 .expect("valid search request")
556 .path()
557 .expect("path should exist");
558 assert_eq!(path.len(), 5);
559 assert_eq!(path.goal(), Point::new(4, 0));
560 }
561
562 #[test]
563 fn finds_a_shortest_path_inside_an_open_room() {
564 let grid = Grid::new(5, 5).expect("grid dimensions are valid");
565 let jps = JumpPointSearch;
566 let result = jps.search(
567 &grid,
568 SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
569 );
570
571 assert!(result.as_ref().expect("valid search request").is_found());
572 assert_eq!(
573 result.as_ref().expect("valid search request").cost(),
574 Some(4)
575 );
576 let path = result
577 .as_ref()
578 .expect("valid search request")
579 .path()
580 .expect("path should exist");
581 assert_eq!(path.start(), Point::new(0, 0));
582 assert_eq!(path.goal(), Point::new(2, 2));
583 }
584
585 #[test]
586 fn turns_at_a_t_junction_when_goal_leaves_the_corridor() {
587 let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
588 for y in 0..3 {
589 for x in 0..5 {
590 if y != 1 && x != 2 {
591 grid.set_cell(Point::new(x, y), Cell::Blocked)
592 .expect("valid grid edit");
593 }
594 }
595 }
596
597 let jps = JumpPointSearch;
598 let result = jps.search(
599 &grid,
600 SearchRequest::new(Point::new(0, 1), Point::new(2, 0)),
601 );
602
603 assert!(result.as_ref().expect("valid search request").is_found());
604 assert_eq!(
605 result.as_ref().expect("valid search request").cost(),
606 Some(3)
607 );
608 let path = result
609 .as_ref()
610 .expect("valid search request")
611 .path()
612 .expect("path should exist");
613 assert!(path.steps().contains(&Point::new(2, 1)));
614 assert_eq!(path.goal(), Point::new(2, 0));
615 }
616
617 #[test]
618 fn turns_at_an_elbow_corridor() {
619 let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
620 for y in 0..5 {
621 for x in 0..5 {
622 if x != 0 && y != 4 {
623 grid.set_cell(Point::new(x, y), Cell::Blocked)
624 .expect("valid grid edit");
625 }
626 }
627 }
628
629 let jps = JumpPointSearch;
630 let result = jps.search(
631 &grid,
632 SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
633 );
634
635 assert!(result.as_ref().expect("valid search request").is_found());
636 assert_eq!(
637 result.as_ref().expect("valid search request").cost(),
638 Some(8)
639 );
640 let path = result
641 .as_ref()
642 .expect("valid search request")
643 .path()
644 .expect("path should exist");
645 assert_eq!(path.start(), Point::new(0, 0));
646 assert_eq!(path.goal(), Point::new(4, 4));
647 assert!(path.steps().contains(&Point::new(0, 4)));
648 }
649
650 #[test]
651 fn inspection_tracks_branch_and_jump_length_metrics() {
652 let grid = Grid::new(8, 1).expect("grid dimensions are valid");
653
654 let inspection = JumpPointSearch.inspect(
655 &grid,
656 SearchRequest::new(Point::new(0, 0), Point::new(7, 0)),
657 );
658
659 assert!(
660 inspection
661 .result
662 .as_ref()
663 .expect("valid search request")
664 .is_found()
665 );
666 assert_eq!(
667 inspection
668 .result
669 .as_ref()
670 .expect("valid search request")
671 .cost(),
672 Some(7)
673 );
674 assert_eq!(inspection.diagnostics.frontier_pushes, 2);
675 assert_eq!(inspection.diagnostics.frontier_pops, 2);
676 assert_eq!(inspection.diagnostics.successful_scans, 1);
677 assert_eq!(inspection.diagnostics.goal_stops, 1);
678 assert_eq!(inspection.diagnostics.jump_length_5_plus_stops, 1);
679 assert_eq!(inspection.diagnostics.max_jump_length, 7);
680 }
681
682 #[test]
683 fn classifies_open_room_cells_as_branches() {
684 let grid = Grid::new(3, 3).expect("grid dimensions are valid");
685
686 assert_eq!(
687 classify_point_kind(&grid, Point::new(1, 1)),
688 PointKind::Branch
689 );
690 }
691}