1use std::collections::HashSet;
7
8use petgraph::stable_graph::NodeIndex;
9
10use crate::model::GalaxyModel;
11use crate::spatial::SystemId;
12
13#[derive(Debug, Clone)]
15pub struct RouteHop {
16 pub system_id: SystemId,
18 pub leg_distance_ly: f64,
20 pub cumulative_ly: f64,
22 pub is_waypoint: bool,
24}
25
26#[derive(Debug, Clone)]
28pub struct Route {
29 pub hops: Vec<RouteHop>,
31 pub total_distance_ly: f64,
33}
34
35#[derive(Debug, Clone, Copy, Default)]
37pub enum RoutingAlgorithm {
38 NearestNeighbor,
40 #[default]
42 TwoOpt,
43}
44
45#[derive(Debug, Clone)]
47pub enum RouteError {
48 SystemNotFound(SystemId),
50 NoPath { from: SystemId, to: SystemId },
52 TooFewTargets,
54}
55
56impl std::fmt::Display for RouteError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::SystemNotFound(id) => write!(f, "system not found: 0x{:012X}", id.0),
60 Self::NoPath { from, to } => {
61 write!(f, "no path from 0x{:012X} to 0x{:012X}", from.0, to.0)
62 }
63 Self::TooFewTargets => write!(f, "need at least 1 target for a route"),
64 }
65 }
66}
67
68impl std::error::Error for RouteError {}
69
70impl GalaxyModel {
71 pub fn shortest_path(&self, from: SystemId, to: SystemId) -> Result<Route, RouteError> {
77 if from == to {
78 return Ok(Route {
79 hops: vec![RouteHop {
80 system_id: from,
81 leg_distance_ly: 0.0,
82 cumulative_ly: 0.0,
83 is_waypoint: false,
84 }],
85 total_distance_ly: 0.0,
86 });
87 }
88
89 let from_node = self
90 .node_map
91 .get(&from)
92 .ok_or(RouteError::SystemNotFound(from))?;
93 let to_node = self
94 .node_map
95 .get(&to)
96 .ok_or(RouteError::SystemNotFound(to))?;
97
98 use petgraph::algo::astar;
100 match astar(
101 &self.graph,
102 *from_node,
103 |n| n == *to_node,
104 |e| *e.weight(),
105 |_| 0.0,
106 ) {
107 Some((_, path)) => Ok(self.path_to_route(&path)),
108 None => self.direct_route(from, to),
109 }
110 }
111
112 fn direct_route(&self, from: SystemId, to: SystemId) -> Result<Route, RouteError> {
114 let dist = self.euclidean_distance(from, to);
115 Ok(Route {
116 hops: vec![
117 RouteHop {
118 system_id: from,
119 leg_distance_ly: 0.0,
120 cumulative_ly: 0.0,
121 is_waypoint: false,
122 },
123 RouteHop {
124 system_id: to,
125 leg_distance_ly: dist,
126 cumulative_ly: dist,
127 is_waypoint: false,
128 },
129 ],
130 total_distance_ly: dist,
131 })
132 }
133
134 fn path_to_route(&self, path: &[NodeIndex]) -> Route {
136 let mut hops = Vec::with_capacity(path.len());
137 let mut cumulative = 0.0;
138
139 for (i, &node) in path.iter().enumerate() {
140 let sys_id = self.graph[node];
141 let leg_dist = if i == 0 {
142 0.0
143 } else {
144 let prev = path[i - 1];
145 self.graph
146 .find_edge(prev, node)
147 .map(|e| self.graph[e])
148 .unwrap_or_else(|| {
149 let prev_id = self.graph[prev];
150 self.euclidean_distance(prev_id, sys_id)
151 })
152 };
153 cumulative += leg_dist;
154 hops.push(RouteHop {
155 system_id: sys_id,
156 leg_distance_ly: leg_dist,
157 cumulative_ly: cumulative,
158 is_waypoint: false,
159 });
160 }
161
162 Route {
163 total_distance_ly: cumulative,
164 hops,
165 }
166 }
167
168 fn euclidean_distance(&self, a: SystemId, b: SystemId) -> f64 {
170 match (self.systems.get(&a), self.systems.get(&b)) {
171 (Some(sa), Some(sb)) => sa.address.distance_ly(&sb.address),
172 _ => 0.0,
173 }
174 }
175
176 pub fn tsp_nearest_neighbor(
182 &self,
183 start: SystemId,
184 targets: &[SystemId],
185 return_to_start: bool,
186 ) -> Result<Route, RouteError> {
187 if targets.is_empty() {
188 return Err(RouteError::TooFewTargets);
189 }
190 if !self.systems.contains_key(&start) {
191 return Err(RouteError::SystemNotFound(start));
192 }
193 for &t in targets {
194 if !self.systems.contains_key(&t) {
195 return Err(RouteError::SystemNotFound(t));
196 }
197 }
198
199 let mut unvisited: Vec<SystemId> = targets.to_vec();
200 let mut order = vec![start];
201 let mut current = start;
202
203 while !unvisited.is_empty() {
204 let (nearest_idx, _) = unvisited
205 .iter()
206 .enumerate()
207 .map(|(i, &t)| (i, self.euclidean_distance(current, t)))
208 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
209 .unwrap();
210
211 current = unvisited.swap_remove(nearest_idx);
212 order.push(current);
213 }
214
215 if return_to_start {
216 order.push(start);
217 }
218
219 Ok(self.build_route_from_order(&order))
220 }
221
222 pub fn build_route_from_order(&self, order: &[SystemId]) -> Route {
224 let mut hops = Vec::with_capacity(order.len());
225 let mut cumulative = 0.0;
226
227 for (i, &sys_id) in order.iter().enumerate() {
228 let leg_dist = if i == 0 {
229 0.0
230 } else {
231 self.euclidean_distance(order[i - 1], sys_id)
232 };
233 cumulative += leg_dist;
234 hops.push(RouteHop {
235 system_id: sys_id,
236 leg_distance_ly: leg_dist,
237 cumulative_ly: cumulative,
238 is_waypoint: false,
239 });
240 }
241
242 Route {
243 total_distance_ly: cumulative,
244 hops,
245 }
246 }
247
248 pub fn two_opt_improve(&self, mut order: Vec<SystemId>) -> Route {
255 let n = order.len();
256 if n < 4 {
257 return self.build_route_from_order(&order);
258 }
259
260 let mut improved = true;
261 while improved {
262 improved = false;
263 for i in 0..n - 2 {
264 for j in (i + 2)..n {
265 if i == 0 && j == n - 1 {
266 continue;
267 }
268 let gain = self.two_opt_gain(&order, i, j);
269 if gain > 1e-6 {
270 order[i + 1..=j].reverse();
271 improved = true;
272 }
273 }
274 }
275 }
276
277 self.build_route_from_order(&order)
278 }
279
280 fn two_opt_gain(&self, order: &[SystemId], i: usize, j: usize) -> f64 {
282 let d = |a: SystemId, b: SystemId| self.euclidean_distance(a, b);
283
284 let old_dist = d(order[i], order[i + 1])
285 + if j + 1 < order.len() {
286 d(order[j], order[j + 1])
287 } else {
288 0.0
289 };
290
291 let new_dist = d(order[i], order[j])
292 + if j + 1 < order.len() {
293 d(order[i + 1], order[j + 1])
294 } else {
295 0.0
296 };
297
298 old_dist - new_dist
299 }
300
301 pub fn tsp_two_opt(
303 &self,
304 start: SystemId,
305 targets: &[SystemId],
306 return_to_start: bool,
307 ) -> Result<Route, RouteError> {
308 let nn_route = self.tsp_nearest_neighbor(start, targets, return_to_start)?;
309 let order: Vec<SystemId> = nn_route.hops.iter().map(|h| h.system_id).collect();
310 Ok(self.two_opt_improve(order))
311 }
312
313 pub fn constrain_hops(&self, route: &Route, max_ly: f64) -> Route {
320 if route.hops.len() < 2 {
321 return route.clone();
322 }
323
324 let mut new_hops: Vec<RouteHop> = Vec::new();
325 new_hops.push(RouteHop {
326 is_waypoint: false,
327 ..route.hops[0].clone()
328 });
329
330 for i in 1..route.hops.len() {
331 let prev_id = new_hops.last().unwrap().system_id;
332 let next_id = route.hops[i].system_id;
333 let leg = self.euclidean_distance(prev_id, next_id);
334
335 if leg <= max_ly {
336 let cumulative = new_hops.last().unwrap().cumulative_ly + leg;
337 new_hops.push(RouteHop {
338 system_id: next_id,
339 leg_distance_ly: leg,
340 cumulative_ly: cumulative,
341 is_waypoint: route.hops[i].is_waypoint,
342 });
343 } else {
344 self.insert_waypoints(&mut new_hops, prev_id, next_id, max_ly);
345 }
346 }
347
348 let total = new_hops.last().map(|h| h.cumulative_ly).unwrap_or(0.0);
349 Route {
350 hops: new_hops,
351 total_distance_ly: total,
352 }
353 }
354
355 fn insert_waypoints(
357 &self,
358 hops: &mut Vec<RouteHop>,
359 from: SystemId,
360 to: SystemId,
361 max_ly: f64,
362 ) {
363 let mut current = from;
364 let mut remaining = self.euclidean_distance(current, to);
365 let max_iterations = 1000;
366 let mut iterations = 0;
367
368 while remaining > max_ly && iterations < max_iterations {
369 iterations += 1;
370
371 let current_sys = match self.systems.get(¤t) {
372 Some(s) => s,
373 None => break,
374 };
375
376 let best = self
377 .nearest_systems(¤t_sys.address, 50)
378 .into_iter()
379 .filter(|(id, dist)| {
380 *id != current
381 && *dist <= max_ly
382 && self.euclidean_distance(*id, to) < remaining
383 })
384 .min_by(|a, b| {
385 let da = self.euclidean_distance(a.0, to);
386 let db = self.euclidean_distance(b.0, to);
387 da.partial_cmp(&db).unwrap()
388 });
389
390 match best {
391 Some((waypoint_id, step_dist)) => {
392 let cumulative = hops.last().unwrap().cumulative_ly + step_dist;
393 hops.push(RouteHop {
394 system_id: waypoint_id,
395 leg_distance_ly: step_dist,
396 cumulative_ly: cumulative,
397 is_waypoint: true,
398 });
399 current = waypoint_id;
400 remaining = self.euclidean_distance(current, to);
401 }
402 None => break,
403 }
404 }
405
406 let final_dist = self.euclidean_distance(current, to);
407 let cumulative = hops.last().unwrap().cumulative_ly + final_dist;
408 hops.push(RouteHop {
409 system_id: to,
410 leg_distance_ly: final_dist,
411 cumulative_ly: cumulative,
412 is_waypoint: false,
413 });
414 }
415
416 pub fn warp_jump_count(route: &Route, warp_range: f64) -> usize {
418 route
419 .hops
420 .iter()
421 .skip(1)
422 .map(|h| (h.leg_distance_ly / warp_range).ceil() as usize)
423 .sum()
424 }
425
426 pub fn reachable_systems(
432 &self,
433 start: SystemId,
434 warp_range: f64,
435 ) -> Result<Vec<SystemId>, RouteError> {
436 if !self.systems.contains_key(&start) {
437 return Err(RouteError::SystemNotFound(start));
438 }
439
440 let mut visited = HashSet::new();
441 let mut stack = vec![start];
442
443 while let Some(current) = stack.pop() {
444 if !visited.insert(current) {
445 continue;
446 }
447 let sys = match self.systems.get(¤t) {
448 Some(s) => s,
449 None => continue,
450 };
451 for (neighbor_id, dist) in self.nearest_systems(&sys.address, 100) {
452 if dist <= warp_range && !visited.contains(&neighbor_id) {
453 stack.push(neighbor_id);
454 }
455 }
456 }
457
458 Ok(visited.into_iter().collect())
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::edges::EdgeStrategy;
466 use nms_core::address::GalacticAddress;
467 use nms_core::biome::Biome;
468 use nms_core::system::{Planet, System};
469
470 fn line_model(n: usize, spacing: i16) -> GalaxyModel {
472 let json = r#"{
473 "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
474 "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
475 "BaseContext": {"GameMode": 1, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
476 "ExpeditionContext": {"GameMode": 6, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
477 "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
478 }"#;
479 let save = nms_save::parse_save(json.as_bytes()).unwrap();
480 let mut model = GalaxyModel::from_save(&save);
481
482 for i in 0..n {
483 let x = (i as i16) * spacing;
484 let ssi = (i + 1) as u16;
485 let addr = GalacticAddress::new(x, 0, 0, ssi, 0, 0);
486 let planet = Planet::new(0, Some(Biome::Barren), None, false, None, None);
487 let system = System::new(addr, None, None, None, vec![planet]);
488 model.insert_system(system);
489 }
490
491 model
492 }
493
494 fn sorted_ids(model: &GalaxyModel) -> Vec<SystemId> {
496 let mut ids: Vec<SystemId> = model.systems.keys().copied().collect();
497 ids.sort_by_key(|id| id.0);
498 ids
499 }
500
501 #[test]
504 fn test_shortest_path_adjacent_systems() {
505 let mut model = line_model(3, 10);
506 model.build_edges(EdgeStrategy::Knn { k: 2 });
507 let ids = sorted_ids(&model);
508 let route = model.shortest_path(ids[0], ids[1]).unwrap();
509 assert!(route.hops.len() >= 2);
510 assert!(route.total_distance_ly > 0.0);
511 }
512
513 #[test]
514 fn test_shortest_path_same_system() {
515 let mut model = line_model(3, 10);
516 model.build_edges(EdgeStrategy::Knn { k: 2 });
517 let id = *model.systems.keys().next().unwrap();
518 let route = model.shortest_path(id, id).unwrap();
519 assert_eq!(route.total_distance_ly, 0.0);
520 assert_eq!(route.hops.len(), 1);
521 }
522
523 #[test]
524 fn test_shortest_path_nonexistent_system_errors() {
525 let model = line_model(3, 10);
526 assert!(
527 model
528 .shortest_path(SystemId(0xDEAD), SystemId(0xBEEF))
529 .is_err()
530 );
531 }
532
533 #[test]
534 fn test_shortest_path_disconnected_falls_back_to_direct() {
535 let mut model = line_model(3, 100);
536 model.build_edges(EdgeStrategy::WarpRange { max_ly: 1.0 });
537 let ids = sorted_ids(&model);
538 let route = model.shortest_path(ids[0], ids[1]).unwrap();
539 assert_eq!(route.hops.len(), 2);
540 }
541
542 #[test]
543 fn test_shortest_path_multi_hop() {
544 let mut model = line_model(5, 10);
545 model.build_edges(EdgeStrategy::Knn { k: 1 });
546 let ids = sorted_ids(&model);
547 let route = model.shortest_path(ids[0], ids[4]).unwrap();
548 assert!(route.hops.len() >= 3, "Should route through intermediates");
549 }
550
551 #[test]
552 fn test_shortest_path_cumulative_distances() {
553 let mut model = line_model(3, 10);
554 model.build_edges(EdgeStrategy::Knn { k: 2 });
555 let ids = sorted_ids(&model);
556 let route = model.shortest_path(ids[0], ids[1]).unwrap();
557 for i in 1..route.hops.len() {
558 let expected = route.hops[i - 1].cumulative_ly + route.hops[i].leg_distance_ly;
559 assert!(
560 (route.hops[i].cumulative_ly - expected).abs() < 0.01,
561 "cumulative mismatch at hop {i}"
562 );
563 }
564 }
565
566 #[test]
567 fn test_shortest_path_first_hop_zero_distance() {
568 let mut model = line_model(3, 10);
569 model.build_edges(EdgeStrategy::Knn { k: 2 });
570 let ids = sorted_ids(&model);
571 let route = model.shortest_path(ids[0], ids[1]).unwrap();
572 assert_eq!(route.hops[0].leg_distance_ly, 0.0);
573 }
574
575 #[test]
578 fn test_tsp_nn_visits_all_targets() {
579 let model = line_model(5, 10);
580 let ids = sorted_ids(&model);
581 let route = model
582 .tsp_nearest_neighbor(ids[0], &ids[1..], false)
583 .unwrap();
584 assert_eq!(route.hops.len(), ids.len());
585 }
586
587 #[test]
588 fn test_tsp_nn_return_to_start() {
589 let model = line_model(3, 10);
590 let ids = sorted_ids(&model);
591 let route = model.tsp_nearest_neighbor(ids[0], &ids[1..], true).unwrap();
592 assert_eq!(route.hops.first().unwrap().system_id, ids[0]);
593 assert_eq!(route.hops.last().unwrap().system_id, ids[0]);
594 }
595
596 #[test]
597 fn test_tsp_nn_empty_targets_errors() {
598 let model = line_model(3, 10);
599 let id = *model.systems.keys().next().unwrap();
600 assert!(model.tsp_nearest_neighbor(id, &[], false).is_err());
601 }
602
603 #[test]
604 fn test_tsp_nn_single_target() {
605 let model = line_model(3, 10);
606 let ids = sorted_ids(&model);
607 let route = model
608 .tsp_nearest_neighbor(ids[0], &[ids[1]], false)
609 .unwrap();
610 assert_eq!(route.hops.len(), 2);
611 }
612
613 #[test]
614 fn test_tsp_nn_nonexistent_target_errors() {
615 let model = line_model(3, 10);
616 let id = *model.systems.keys().next().unwrap();
617 assert!(
618 model
619 .tsp_nearest_neighbor(id, &[SystemId(0xDEAD)], false)
620 .is_err()
621 );
622 }
623
624 #[test]
625 fn test_tsp_nn_nonexistent_start_errors() {
626 let model = line_model(3, 10);
627 let ids = sorted_ids(&model);
628 assert!(
629 model
630 .tsp_nearest_neighbor(SystemId(0xDEAD), &ids, false)
631 .is_err()
632 );
633 }
634
635 #[test]
636 fn test_tsp_nn_greedy_picks_nearest() {
637 let model = line_model(3, 50);
639 let ids = sorted_ids(&model);
640 let route = model
641 .tsp_nearest_neighbor(ids[0], &[ids[2], ids[1]], false)
642 .unwrap();
643 assert_eq!(route.hops[1].system_id, ids[1]);
644 }
645
646 #[test]
649 fn test_two_opt_does_not_increase_distance() {
650 let model = line_model(6, 20);
651 let ids = sorted_ids(&model);
652 let nn_route = model
653 .tsp_nearest_neighbor(ids[0], &ids[1..], false)
654 .unwrap();
655 let nn_order: Vec<SystemId> = nn_route.hops.iter().map(|h| h.system_id).collect();
656 let opt_route = model.two_opt_improve(nn_order);
657 assert!(opt_route.total_distance_ly <= nn_route.total_distance_ly + 1e-6);
658 }
659
660 #[test]
661 fn test_two_opt_small_route_unchanged() {
662 let model = line_model(3, 10);
663 let ids = sorted_ids(&model);
664 let route = model.tsp_two_opt(ids[0], &ids[1..], false).unwrap();
665 assert_eq!(route.hops.len(), 3);
666 }
667
668 #[test]
669 fn test_two_opt_improves_scrambled_route() {
670 let model = line_model(8, 10);
671 let ids = sorted_ids(&model);
672
673 let mut scrambled = ids.clone();
674 scrambled.swap(1, 5);
675 scrambled.swap(2, 6);
676
677 let scrambled_route = model.build_route_from_order(&scrambled);
678 let improved = model.two_opt_improve(scrambled);
679 assert!(improved.total_distance_ly <= scrambled_route.total_distance_ly);
680 }
681
682 #[test]
683 fn test_tsp_two_opt_visits_all() {
684 let model = line_model(5, 10);
685 let ids = sorted_ids(&model);
686 let route = model.tsp_two_opt(ids[0], &ids[1..], false).unwrap();
687 assert_eq!(route.hops.len(), ids.len());
688 }
689
690 #[test]
693 fn test_constrain_hops_short_route_unchanged() {
694 let model = line_model(3, 5);
695 let ids = sorted_ids(&model);
696 let route = model.build_route_from_order(&ids);
697 let constrained = model.constrain_hops(&route, 100_000.0);
698 assert_eq!(constrained.hops.len(), route.hops.len());
699 }
700
701 #[test]
702 fn test_constrain_hops_inserts_waypoints() {
703 let model = line_model(10, 10);
704 let ids = sorted_ids(&model);
705 let route = model.build_route_from_order(&[ids[0], ids[9]]);
707 let constrained = model.constrain_hops(&route, 5000.0);
708 assert!(constrained.hops.len() > 2, "Should insert waypoints");
709 for hop in &constrained.hops[1..constrained.hops.len() - 1] {
710 assert!(hop.is_waypoint);
711 }
712 }
713
714 #[test]
715 fn test_constrain_hops_preserves_endpoints() {
716 let model = line_model(10, 10);
717 let ids = sorted_ids(&model);
718 let route = model.build_route_from_order(&[ids[0], ids[9]]);
719 let constrained = model.constrain_hops(&route, 5000.0);
720 assert_eq!(constrained.hops.first().unwrap().system_id, ids[0]);
721 assert_eq!(constrained.hops.last().unwrap().system_id, ids[9]);
722 }
723
724 #[test]
725 fn test_constrain_hops_respects_warp_range() {
726 let model = line_model(10, 10);
727 let ids = sorted_ids(&model);
728 let route = model.build_route_from_order(&[ids[0], ids[9]]);
729 let constrained = model.constrain_hops(&route, 5000.0);
730 let within_range = constrained
731 .hops
732 .iter()
733 .skip(1)
734 .filter(|h| h.leg_distance_ly <= 5000.0 + 1.0)
735 .count();
736 assert!(within_range > 0);
737 }
738
739 #[test]
740 fn test_warp_jump_count() {
741 let route = Route {
742 total_distance_ly: 10000.0,
743 hops: vec![
744 RouteHop {
745 system_id: SystemId(0),
746 leg_distance_ly: 0.0,
747 cumulative_ly: 0.0,
748 is_waypoint: false,
749 },
750 RouteHop {
751 system_id: SystemId(1),
752 leg_distance_ly: 5000.0,
753 cumulative_ly: 5000.0,
754 is_waypoint: false,
755 },
756 RouteHop {
757 system_id: SystemId(2),
758 leg_distance_ly: 5000.0,
759 cumulative_ly: 10000.0,
760 is_waypoint: false,
761 },
762 ],
763 };
764 assert_eq!(GalaxyModel::warp_jump_count(&route, 2500.0), 4);
765 assert_eq!(GalaxyModel::warp_jump_count(&route, 5000.0), 2);
766 }
767
768 #[test]
769 fn test_constrain_hops_empty_route() {
770 let model = line_model(3, 10);
771 let route = Route {
772 hops: vec![],
773 total_distance_ly: 0.0,
774 };
775 let constrained = model.constrain_hops(&route, 5000.0);
776 assert!(constrained.hops.is_empty());
777 }
778
779 #[test]
782 fn test_reachable_includes_start() {
783 let model = line_model(5, 10);
784 let id = *model.systems.keys().next().unwrap();
785 let reachable = model.reachable_systems(id, 100_000.0).unwrap();
786 assert!(reachable.contains(&id));
787 }
788
789 #[test]
790 fn test_reachable_all_within_large_range() {
791 let model = line_model(5, 10);
792 let id = *model.systems.keys().next().unwrap();
793 let reachable = model.reachable_systems(id, 1_000_000.0).unwrap();
794 assert_eq!(reachable.len(), model.systems.len());
795 }
796
797 #[test]
798 fn test_reachable_isolated_with_tiny_range() {
799 let model = line_model(5, 100);
800 let id = *model.systems.keys().next().unwrap();
801 let reachable = model.reachable_systems(id, 1.0).unwrap();
802 assert_eq!(reachable.len(), 1);
803 }
804
805 #[test]
806 fn test_reachable_partial() {
807 let json = r#"{
808 "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
809 "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
810 "BaseContext": {"GameMode": 1, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
811 "ExpeditionContext": {"GameMode": 6, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
812 "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
813 }"#;
814 let save = nms_save::parse_save(json.as_bytes()).unwrap();
815 let mut model = GalaxyModel::from_save(&save);
816
817 let positions = [(0i16, 1u16), (10, 2), (20, 3), (100, 4), (110, 5)];
820 for (x, ssi) in positions {
821 let addr = GalacticAddress::new(x, 0, 0, ssi, 0, 0);
822 let system = System::new(addr, None, None, None, vec![]);
823 model.insert_system(system);
824 }
825
826 let start_addr = GalacticAddress::new(0, 0, 0, 1, 0, 0);
827 let start_id = SystemId::from_address(&start_addr);
828 let reachable = model.reachable_systems(start_id, 5000.0).unwrap();
829 assert!(reachable.len() >= 3);
831 assert!(reachable.len() < 6); }
833
834 #[test]
835 fn test_reachable_nonexistent_start_errors() {
836 let model = line_model(3, 10);
837 assert!(model.reachable_systems(SystemId(0xDEAD), 5000.0).is_err());
838 }
839}