1use std::collections::{BTreeMap, BTreeSet, VecDeque};
11
12use serde::Deserialize;
13
14use crate::{Cell, Grid, Point};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MapfAgent {
22 pub agent_id: String,
24 pub start: Point,
26 pub goal: Point,
28}
29
30impl MapfAgent {
31 #[must_use]
33 pub fn new(agent_id: impl Into<String>, start: Point, goal: Point) -> Self {
34 Self {
35 agent_id: agent_id.into(),
36 start,
37 goal,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MapfObjective {
48 Makespan,
50 SumOfCosts,
52}
53
54impl MapfObjective {
55 #[must_use]
57 pub const fn slug(self) -> &'static str {
58 match self {
59 Self::Makespan => "makespan",
60 Self::SumOfCosts => "sum-of-costs",
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct MapfProblem {
68 pub problem_id: String,
70 pub grid: Grid,
72 pub agents: Vec<MapfAgent>,
74 pub objective: MapfObjective,
76}
77
78impl MapfProblem {
79 #[must_use]
84 pub fn validate_plan(&self, plan: &MapfPlan) -> MapfValidationReport {
85 validate_mapf_plan(self, plan)
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct MapfStarterPlanner {
103 max_timesteps: usize,
104}
105
106impl MapfStarterPlanner {
107 pub const MAX_TIMESTEPS: usize = 1 << 20;
109
110 #[must_use]
114 pub const fn new(max_timesteps: usize) -> Self {
115 let max_timesteps = if max_timesteps > Self::MAX_TIMESTEPS {
116 Self::MAX_TIMESTEPS
117 } else {
118 max_timesteps
119 };
120 Self { max_timesteps }
121 }
122
123 #[must_use]
125 pub const fn max_timesteps(self) -> usize {
126 self.max_timesteps
127 }
128
129 #[must_use]
131 pub fn plan(&self, problem: &MapfProblem) -> MapfStarterPlannerResult {
132 let mut reservations = ReservationTable::default();
133 let mut planned_paths = Vec::with_capacity(problem.agents.len());
134
135 for agent in &problem.agents {
136 let path = match self.plan_agent_path(problem, agent, &reservations) {
137 Ok(path) => path,
138 Err(reason) => {
139 return MapfStarterPlannerResult::failed(reason, MapfPlan::new(planned_paths));
140 }
141 };
142
143 reservations.reserve_path(&path, self.max_timesteps);
144 planned_paths.push(MapfAgentPath::new(
145 agent.agent_id.clone(),
146 pad_path_to_horizon(path, self.max_timesteps),
147 ));
148 }
149
150 let plan = MapfPlan::new(planned_paths);
151 let validation = problem.validate_plan(&plan);
152 if validation.valid {
153 MapfStarterPlannerResult::solved(plan, validation)
154 } else {
155 MapfStarterPlannerResult::failed(
156 MapfStarterPlannerFailure::UnvalidatedPlan { validation },
157 plan,
158 )
159 }
160 }
161
162 fn plan_agent_path(
163 &self,
164 problem: &MapfProblem,
165 agent: &MapfAgent,
166 reservations: &ReservationTable,
167 ) -> Result<Vec<Point>, MapfStarterPlannerFailure> {
168 if !problem.grid.is_walkable(agent.start) {
169 return Err(MapfStarterPlannerFailure::InvalidStart {
170 agent_id: agent.agent_id.clone(),
171 point: agent.start,
172 });
173 }
174 if !problem.grid.is_walkable(agent.goal) {
175 return Err(MapfStarterPlannerFailure::InvalidGoal {
176 agent_id: agent.agent_id.clone(),
177 point: agent.goal,
178 });
179 }
180 if reservations.vertex_reserved(0, agent.start) {
181 return Err(MapfStarterPlannerFailure::StartReserved {
182 agent_id: agent.agent_id.clone(),
183 point: agent.start,
184 });
185 }
186
187 let start_state = TimedPoint {
188 timestep: 0,
189 point: agent.start,
190 };
191 let mut frontier = VecDeque::from([start_state]);
192 let mut parents = BTreeMap::from([(start_state, None)]);
193
194 while let Some(state) = frontier.pop_front() {
195 if state.point == agent.goal
196 && reservations.vertex_available_from(
197 state.timestep,
198 agent.goal,
199 self.max_timesteps,
200 )
201 {
202 return Ok(reconstruct_timed_path(state, &parents));
203 }
204
205 if state.timestep >= self.max_timesteps {
206 continue;
207 }
208
209 for next_point in ordered_mapf_moves(&problem.grid, state.point, agent.goal) {
210 let next_state = TimedPoint {
211 timestep: state.timestep + 1,
212 point: next_point,
213 };
214 if parents.contains_key(&next_state)
215 || !reservations.transition_allowed(state.timestep + 1, state.point, next_point)
216 {
217 continue;
218 }
219
220 parents.insert(next_state, Some(state));
221 frontier.push_back(next_state);
222 }
223 }
224
225 Err(MapfStarterPlannerFailure::NoPathWithinHorizon {
226 agent_id: agent.agent_id.clone(),
227 max_timesteps: self.max_timesteps,
228 })
229 }
230}
231
232impl Default for MapfStarterPlanner {
233 fn default() -> Self {
234 Self::new(64)
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct MapfStarterPlannerResult {
241 pub outcome: MapfStarterPlannerOutcome,
243}
244
245impl MapfStarterPlannerResult {
246 #[must_use]
248 pub const fn is_solved(&self) -> bool {
249 matches!(self.outcome, MapfStarterPlannerOutcome::Solved { .. })
250 }
251
252 #[must_use]
254 pub const fn solved_plan(&self) -> Option<&MapfPlan> {
255 match &self.outcome {
256 MapfStarterPlannerOutcome::Solved { plan, .. } => Some(plan),
257 MapfStarterPlannerOutcome::Failed { .. } => None,
258 }
259 }
260
261 #[must_use]
263 pub const fn partial_plan(&self) -> Option<&MapfPlan> {
264 match &self.outcome {
265 MapfStarterPlannerOutcome::Solved { .. } => None,
266 MapfStarterPlannerOutcome::Failed { partial_plan, .. } => Some(partial_plan),
267 }
268 }
269
270 #[must_use]
275 pub const fn plan(&self) -> Option<&MapfPlan> {
276 match &self.outcome {
277 MapfStarterPlannerOutcome::Solved { plan, .. } => Some(plan),
278 MapfStarterPlannerOutcome::Failed { partial_plan, .. } => Some(partial_plan),
279 }
280 }
281
282 #[must_use]
285 pub const fn validation_report(&self) -> Option<&MapfValidationReport> {
286 match &self.outcome {
287 MapfStarterPlannerOutcome::Solved { validation, .. } => Some(validation),
288 MapfStarterPlannerOutcome::Failed {
289 reason: MapfStarterPlannerFailure::UnvalidatedPlan { validation, .. },
290 ..
291 } => Some(validation),
292 MapfStarterPlannerOutcome::Failed { .. } => None,
293 }
294 }
295
296 fn solved(plan: MapfPlan, validation: MapfValidationReport) -> Self {
297 Self {
298 outcome: MapfStarterPlannerOutcome::Solved { plan, validation },
299 }
300 }
301
302 fn failed(reason: MapfStarterPlannerFailure, partial_plan: MapfPlan) -> Self {
303 Self {
304 outcome: MapfStarterPlannerOutcome::Failed {
305 reason,
306 partial_plan,
307 },
308 }
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum MapfStarterPlannerOutcome {
315 Solved {
317 plan: MapfPlan,
318 validation: MapfValidationReport,
319 },
320 Failed {
322 reason: MapfStarterPlannerFailure,
323 partial_plan: MapfPlan,
325 },
326}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum MapfStarterPlannerFailure {
331 InvalidStart { agent_id: String, point: Point },
333 InvalidGoal { agent_id: String, point: Point },
335 StartReserved { agent_id: String, point: Point },
337 NoPathWithinHorizon {
339 agent_id: String,
340 max_timesteps: usize,
341 },
342 UnvalidatedPlan { validation: MapfValidationReport },
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct MapfAgentPath {
349 pub agent_id: String,
351 pub positions: Vec<Point>,
353}
354
355impl MapfAgentPath {
356 #[must_use]
358 pub fn new(agent_id: impl Into<String>, positions: Vec<Point>) -> Self {
359 Self {
360 agent_id: agent_id.into(),
361 positions,
362 }
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct MapfPlan {
369 pub agent_paths: Vec<MapfAgentPath>,
371}
372
373impl MapfPlan {
374 #[must_use]
376 pub fn new(agent_paths: Vec<MapfAgentPath>) -> Self {
377 Self { agent_paths }
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
387pub enum MapfConflict {
388 InvalidStart { agent_id: String, point: Point },
390 InvalidGoal { agent_id: String, point: Point },
392 EmptyAgentPath { agent_id: String },
394 MissingAgentPath { agent_id: String },
396 UnknownAgentPath { agent_id: String },
398 DuplicateAgentPath { agent_id: String },
400 StartMismatch {
402 agent_id: String,
403 expected: Point,
404 actual: Point,
405 },
406 OutOfBoundsCell {
408 agent_id: String,
409 timestep: usize,
410 point: Point,
411 },
412 BlockedCell {
414 agent_id: String,
415 timestep: usize,
416 point: Point,
417 },
418 IllegalTransition {
420 agent_id: String,
421 timestep: usize,
422 from: Point,
423 to: Point,
424 },
425 GoalNotReached {
427 agent_id: String,
428 expected: Point,
429 actual: Point,
430 },
431 GoalDeparted {
433 agent_id: String,
434 timestep: usize,
435 goal: Point,
436 actual: Point,
437 },
438 Vertex {
440 timestep: usize,
441 point: Point,
442 agent_ids: Vec<String>,
443 },
444 EdgeSwap {
446 timestep: usize,
447 from: Point,
448 to: Point,
449 agent_a: String,
450 agent_b: String,
451 },
452}
453
454impl MapfConflict {
455 #[must_use]
457 pub const fn kind(&self) -> &'static str {
458 match self {
459 Self::InvalidStart { .. } => "invalid-start",
460 Self::InvalidGoal { .. } => "invalid-goal",
461 Self::EmptyAgentPath { .. } => "empty-agent-path",
462 Self::MissingAgentPath { .. } => "missing-agent-path",
463 Self::UnknownAgentPath { .. } => "unknown-agent-path",
464 Self::DuplicateAgentPath { .. } => "duplicate-agent-path",
465 Self::StartMismatch { .. } => "start-mismatch",
466 Self::OutOfBoundsCell { .. } => "out-of-bounds-cell",
467 Self::BlockedCell { .. } => "blocked-cell",
468 Self::IllegalTransition { .. } => "illegal-transition",
469 Self::GoalNotReached { .. } => "goal-not-reached",
470 Self::GoalDeparted { .. } => "goal-departed",
471 Self::Vertex { .. } => "vertex",
472 Self::EdgeSwap { .. } => "edge-swap",
473 }
474 }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub struct MapfPlanMetrics {
480 pub makespan: usize,
482 pub sum_of_costs: usize,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct MapfValidationReport {
489 pub valid: bool,
491 pub conflicts: Vec<MapfConflict>,
493 pub metrics: MapfPlanMetrics,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
498struct TimedPoint {
499 timestep: usize,
500 point: Point,
501}
502
503#[derive(Debug, Default, Clone, PartialEq, Eq)]
504struct ReservationTable {
505 vertices: BTreeSet<(usize, Point)>,
506 edges: BTreeSet<(usize, Point, Point)>,
507}
508
509impl ReservationTable {
510 fn reserve_path(&mut self, path: &[Point], max_timesteps: usize) {
511 let Some(&last) = path.last() else {
512 return;
513 };
514
515 for timestep in 0..=max_timesteps {
516 let point = path.get(timestep).copied().unwrap_or(last);
517 self.vertices.insert((timestep, point));
518
519 if timestep > 0 {
520 let previous = path.get(timestep - 1).copied().unwrap_or(last);
521 self.edges.insert((timestep, previous, point));
522 }
523 }
524 }
525
526 fn vertex_reserved(&self, timestep: usize, point: Point) -> bool {
527 self.vertices.contains(&(timestep, point))
528 }
529
530 fn vertex_available_from(
531 &self,
532 start_timestep: usize,
533 point: Point,
534 max_timestep: usize,
535 ) -> bool {
536 (start_timestep..=max_timestep).all(|timestep| !self.vertex_reserved(timestep, point))
537 }
538
539 fn transition_allowed(&self, timestep: usize, from: Point, to: Point) -> bool {
540 !self.vertex_reserved(timestep, to) && !self.edges.contains(&(timestep, to, from))
541 }
542}
543
544fn ordered_mapf_moves(grid: &Grid, current: Point, goal: Point) -> Vec<Point> {
546 let mut candidates = Vec::with_capacity(5);
547 candidates.push(current);
548 candidates.extend(grid.neighbors4(current));
549 candidates.sort_by_key(|point| (manhattan_distance(*point, goal), *point));
550 candidates
551}
552
553fn reconstruct_timed_path(
554 end_state: TimedPoint,
555 parents: &BTreeMap<TimedPoint, Option<TimedPoint>>,
556) -> Vec<Point> {
557 let mut states = vec![end_state];
558 let mut current = end_state;
559
560 while let Some(parent) = parents
561 .get(¤t)
562 .expect("timed path reconstruction requires known states")
563 {
564 states.push(*parent);
565 current = *parent;
566 }
567
568 states.reverse();
569 states.into_iter().map(|state| state.point).collect()
570}
571
572fn pad_path_to_horizon(mut path: Vec<Point>, max_timesteps: usize) -> Vec<Point> {
574 let target_len = max_timesteps + 1;
575 let last = path
576 .last()
577 .copied()
578 .expect("planned agent paths always include a start point");
579 path.resize(target_len, last);
580 path
581}
582
583fn manhattan_distance(from: Point, to: Point) -> usize {
584 from.x.abs_diff(to.x) + from.y.abs_diff(to.y)
585}
586
587fn validate_mapf_plan(problem: &MapfProblem, plan: &MapfPlan) -> MapfValidationReport {
588 let mut conflicts = validate_problem(problem);
589 let agent_by_id = problem
590 .agents
591 .iter()
592 .map(|agent| (agent.agent_id.as_str(), agent))
593 .collect::<BTreeMap<_, _>>();
594 let mut paths_by_agent: BTreeMap<&str, &MapfAgentPath> = BTreeMap::new();
595
596 for agent_path in &plan.agent_paths {
597 let agent_id = agent_path.agent_id.as_str();
598 if !agent_by_id.contains_key(agent_id) {
599 conflicts.push(MapfConflict::UnknownAgentPath {
600 agent_id: agent_path.agent_id.clone(),
601 });
602 continue;
603 }
604 if paths_by_agent.insert(agent_id, agent_path).is_some() {
605 conflicts.push(MapfConflict::DuplicateAgentPath {
606 agent_id: agent_path.agent_id.clone(),
607 });
608 }
609 }
610
611 for agent in &problem.agents {
612 let Some(path) = paths_by_agent.get(agent.agent_id.as_str()) else {
613 conflicts.push(MapfConflict::MissingAgentPath {
614 agent_id: agent.agent_id.clone(),
615 });
616 continue;
617 };
618
619 validate_agent_path(problem, agent, path, &mut conflicts);
620 }
621
622 conflicts.extend(vertex_conflicts(plan));
623 conflicts.extend(edge_swap_conflicts(plan));
624
625 let metrics = metrics_for(problem, &paths_by_agent);
626 let valid = conflicts.is_empty();
627 MapfValidationReport {
628 valid,
629 conflicts,
630 metrics,
631 }
632}
633
634fn validate_problem(problem: &MapfProblem) -> Vec<MapfConflict> {
635 let mut conflicts = Vec::new();
636 for agent in &problem.agents {
637 if !problem.grid.is_walkable(agent.start) {
638 conflicts.push(MapfConflict::InvalidStart {
639 agent_id: agent.agent_id.clone(),
640 point: agent.start,
641 });
642 }
643 if !problem.grid.is_walkable(agent.goal) {
644 conflicts.push(MapfConflict::InvalidGoal {
645 agent_id: agent.agent_id.clone(),
646 point: agent.goal,
647 });
648 }
649 }
650 conflicts
651}
652
653fn validate_agent_path(
654 problem: &MapfProblem,
655 agent: &MapfAgent,
656 path: &MapfAgentPath,
657 conflicts: &mut Vec<MapfConflict>,
658) {
659 let Some(first) = path.positions.first().copied() else {
660 conflicts.push(MapfConflict::EmptyAgentPath {
661 agent_id: agent.agent_id.clone(),
662 });
663 return;
664 };
665
666 if first != agent.start {
667 conflicts.push(MapfConflict::StartMismatch {
668 agent_id: agent.agent_id.clone(),
669 expected: agent.start,
670 actual: first,
671 });
672 }
673
674 for (timestep, &point) in path.positions.iter().enumerate() {
675 match problem.grid.cell(point) {
676 Some(Cell::Open) => {}
677 Some(Cell::Blocked) => conflicts.push(MapfConflict::BlockedCell {
678 agent_id: agent.agent_id.clone(),
679 timestep,
680 point,
681 }),
682 None => conflicts.push(MapfConflict::OutOfBoundsCell {
683 agent_id: agent.agent_id.clone(),
684 timestep,
685 point,
686 }),
687 }
688 }
689
690 for (transition_index, pair) in path.positions.windows(2).enumerate() {
691 if !problem.grid.segment_is_walkable(pair[0], pair[1]) {
692 conflicts.push(MapfConflict::IllegalTransition {
693 agent_id: agent.agent_id.clone(),
694 timestep: transition_index + 1,
695 from: pair[0],
696 to: pair[1],
697 });
698 }
699 }
700
701 let Some(first_arrival) = path.positions.iter().position(|&point| point == agent.goal) else {
702 conflicts.push(MapfConflict::GoalNotReached {
703 agent_id: agent.agent_id.clone(),
704 expected: agent.goal,
705 actual: *path
706 .positions
707 .last()
708 .expect("non-empty path should still have a last point"),
709 });
710 return;
711 };
712
713 if let Some((offset, &actual)) = path.positions[first_arrival + 1..]
714 .iter()
715 .enumerate()
716 .find(|(_, point)| **point != agent.goal)
717 {
718 conflicts.push(MapfConflict::GoalDeparted {
719 agent_id: agent.agent_id.clone(),
720 timestep: first_arrival + 1 + offset,
721 goal: agent.goal,
722 actual,
723 });
724 }
725}
726
727fn vertex_conflicts(plan: &MapfPlan) -> Vec<MapfConflict> {
728 let mut occupancy: BTreeMap<(usize, Point), Vec<String>> = BTreeMap::new();
729 let horizon = plan
730 .agent_paths
731 .iter()
732 .map(|path| path.positions.len())
733 .max()
734 .unwrap_or(0);
735
736 for path in &plan.agent_paths {
737 for timestep in 0..horizon {
738 let Some(point) = path_position_at_or_after(path, timestep) else {
739 continue;
740 };
741 occupancy
742 .entry((timestep, point))
743 .or_default()
744 .push(path.agent_id.clone());
745 }
746 }
747
748 occupancy
749 .into_iter()
750 .filter_map(|((timestep, point), mut agent_ids)| {
751 if agent_ids.len() < 2 {
752 return None;
753 }
754 agent_ids.sort();
755 Some(MapfConflict::Vertex {
756 timestep,
757 point,
758 agent_ids,
759 })
760 })
761 .collect()
762}
763
764fn edge_swap_conflicts(plan: &MapfPlan) -> Vec<MapfConflict> {
765 let mut conflicts = Vec::new();
766 let horizon = plan
767 .agent_paths
768 .iter()
769 .map(|path| path.positions.len())
770 .max()
771 .unwrap_or(0);
772
773 for (left_index, left) in plan.agent_paths.iter().enumerate() {
774 for right in plan.agent_paths.iter().skip(left_index + 1) {
775 for timestep in 1..horizon {
776 let Some(left_from) = path_position_at_or_after(left, timestep - 1) else {
777 continue;
778 };
779 let Some(left_to) = path_position_at_or_after(left, timestep) else {
780 continue;
781 };
782 let Some(right_from) = path_position_at_or_after(right, timestep - 1) else {
783 continue;
784 };
785 let Some(right_to) = path_position_at_or_after(right, timestep) else {
786 continue;
787 };
788
789 if left_from == right_to && left_to == right_from && left_from != left_to {
790 conflicts.push(MapfConflict::EdgeSwap {
791 timestep,
792 from: left_from,
793 to: left_to,
794 agent_a: left.agent_id.clone(),
795 agent_b: right.agent_id.clone(),
796 });
797 }
798 }
799 }
800 }
801 conflicts
802}
803
804fn path_position_at_or_after(path: &MapfAgentPath, timestep: usize) -> Option<Point> {
805 path.positions
806 .get(timestep)
807 .copied()
808 .or_else(|| path.positions.last().copied())
809}
810
811fn metrics_for(
812 problem: &MapfProblem,
813 paths_by_agent: &BTreeMap<&str, &MapfAgentPath>,
814) -> MapfPlanMetrics {
815 let mut makespan = 0usize;
816 let mut sum_of_costs = 0usize;
817
818 for agent in &problem.agents {
819 let Some(path) = paths_by_agent.get(agent.agent_id.as_str()) else {
820 continue;
821 };
822 if path.positions.is_empty() {
823 continue;
824 }
825
826 let first_arrival = path
827 .positions
828 .iter()
829 .position(|&point| point == agent.goal)
830 .unwrap_or_else(|| path.positions.len() - 1);
831 makespan = makespan.max(first_arrival);
832 sum_of_costs += first_arrival;
833 }
834
835 MapfPlanMetrics {
836 makespan,
837 sum_of_costs,
838 }
839}
840
841impl<'de> Deserialize<'de> for MapfObjective {
842 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
843 where
844 D: serde::Deserializer<'de>,
845 {
846 let value = String::deserialize(deserializer)?;
847 match value.as_str() {
848 "makespan" => Ok(Self::Makespan),
849 "sum-of-costs" => Ok(Self::SumOfCosts),
850 _ => Err(serde::de::Error::custom(format!(
851 "unsupported MAPF objective '{value}'"
852 ))),
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn starter_planner_reports_explicit_bounded_failure() {
863 let problem = MapfProblem {
864 problem_id: "starter-bounded-failure".to_string(),
865 grid: Grid::new(3, 1).expect("grid dimensions are valid"),
866 agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
867 objective: MapfObjective::SumOfCosts,
868 };
869
870 let result = MapfStarterPlanner::new(1).plan(&problem);
871 assert!(!result.is_solved());
872 assert!(result.solved_plan().is_none());
873 assert!(
874 result
875 .partial_plan()
876 .expect("failed result carries partial plan")
877 .agent_paths
878 .is_empty()
879 );
880 match result.outcome {
881 MapfStarterPlannerOutcome::Failed {
882 reason:
883 MapfStarterPlannerFailure::NoPathWithinHorizon {
884 agent_id,
885 max_timesteps,
886 },
887 partial_plan,
888 } => {
889 assert_eq!(agent_id, "alpha");
890 assert_eq!(max_timesteps, 1);
891 assert!(partial_plan.agent_paths.is_empty());
892 }
893 other => panic!("expected bounded no-path failure, got {other:?}"),
894 }
895 }
896
897 #[test]
898 fn starter_planner_reports_invalid_start_for_blocked_start_cell() {
899 let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
900 grid.set_cell(Point::new(0, 0), Cell::Blocked)
901 .expect("grid edit should succeed");
902 let problem = MapfProblem {
903 problem_id: "starter-invalid-start".to_string(),
904 grid,
905 agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
906 objective: MapfObjective::SumOfCosts,
907 };
908
909 match MapfStarterPlanner::new(64).plan(&problem).outcome {
910 MapfStarterPlannerOutcome::Failed {
911 reason: MapfStarterPlannerFailure::InvalidStart { agent_id, point },
912 ..
913 } => {
914 assert_eq!(agent_id, "alpha");
915 assert_eq!(point, Point::new(0, 0));
916 }
917 other => panic!("expected invalid-start failure, got {other:?}"),
918 }
919 }
920
921 #[test]
922 fn starter_planner_reports_invalid_goal_for_blocked_goal_cell() {
923 let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
924 grid.set_cell(Point::new(2, 0), Cell::Blocked)
925 .expect("grid edit should succeed");
926 let problem = MapfProblem {
927 problem_id: "starter-invalid-goal".to_string(),
928 grid,
929 agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(2, 0))],
930 objective: MapfObjective::SumOfCosts,
931 };
932
933 match MapfStarterPlanner::new(64).plan(&problem).outcome {
934 MapfStarterPlannerOutcome::Failed {
935 reason: MapfStarterPlannerFailure::InvalidGoal { agent_id, point },
936 ..
937 } => {
938 assert_eq!(agent_id, "alpha");
939 assert_eq!(point, Point::new(2, 0));
940 }
941 other => panic!("expected invalid-goal failure, got {other:?}"),
942 }
943 }
944
945 #[test]
946 fn starter_planner_saturates_unbounded_horizon() {
947 let planner = MapfStarterPlanner::new(usize::MAX);
948 assert_eq!(planner.max_timesteps(), MapfStarterPlanner::MAX_TIMESTEPS);
949
950 let problem = MapfProblem {
951 problem_id: "starter-unbounded-horizon".to_string(),
952 grid: Grid::new(5, 5).expect("grid dimensions are valid"),
953 agents: vec![MapfAgent::new("alpha", Point::new(0, 0), Point::new(4, 4))],
954 objective: MapfObjective::SumOfCosts,
955 };
956
957 assert!(planner.plan(&problem).is_solved());
958 }
959}