Skip to main content

nms_graph/
route.rs

1//! Graph routing algorithms for galactic navigation.
2//!
3//! Provides shortest-path, TSP, and warp-range-constrained routing
4//! over the petgraph topology.
5
6use std::collections::HashSet;
7
8use petgraph::stable_graph::NodeIndex;
9
10use crate::model::GalaxyModel;
11use crate::spatial::SystemId;
12
13/// A single hop in a route.
14#[derive(Debug, Clone)]
15pub struct RouteHop {
16    /// System at this waypoint.
17    pub system_id: SystemId,
18    /// Distance from previous hop (0 for start).
19    pub leg_distance_ly: f64,
20    /// Cumulative distance from route start.
21    pub cumulative_ly: f64,
22    /// Whether this is an intermediate waypoint inserted for hop constraints.
23    pub is_waypoint: bool,
24}
25
26/// A complete route through multiple systems.
27#[derive(Debug, Clone)]
28pub struct Route {
29    /// Ordered list of hops (first is the start).
30    pub hops: Vec<RouteHop>,
31    /// Total distance in light-years.
32    pub total_distance_ly: f64,
33}
34
35/// Routing algorithm choice.
36#[derive(Debug, Clone, Copy, Default)]
37pub enum RoutingAlgorithm {
38    /// Greedy nearest-neighbor traversal.
39    NearestNeighbor,
40    /// Nearest-neighbor followed by 2-opt improvement.
41    #[default]
42    TwoOpt,
43}
44
45/// Errors that can occur during routing.
46#[derive(Debug, Clone)]
47pub enum RouteError {
48    /// A system ID was not found in the model.
49    SystemNotFound(SystemId),
50    /// No path exists between two systems in the graph.
51    NoPath { from: SystemId, to: SystemId },
52    /// Too few targets to form a route.
53    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    // -- 4.1: Dijkstra shortest path --
72
73    /// Find the shortest path between two systems using A* (Dijkstra with zero heuristic).
74    ///
75    /// If no graph path exists, falls back to a direct Euclidean hop.
76    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 astar with zero heuristic (equivalent to Dijkstra but returns the path)
99        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    /// Create a direct single-hop route between two systems.
113    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    /// Convert a path of NodeIndexes into a Route with distances.
135    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    /// Euclidean distance in ly between two systems.
169    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    // -- 4.2: Nearest-neighbor TSP --
177
178    /// Plan a route visiting all targets using nearest-neighbor greedy.
179    ///
180    /// Uses Euclidean distances directly (not graph edges) for the TSP ordering.
181    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    /// Convert an ordered list of SystemIds into a Route.
223    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    // -- 4.3: 2-opt improvement --
249
250    /// Improve a route using 2-opt local search.
251    ///
252    /// Iteratively reverses segments when doing so reduces total distance.
253    /// Runs until no improvement is found (local optimum).
254    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    /// Calculate the distance gain from a 2-opt swap.
281    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    /// Plan a route using nearest-neighbor + 2-opt.
302    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    // -- 4.4: Hop-constrained routing --
314
315    /// Constrain a route so no hop exceeds `max_ly`.
316    ///
317    /// Inserts intermediate known systems as waypoints when a hop is too long.
318    /// Waypoints are marked with `is_waypoint: true`.
319    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    /// Insert intermediate waypoints between `from` and `to`.
356    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(&current) {
372                Some(s) => s,
373                None => break,
374            };
375
376            let best = self
377                .nearest_systems(&current_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    /// Count the number of warp jumps needed for a route at a given range.
417    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    // -- 4.8: Reachability analysis --
427
428    /// Find all systems reachable from `start` within a given warp range.
429    ///
430    /// Uses DFS over the spatial index (no graph clone needed).
431    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(&current) {
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    /// Build a model with N systems in a line along the X axis.
471    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    /// Get sorted system IDs from a model (sorted by packed address).
495    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    // -- 4.1 tests --
502
503    #[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    // -- 4.2 tests --
576
577    #[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        // Systems at x=0, x=50, x=100. Starting at 0, greedy should visit 50 first.
638        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    // -- 4.3 tests --
647
648    #[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    // -- 4.4 tests --
691
692    #[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        // Route from first to last (9 * 4000 = 36000 ly)
706        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    // -- 4.8 tests --
780
781    #[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        // Close cluster: 0, 10, 20 (4000 ly apart)
818        // Far cluster: 100, 110 (4000 ly apart, 32000 ly from close cluster)
819        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        // Should reach 0, 10, 20 but not 100, 110
830        assert!(reachable.len() >= 3);
831        assert!(reachable.len() < 6); // 5 inserted + 1 origin from save
832    }
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}