1use std::{cmp::Ordering, collections::BinaryHeap};
10
11use super::dijkstra::Dijkstra;
12use crate::{
13 grid::Grid,
14 path::Path,
15 point::Point,
16 search::{Pathfinder, SearchRequest, SearchResult},
17};
18
19#[derive(Debug, Default, Clone, Copy)]
26pub struct RectangularSymmetryReduction;
27
28impl Pathfinder for RectangularSymmetryReduction {
29 fn name(&self) -> &'static str {
30 "rectangular-symmetry-reduction"
31 }
32
33 fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
34 crate::search::validate_request(grid, request)?;
35 let Some(start_index) = grid.index_of(request.start) else {
36 return crate::search::not_found(0);
37 };
38 let Some(goal_index) = grid.index_of(request.goal) else {
39 return crate::search::not_found(0);
40 };
41
42 if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
43 return crate::search::not_found(0);
44 }
45
46 if !grid.is_reachable(request.start, request.goal) {
47 return crate::search::not_found(0);
48 }
49
50 if request.start == request.goal {
51 return crate::search::found(
52 Path::from_steps(vec![request.start]).expect("path contains at least one point"),
53 1,
54 );
55 }
56
57 let decomposition = RoomDecomposition::new(grid);
58 let Some(start_room_id) = decomposition.room_id(start_index) else {
59 return crate::search::not_found(0);
60 };
61 let Some(goal_room_id) = decomposition.room_id(goal_index) else {
62 return crate::search::not_found(0);
63 };
64 let start_room = decomposition.room(start_room_id);
65 let goal_room = decomposition.room(goal_room_id);
66
67 if start_room_id == goal_room_id {
68 let steps = direct_room_path(request.start, request.goal);
69 let cost = weighted_path_cost(grid, &steps);
70 if cost > manhattan_distance(request.start, request.goal) {
71 return Dijkstra.search(grid, request);
72 }
73 return crate::search::found(
74 Path::from_steps_with_cost(steps, cost).expect("path contains at least one point"),
75 1,
76 );
77 }
78
79 let start_is_inserted = !start_room.is_kept(request.start);
80 let goal_is_inserted = !goal_room.is_kept(request.goal);
81 let endpoints = SearchEndpoints {
82 start: request.start,
83 start_index,
84 start_is_inserted,
85 goal: request.goal,
86 goal_index,
87 goal_is_inserted,
88 };
89 let start_adapter = SpecialAdapter::new(
90 grid,
91 &decomposition,
92 endpoints.start,
93 endpoints.start_index,
94 endpoints.start_is_inserted,
95 );
96 let goal_adapter = SpecialAdapter::new(
97 grid,
98 &decomposition,
99 endpoints.goal,
100 endpoints.goal_index,
101 endpoints.goal_is_inserted,
102 );
103 let adapters = EndpointAdapters {
104 start: start_adapter.as_ref(),
105 goal: goal_adapter.as_ref(),
106 };
107 let mut frontier = BinaryHeap::from([FrontierEntry {
108 estimated_total_cost: manhattan_distance(request.start, request.goal),
109 cost_so_far: 0,
110 index: start_index,
111 }]);
112 let mut best_costs = vec![usize::MAX; grid.cell_count()];
113 let mut parents = std::iter::repeat_with(|| None)
114 .take(grid.cell_count())
115 .collect::<Vec<Option<usize>>>();
116 let mut visited_nodes = 0;
117 let watch = crate::search::BudgetWatch::start(request.budget);
118
119 best_costs[start_index] = 0;
120
121 while let Some(entry) = frontier.pop() {
122 if entry.cost_so_far != best_costs[entry.index] {
123 continue;
124 }
125
126 visited_nodes += 1;
127 if entry.index == goal_index {
128 break;
129 }
130
131 if let Err(reason) = watch.check(visited_nodes) {
132 return Err(crate::search::budget_error(reason));
133 }
134
135 let current = grid.point_from_index(entry.index);
136 for_each_successor(
137 grid,
138 &decomposition,
139 current,
140 entry.index,
141 endpoints,
142 adapters,
143 |target_index, edge_cost| {
144 let next_cost = entry.cost_so_far + edge_cost;
145 if next_cost >= best_costs[target_index] {
146 return;
147 }
148
149 best_costs[target_index] = next_cost;
150 parents[target_index] = Some(entry.index);
151
152 let target = grid.point_from_index(target_index);
153 frontier.push(FrontierEntry {
154 estimated_total_cost: next_cost + manhattan_distance(target, request.goal),
155 cost_so_far: next_cost,
156 index: target_index,
157 });
158 },
159 );
160 }
161
162 if best_costs[goal_index] == usize::MAX {
163 return crate::search::not_found(visited_nodes);
164 }
165
166 crate::search::found(
167 reconstruct_path(grid, request.start, goal_index, &parents),
168 visited_nodes,
169 )
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct FrontierEntry {
175 estimated_total_cost: usize,
176 cost_so_far: usize,
177 index: usize,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181struct SearchEndpoints {
182 start: Point,
183 start_index: usize,
184 start_is_inserted: bool,
185 goal: Point,
186 goal_index: usize,
187 goal_is_inserted: bool,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191struct EndpointAdapters<'a> {
192 start: Option<&'a SpecialAdapter>,
193 goal: Option<&'a SpecialAdapter>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197struct SpecialAdapter {
198 room_id: usize,
199 special: Point,
200 special_index: usize,
201 forward_targets: [(usize, usize); 4],
202 forward_len: usize,
203 back_connectors: [usize; 4],
204 back_len: usize,
205}
206
207impl SpecialAdapter {
208 fn new(
209 grid: &Grid,
210 decomposition: &RoomDecomposition,
211 special: Point,
212 special_index: usize,
213 is_inserted: bool,
214 ) -> Option<Self> {
215 if !is_inserted {
216 return None;
217 }
218
219 let room_id = decomposition
220 .room_id(special_index)
221 .expect("inserted special nodes must belong to a room");
222 let room = decomposition.room(room_id);
223 let mut forward_targets = [(0usize, 0usize); 4];
224 let mut back_connectors = [0usize; 4];
225 let mut forward_len = 0usize;
226 let mut back_len = 0usize;
227
228 room.for_each_connection_point(special, |target| {
229 let target_index = grid
230 .index_of(target)
231 .expect("room connection points must exist inside the grid");
232 forward_targets[forward_len] = (target_index, manhattan_distance(special, target));
233 back_connectors[back_len] = target_index;
234 forward_len += 1;
235 back_len += 1;
236 });
237
238 Some(Self {
239 room_id,
240 special,
241 special_index,
242 forward_targets,
243 forward_len,
244 back_connectors,
245 back_len,
246 })
247 }
248
249 fn for_each_forward_edge(&self, mut f: impl FnMut(usize, usize)) {
250 for (target_index, edge_cost) in self.forward_targets[..self.forward_len].iter().copied() {
251 f(target_index, edge_cost);
252 }
253 }
254
255 fn maybe_emit_back_edge(
256 &self,
257 current_room_id: usize,
258 current_index: usize,
259 current: Point,
260 mut f: impl FnMut(usize, usize),
261 ) {
262 if current_room_id != self.room_id {
263 return;
264 }
265
266 if self.back_connectors[..self.back_len]
267 .iter()
268 .all(|connector| *connector != current_index)
269 {
270 return;
271 }
272
273 f(
274 self.special_index,
275 manhattan_distance(current, self.special),
276 );
277 }
278}
279
280impl Ord for FrontierEntry {
281 fn cmp(&self, other: &Self) -> Ordering {
282 other
283 .estimated_total_cost
284 .cmp(&self.estimated_total_cost)
285 .then_with(|| other.cost_so_far.cmp(&self.cost_so_far))
286 .then_with(|| other.index.cmp(&self.index))
287 }
288}
289
290impl PartialOrd for FrontierEntry {
291 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
292 Some(self.cmp(other))
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297struct Room {
298 x1: usize,
299 y1: usize,
300 x2: usize,
301 y2: usize,
302 trivial: bool,
303 left_side: Vec<SideEntry>,
304 right_side: Vec<SideEntry>,
305 top_side: Vec<SideEntry>,
306 bottom_side: Vec<SideEntry>,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310struct SideEntry {
311 index: usize,
312 coordinate: usize,
313}
314
315impl Room {
316 fn width(&self) -> usize {
317 self.x2 - self.x1 + 1
318 }
319
320 fn height(&self) -> usize {
321 self.y2 - self.y1 + 1
322 }
323
324 fn is_trivial(&self) -> bool {
325 self.trivial
326 }
327
328 fn is_perimeter(&self, point: Point) -> bool {
329 point.x == self.x1 || point.x == self.x2 || point.y == self.y1 || point.y == self.y2
330 }
331
332 fn is_kept(&self, point: Point) -> bool {
333 self.is_trivial() || self.is_perimeter(point)
334 }
335
336 fn for_each_connection_point(&self, point: Point, mut f: impl FnMut(Point)) {
337 let mut seen = [None; 4];
338 let mut seen_len = 0usize;
339
340 for candidate in [
341 Point::new(self.x1, point.y),
342 Point::new(self.x2, point.y),
343 Point::new(point.x, self.y1),
344 Point::new(point.x, self.y2),
345 ] {
346 if seen[..seen_len]
347 .iter()
348 .flatten()
349 .any(|prior| *prior == candidate)
350 {
351 continue;
352 }
353
354 seen[seen_len] = Some(candidate);
355 seen_len += 1;
356 f(candidate);
357 }
358 }
359
360 fn emit_materialized_macro_edges(
361 &self,
362 point: Point,
363 point_index: usize,
364 side_mask: u8,
365 mut f: impl FnMut(usize, usize),
366 ) {
367 if self.is_trivial() || side_mask == 0 {
368 return;
369 }
370
371 if side_mask & SIDE_LEFT != 0 {
372 let base_cost = self.width() - 1;
373 for entry in &self.right_side {
374 if entry.index != point_index {
375 f(entry.index, base_cost + point.y.abs_diff(entry.coordinate));
376 }
377 }
378 }
379
380 if side_mask & SIDE_RIGHT != 0 {
381 let base_cost = self.width() - 1;
382 for entry in &self.left_side {
383 if entry.index != point_index {
384 f(entry.index, base_cost + point.y.abs_diff(entry.coordinate));
385 }
386 }
387 }
388
389 if side_mask & SIDE_TOP != 0 {
390 let skipped_coordinate = if side_mask & SIDE_LEFT != 0 {
391 Some(self.x2)
392 } else if side_mask & SIDE_RIGHT != 0 {
393 Some(self.x1)
394 } else {
395 None
396 };
397 let base_cost = self.height() - 1;
398 for entry in &self.bottom_side {
399 if Some(entry.coordinate) == skipped_coordinate || entry.index == point_index {
400 continue;
401 }
402 f(entry.index, base_cost + point.x.abs_diff(entry.coordinate));
403 }
404 }
405
406 if side_mask & SIDE_BOTTOM != 0 {
407 let skipped_coordinate = if side_mask & SIDE_LEFT != 0 {
408 Some(self.x2)
409 } else if side_mask & SIDE_RIGHT != 0 {
410 Some(self.x1)
411 } else {
412 None
413 };
414 let base_cost = self.height() - 1;
415 for entry in &self.top_side {
416 if Some(entry.coordinate) == skipped_coordinate || entry.index == point_index {
417 continue;
418 }
419 f(entry.index, base_cost + point.x.abs_diff(entry.coordinate));
420 }
421 }
422 }
423}
424
425const SIDE_LEFT: u8 = 1;
426const SIDE_RIGHT: u8 = 1 << 1;
427const SIDE_TOP: u8 = 1 << 2;
428const SIDE_BOTTOM: u8 = 1 << 3;
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431struct RoomDecomposition {
432 rooms: Vec<Room>,
433 room_by_index: Vec<Option<usize>>,
434 search_node_by_index: Vec<bool>,
435 side_mask_by_index: Vec<u8>,
436}
437
438impl RoomDecomposition {
439 fn new(grid: &Grid) -> Self {
440 let mut assigned = vec![false; grid.cell_count()];
441 let mut room_by_index = vec![None; grid.cell_count()];
442 let mut search_node_by_index = vec![false; grid.cell_count()];
443 let mut side_mask_by_index = vec![0u8; grid.cell_count()];
444 let mut rooms = Vec::new();
445
446 for y in 0..grid.height() {
447 for x in 0..grid.width() {
448 let point = Point::new(x, y);
449 let Some(index) = grid.index_of(point) else {
450 continue;
451 };
452
453 if assigned[index] || !grid.is_walkable(point) {
454 continue;
455 }
456
457 let (width, height) = best_room_from(grid, &assigned, point);
458 let x2 = x + width - 1;
459 let y2 = y + height - 1;
460 let trivial = width <= 2 || height <= 2;
461 let mut left_side = Vec::new();
462 let mut right_side = Vec::new();
463 let mut top_side = Vec::new();
464 let mut bottom_side = Vec::new();
465 let room = Room {
466 x1: x,
467 y1: y,
468 x2,
469 y2,
470 trivial,
471 left_side: Vec::new(),
472 right_side: Vec::new(),
473 top_side: Vec::new(),
474 bottom_side: Vec::new(),
475 };
476 let room_id = rooms.len();
477
478 for yy in room.y1..=room.y2 {
479 for xx in room.x1..=room.x2 {
480 let member = Point::new(xx, yy);
481 let member_index = grid
482 .index_of(member)
483 .expect("room members must exist inside the grid");
484 assigned[member_index] = true;
485 room_by_index[member_index] = Some(room_id);
486
487 let mut side_mask = 0u8;
488 if xx == room.x1 {
489 side_mask |= SIDE_LEFT;
490 }
491 if xx == room.x2 {
492 side_mask |= SIDE_RIGHT;
493 }
494 if yy == room.y1 {
495 side_mask |= SIDE_TOP;
496 }
497 if yy == room.y2 {
498 side_mask |= SIDE_BOTTOM;
499 }
500
501 if trivial || side_mask != 0 {
502 search_node_by_index[member_index] = true;
503 }
504 side_mask_by_index[member_index] = side_mask;
505
506 if !trivial {
507 if side_mask & SIDE_LEFT != 0 {
508 left_side.push(SideEntry {
509 index: member_index,
510 coordinate: yy,
511 });
512 }
513 if side_mask & SIDE_RIGHT != 0 {
514 right_side.push(SideEntry {
515 index: member_index,
516 coordinate: yy,
517 });
518 }
519 if side_mask & SIDE_TOP != 0 {
520 top_side.push(SideEntry {
521 index: member_index,
522 coordinate: xx,
523 });
524 }
525 if side_mask & SIDE_BOTTOM != 0 {
526 bottom_side.push(SideEntry {
527 index: member_index,
528 coordinate: xx,
529 });
530 }
531 }
532 }
533 }
534
535 rooms.push(Room {
536 left_side,
537 right_side,
538 top_side,
539 bottom_side,
540 ..room
541 });
542 }
543 }
544
545 Self {
546 rooms,
547 room_by_index,
548 search_node_by_index,
549 side_mask_by_index,
550 }
551 }
552
553 fn room_id(&self, index: usize) -> Option<usize> {
554 self.room_by_index[index]
555 }
556
557 fn room(&self, room_id: usize) -> &Room {
558 &self.rooms[room_id]
559 }
560
561 fn is_search_node(&self, index: usize, start_index: usize, goal_index: usize) -> bool {
562 index == start_index || index == goal_index || self.search_node_by_index[index]
563 }
564
565 fn side_mask(&self, index: usize) -> u8 {
566 self.side_mask_by_index[index]
567 }
568}
569
570fn best_room_from(grid: &Grid, assigned: &[bool], origin: Point) -> (usize, usize) {
571 let mut best_width = 1;
572 let mut best_height = 1;
573 let mut best_interior = 0usize;
574 let mut best_area = 1usize;
575 let mut min_width = usize::MAX;
576
577 for y in origin.y..grid.height() {
578 let row_width = contiguous_unassigned_width(grid, assigned, origin.x, y);
579 if row_width == 0 {
580 break;
581 }
582
583 min_width = min_width.min(row_width);
584 let height = y - origin.y + 1;
585 let area = min_width * height;
586 let interior = interior_node_count(min_width, height);
587
588 if (interior, area) > (best_interior, best_area) {
589 best_interior = interior;
590 best_area = area;
591 best_width = min_width;
592 best_height = height;
593 }
594 }
595
596 (best_width, best_height)
597}
598
599fn contiguous_unassigned_width(grid: &Grid, assigned: &[bool], start_x: usize, y: usize) -> usize {
600 let mut width = 0;
601 for x in start_x..grid.width() {
602 let point = Point::new(x, y);
603 if !grid.is_walkable(point) {
604 break;
605 }
606
607 let index = grid
608 .index_of(point)
609 .expect("walkable points must exist inside the grid");
610 if assigned[index] {
611 break;
612 }
613
614 width += 1;
615 }
616 width
617}
618
619fn interior_node_count(width: usize, height: usize) -> usize {
620 width.saturating_sub(2) * height.saturating_sub(2)
621}
622
623fn for_each_successor(
624 grid: &Grid,
625 decomposition: &RoomDecomposition,
626 current: Point,
627 current_index: usize,
628 endpoints: SearchEndpoints,
629 adapters: EndpointAdapters<'_>,
630 mut f: impl FnMut(usize, usize),
631) {
632 let room_id = decomposition
633 .room_id(current_index)
634 .expect("walkable search nodes must belong to a room");
635 let room = decomposition.room(room_id);
636
637 for_each_cardinal_successor(
638 grid,
639 decomposition,
640 current,
641 current_index,
642 endpoints,
643 &mut f,
644 );
645
646 if room.is_trivial() {
647 return;
648 }
649
650 if current_index == endpoints.start_index
651 && let Some(adapter) = adapters.start
652 {
653 adapter.for_each_forward_edge(&mut f);
654 }
655
656 if current_index == endpoints.goal_index
657 && let Some(adapter) = adapters.goal
658 {
659 adapter.for_each_forward_edge(&mut f);
660 }
661
662 let side_mask = decomposition.side_mask(current_index);
663 if side_mask != 0 {
664 room.emit_materialized_macro_edges(current, current_index, side_mask, &mut f);
665
666 if let Some(adapter) = adapters.start {
667 adapter.maybe_emit_back_edge(room_id, current_index, current, &mut f);
668 }
669
670 if let Some(adapter) = adapters.goal {
671 adapter.maybe_emit_back_edge(room_id, current_index, current, &mut f);
672 }
673 }
674}
675
676fn for_each_cardinal_successor(
677 grid: &Grid,
678 decomposition: &RoomDecomposition,
679 current: Point,
680 current_index: usize,
681 endpoints: SearchEndpoints,
682 mut f: impl FnMut(usize, usize),
683) {
684 if current.x > 0 {
685 let target_index = current_index - 1;
686 if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
687 f(target_index, 1);
688 }
689 }
690
691 if current.x + 1 < grid.width() {
692 let target_index = current_index + 1;
693 if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
694 f(target_index, 1);
695 }
696 }
697
698 if current.y > 0 {
699 let target_index = current_index - grid.width();
700 if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
701 f(target_index, 1);
702 }
703 }
704
705 if current.y + 1 < grid.height() {
706 let target_index = current_index + grid.width();
707 if decomposition.is_search_node(target_index, endpoints.start_index, endpoints.goal_index) {
708 f(target_index, 1);
709 }
710 }
711}
712
713fn straight_segment(from: Point, to: Point) -> Vec<Point> {
714 let mut points = Vec::with_capacity(manhattan_distance(from, to));
715 let mut current = from;
716
717 while current.x != to.x {
718 current = if current.x < to.x {
719 Point::new(current.x + 1, current.y)
720 } else {
721 Point::new(current.x - 1, current.y)
722 };
723 points.push(current);
724 }
725
726 while current.y != to.y {
727 current = if current.y < to.y {
728 Point::new(current.x, current.y + 1)
729 } else {
730 Point::new(current.x, current.y - 1)
731 };
732 points.push(current);
733 }
734
735 points
736}
737
738fn direct_room_path(start: Point, goal: Point) -> Vec<Point> {
739 let mut steps = vec![start];
740 steps.extend(straight_segment(start, goal));
741 steps
742}
743
744fn reconstruct_path(
745 grid: &Grid,
746 start: Point,
747 goal_index: usize,
748 parents: &[Option<usize>],
749) -> Path {
750 let mut segments = Vec::new();
751 let mut current_index = goal_index;
752
753 while let Some(parent_index) = parents[current_index] {
754 let parent = grid.point_from_index(parent_index);
755 let current = grid.point_from_index(current_index);
756 segments.push(straight_segment(parent, current));
757 current_index = parent_index;
758 }
759
760 let mut steps = vec![start];
761 for segment in segments.iter().rev() {
762 steps.extend(segment.iter().copied());
763 }
764
765 let cost = weighted_path_cost(grid, &steps);
766 Path::from_steps_with_cost(steps, cost).expect("path contains at least one point")
767}
768
769fn weighted_path_cost(grid: &Grid, steps: &[Point]) -> usize {
770 steps.iter().skip(1).fold(0usize, |total, point| {
771 total.saturating_add(grid.traversal_cost(*point).unwrap_or(1))
772 })
773}
774
775fn manhattan_distance(from: Point, to: Point) -> usize {
776 from.x.abs_diff(to.x) + from.y.abs_diff(to.y)
777}
778
779#[cfg(test)]
780mod tests {
781 use crate::{
782 algorithms::rectangular_symmetry_reduction::RectangularSymmetryReduction,
783 grid::{Cell, Grid},
784 point::Point,
785 search::{Pathfinder, SearchRequest},
786 };
787
788 #[test]
789 fn charges_per_cell_traversal_cost_matching_dijkstra() {
790 let mut grid = Grid::new(8, 1).expect("grid dimensions are valid");
791 assert_eq!(grid.set_traversal_cost(Point::new(4, 0), 5), Ok(()));
792
793 let request = SearchRequest::new(Point::new(0, 0), Point::new(7, 0));
794 let result = RectangularSymmetryReduction.search(&grid, request);
795
796 assert!(result.as_ref().expect("valid search request").is_found());
797 assert_eq!(
798 result.as_ref().expect("valid search request").cost(),
799 Some(11)
800 );
801
802 let path = result
803 .as_ref()
804 .expect("valid search request")
805 .path()
806 .expect("path should exist");
807 let manual: usize = path
808 .steps()
809 .iter()
810 .skip(1)
811 .map(|p| grid.traversal_cost(*p).expect("walkable cell has cost"))
812 .sum();
813 assert_eq!(
814 Some(manual),
815 result.as_ref().expect("valid search request").cost()
816 );
817
818 let dijkstra_cost = crate::Dijkstra
819 .search(&grid, request)
820 .as_ref()
821 .expect("valid search request")
822 .cost();
823 assert_eq!(
824 result.as_ref().expect("valid search request").cost(),
825 dijkstra_cost
826 );
827 }
828
829 #[test]
830 fn finds_a_shortest_path_through_the_only_gap() {
831 let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
832 for y in 0..5 {
833 if y != 2 {
834 grid.set_cell(Point::new(2, y), Cell::Blocked)
835 .expect("valid grid edit");
836 }
837 }
838
839 let rsr = RectangularSymmetryReduction;
840 let result = rsr.search(
841 &grid,
842 SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
843 );
844
845 assert!(result.as_ref().expect("valid search request").is_found());
846 assert_eq!(
847 result.as_ref().expect("valid search request").cost(),
848 Some(8)
849 );
850
851 let path = result
852 .as_ref()
853 .expect("valid search request")
854 .path()
855 .expect("path should exist");
856 assert_eq!(path.start(), Point::new(0, 0));
857 assert_eq!(path.goal(), Point::new(4, 4));
858 assert!(path.steps().contains(&Point::new(2, 2)));
859 }
860
861 #[test]
862 fn reports_when_no_path_exists() {
863 let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
864 for x in 0..3 {
865 grid.set_cell(Point::new(x, 1), Cell::Blocked)
866 .expect("valid grid edit");
867 }
868
869 let rsr = RectangularSymmetryReduction;
870 let result = rsr.search(
871 &grid,
872 SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
873 );
874
875 assert!(!result.as_ref().expect("valid search request").is_found());
876 assert_eq!(result.as_ref().expect("valid search request").cost(), None);
877 assert!(
878 result
879 .as_ref()
880 .expect("valid search request")
881 .stats()
882 .visited_nodes
883 > 0
884 );
885 }
886
887 #[test]
888 fn directly_connects_points_inside_the_same_empty_room() {
889 let grid = Grid::new(6, 5).expect("grid dimensions are valid");
890 let rsr = RectangularSymmetryReduction;
891 let result = rsr.search(
892 &grid,
893 SearchRequest::new(Point::new(1, 1), Point::new(4, 3)),
894 );
895
896 assert!(result.as_ref().expect("valid search request").is_found());
897 assert_eq!(
898 result.as_ref().expect("valid search request").cost(),
899 Some(5)
900 );
901 assert_eq!(
902 result
903 .as_ref()
904 .expect("valid search request")
905 .stats()
906 .visited_nodes,
907 1
908 );
909 }
910
911 #[test]
912 fn same_room_weighted_route_matches_dijkstra() {
913 let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
914 assert_eq!(grid.set_traversal_cost(Point::new(1, 1), 100), Ok(()));
915
916 let request = SearchRequest::new(Point::new(0, 1), Point::new(2, 1));
917 let result = RectangularSymmetryReduction.search(&grid, request);
918 let dijkstra = crate::Dijkstra.search(&grid, request);
919
920 assert!(result.as_ref().expect("valid search request").is_found());
921 assert_eq!(
922 result.as_ref().expect("valid search request").cost(),
923 Some(4)
924 );
925 assert_eq!(
926 result.as_ref().expect("valid search request").cost(),
927 dijkstra.as_ref().expect("valid search request").cost()
928 );
929 assert!(
930 !result
931 .as_ref()
932 .expect("valid search request")
933 .path()
934 .expect("path should exist")
935 .steps()
936 .contains(&Point::new(1, 1))
937 );
938 }
939
940 #[test]
941 fn connects_inserted_room_endpoints_through_a_doorway() {
942 let mut grid = Grid::new(8, 5).expect("grid dimensions are valid");
943 for y in 0..5 {
944 if y != 2 {
945 grid.set_cell(Point::new(4, y), Cell::Blocked)
946 .expect("valid grid edit");
947 }
948 }
949
950 let rsr = RectangularSymmetryReduction;
951 let result = rsr.search(
952 &grid,
953 SearchRequest::new(Point::new(1, 1), Point::new(6, 3)),
954 );
955
956 assert!(result.as_ref().expect("valid search request").is_found());
957 assert_eq!(
958 result.as_ref().expect("valid search request").cost(),
959 Some(7)
960 );
961
962 let path = result
963 .as_ref()
964 .expect("valid search request")
965 .path()
966 .expect("path should exist");
967 assert_eq!(path.start(), Point::new(1, 1));
968 assert_eq!(path.goal(), Point::new(6, 3));
969 assert!(path.steps().contains(&Point::new(4, 2)));
970 }
971
972 #[test]
973 fn materializes_non_trivial_room_perimeter_metadata() {
974 let grid = Grid::new(4, 4).expect("grid dimensions are valid");
975 let decomposition = super::RoomDecomposition::new(&grid);
976 let room = decomposition.room(0);
977
978 assert!(!room.is_trivial());
979 assert_eq!(room.left_side.len(), 4);
980 assert_eq!(room.right_side.len(), 4);
981 assert_eq!(room.top_side.len(), 4);
982 assert_eq!(room.bottom_side.len(), 4);
983
984 let top_left = grid
985 .index_of(Point::new(0, 0))
986 .expect("top-left should exist");
987 let center = grid
988 .index_of(Point::new(1, 1))
989 .expect("center should exist");
990
991 assert_eq!(
992 decomposition.side_mask(top_left),
993 super::SIDE_LEFT | super::SIDE_TOP
994 );
995 assert!(decomposition.search_node_by_index[top_left]);
996 assert!(!decomposition.search_node_by_index[center]);
997 }
998
999 #[test]
1000 fn materializes_trivial_rooms_as_search_nodes_without_macro_sides() {
1001 let grid = Grid::new(2, 2).expect("grid dimensions are valid");
1002 let decomposition = super::RoomDecomposition::new(&grid);
1003 let room = decomposition.room(0);
1004
1005 assert!(room.is_trivial());
1006 assert!(room.left_side.is_empty());
1007 assert!(room.right_side.is_empty());
1008 assert!(room.top_side.is_empty());
1009 assert!(room.bottom_side.is_empty());
1010
1011 for y in 0..grid.height() {
1012 for x in 0..grid.width() {
1013 let index = grid
1014 .index_of(Point::new(x, y))
1015 .expect("grid point should exist");
1016 assert!(decomposition.search_node_by_index[index]);
1017 }
1018 }
1019 }
1020}