Skip to main content

nms_query/
route.rs

1//! Route query layer -- resolves targets and executes routing algorithms.
2
3use std::collections::HashSet;
4
5use nms_core::address::GalacticAddress;
6use nms_graph::query::BiomeFilter;
7use nms_graph::route::{Route, RoutingAlgorithm};
8use nms_graph::{GalaxyModel, GraphError, SystemId};
9
10/// How route targets are specified.
11#[derive(Debug, Clone)]
12pub enum TargetSelection {
13    /// Find planets matching a biome filter, use their systems as targets.
14    Biome(BiomeFilter),
15    /// Named bases or systems (looked up case-insensitively).
16    Named(Vec<String>),
17    /// Explicit system IDs.
18    SystemIds(Vec<SystemId>),
19}
20
21/// Where the route starts from.
22#[derive(Debug, Clone, Default)]
23pub enum RouteFrom {
24    /// Use the player's current position from the save file.
25    #[default]
26    CurrentPosition,
27    /// Use a named base's position.
28    Base(String),
29    /// Use an explicit galactic address.
30    Address(GalacticAddress),
31}
32
33/// Parameters for a route query.
34#[derive(Debug, Clone)]
35pub struct RouteQuery {
36    /// How targets are selected.
37    pub targets: TargetSelection,
38    /// Starting point for the route.
39    pub from: RouteFrom,
40    /// Ship warp range in light-years (for hop constraints and jump counts).
41    pub warp_range: Option<f64>,
42    /// Only consider targets within this radius in light-years.
43    pub within_ly: Option<f64>,
44    /// Maximum number of targets to visit.
45    pub max_targets: Option<usize>,
46    /// Which routing algorithm to use.
47    pub algorithm: RoutingAlgorithm,
48    /// Whether the route should return to the start.
49    pub return_to_start: bool,
50}
51
52/// Result of a route query.
53#[derive(Debug, Clone)]
54pub struct RouteResult {
55    /// The computed route.
56    pub route: Route,
57    /// Warp range used (if any).
58    pub warp_range: Option<f64>,
59    /// Total warp jumps needed at the given warp range.
60    pub warp_jumps: Option<usize>,
61    /// Which algorithm was used.
62    pub algorithm: RoutingAlgorithm,
63    /// Number of non-waypoint targets visited (excluding start).
64    pub targets_visited: usize,
65}
66
67/// Resolve the starting address from a `RouteFrom` specification.
68fn resolve_start(model: &GalaxyModel, from: &RouteFrom) -> Result<GalacticAddress, GraphError> {
69    match from {
70        RouteFrom::CurrentPosition => model
71            .player_position()
72            .copied()
73            .ok_or(GraphError::NoPlayerPosition),
74        RouteFrom::Base(name) => model
75            .base(name)
76            .map(|b| b.address)
77            .ok_or_else(|| GraphError::BaseNotFound(name.clone())),
78        RouteFrom::Address(addr) => Ok(*addr),
79    }
80}
81
82/// Resolve named targets (bases or systems) to `SystemId`s.
83fn resolve_named_targets(
84    model: &GalaxyModel,
85    names: &[String],
86) -> Result<Vec<SystemId>, Box<dyn std::error::Error>> {
87    let mut ids = Vec::with_capacity(names.len());
88    for name in names {
89        // Try as a base first
90        if let Some(base) = model.base(name) {
91            ids.push(SystemId::from_address(&base.address));
92            continue;
93        }
94        // Try as a system name
95        if let Some((id, _)) = model.system_by_name(name) {
96            ids.push(*id);
97            continue;
98        }
99        return Err(format!("Target not found: \"{name}\"").into());
100    }
101    Ok(ids)
102}
103
104/// Resolve biome filter targets to `SystemId`s (deduplicated by system).
105fn resolve_biome_targets(
106    model: &GalaxyModel,
107    filter: &BiomeFilter,
108    from: &GalacticAddress,
109    within_ly: Option<f64>,
110    max_targets: Option<usize>,
111) -> Vec<SystemId> {
112    let limit = max_targets.unwrap_or(100) * 2; // over-fetch for dedup
113
114    let planet_matches = if let Some(radius) = within_ly {
115        model.planets_within_radius(from, radius, filter)
116    } else {
117        model.nearest_planets(from, limit, filter)
118    };
119
120    // Deduplicate by system
121    let mut seen = HashSet::new();
122    let mut ids = Vec::new();
123    for (key, _, _) in &planet_matches {
124        if seen.insert(key.0) {
125            ids.push(key.0);
126        }
127    }
128
129    ids
130}
131
132/// Execute a route query against the galaxy model.
133///
134/// Resolves targets, runs the selected routing algorithm, and optionally
135/// applies hop constraints based on warp range.
136pub fn execute_route(
137    model: &GalaxyModel,
138    query: &RouteQuery,
139) -> Result<RouteResult, Box<dyn std::error::Error>> {
140    // 1. Resolve start position
141    let start_addr = resolve_start(model, &query.from)?;
142
143    // 2. Find nearest system to start address
144    let nearest = model.nearest_systems(&start_addr, 1);
145    let start_id = nearest
146        .first()
147        .map(|(id, _)| *id)
148        .ok_or("No systems in model")?;
149
150    // 3. Resolve targets to SystemIds
151    let mut target_ids = match &query.targets {
152        TargetSelection::Biome(filter) => resolve_biome_targets(
153            model,
154            filter,
155            &start_addr,
156            query.within_ly,
157            query.max_targets,
158        ),
159        TargetSelection::Named(names) => resolve_named_targets(model, names)?,
160        TargetSelection::SystemIds(ids) => ids.clone(),
161    };
162
163    // 4. Remove start from targets
164    target_ids.retain(|id| *id != start_id);
165
166    // 5. Apply max_targets limit
167    if let Some(max) = query.max_targets {
168        target_ids.truncate(max);
169    }
170
171    // 6. Run routing algorithm
172    let route = match query.algorithm {
173        RoutingAlgorithm::NearestNeighbor => {
174            model.tsp_nearest_neighbor(start_id, &target_ids, query.return_to_start)?
175        }
176        RoutingAlgorithm::TwoOpt => {
177            model.tsp_two_opt(start_id, &target_ids, query.return_to_start)?
178        }
179    };
180
181    // 7. Apply hop constraints if warp_range specified
182    let route = if let Some(warp_range) = query.warp_range {
183        model.constrain_hops(&route, warp_range)
184    } else {
185        route
186    };
187
188    // 8. Compute warp jump count
189    let warp_jumps = query
190        .warp_range
191        .map(|wr| GalaxyModel::warp_jump_count(&route, wr));
192
193    // Count non-waypoint targets (excluding start)
194    let targets_visited = route.hops.iter().skip(1).filter(|h| !h.is_waypoint).count();
195
196    Ok(RouteResult {
197        route,
198        warp_range: query.warp_range,
199        warp_jumps,
200        algorithm: query.algorithm,
201        targets_visited,
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use nms_core::biome::Biome;
209
210    fn test_model() -> GalaxyModel {
211        let json = r#"{
212            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
213            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
214            "BaseContext": {
215                "GameMode": 1,
216                "PlayerStateData": {
217                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
218                    "Units": 0, "Nanites": 0, "Specials": 0,
219                    "PersistentPlayerBases": [{"BaseVersion": 8, "GalacticAddress": "0x001000000064", "Position": [0.0,0.0,0.0], "Forward": [1.0,0.0,0.0], "LastUpdateTimestamp": 0, "Objects": [], "RID": "", "Owner": {"LID":"","UID":"1","USN":"","PTK":"ST","TS":0}, "Name": "Alpha Base", "BaseType": {"PersistentBaseTypes": "HomePlanetBase"}, "LastEditedById": "", "LastEditedByUsername": ""}]
220                }
221            },
222            "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": []}},
223            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
224                {"DD": {"UA": "0x001000000064", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
225                {"DD": {"UA": "0x101000000064", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
226                {"DD": {"UA": "0x002000000C80", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
227                {"DD": {"UA": "0x102000000C80", "DT": "Planet", "VP": ["0xCD", 1]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
228                {"DD": {"UA": "0x003000001900", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
229                {"DD": {"UA": "0x103000001900", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
230            ]}}}
231        }"#;
232        nms_save::parse_save(json.as_bytes())
233            .map(|save| GalaxyModel::from_save(&save))
234            .unwrap()
235    }
236
237    #[test]
238    fn test_execute_route_with_system_ids() {
239        let model = test_model();
240        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
241        assert!(ids.len() >= 3, "Need at least 3 systems for routing");
242
243        let query = RouteQuery {
244            targets: TargetSelection::SystemIds(ids),
245            from: RouteFrom::CurrentPosition,
246            warp_range: None,
247            within_ly: None,
248            max_targets: None,
249            algorithm: RoutingAlgorithm::TwoOpt,
250            return_to_start: false,
251        };
252
253        let result = execute_route(&model, &query).unwrap();
254        assert!(!result.route.hops.is_empty());
255        assert!(result.targets_visited > 0);
256    }
257
258    #[test]
259    fn test_execute_route_from_base() {
260        let model = test_model();
261        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
262
263        let query = RouteQuery {
264            targets: TargetSelection::SystemIds(ids),
265            from: RouteFrom::Base("Alpha Base".into()),
266            warp_range: None,
267            within_ly: None,
268            max_targets: None,
269            algorithm: RoutingAlgorithm::NearestNeighbor,
270            return_to_start: false,
271        };
272
273        let result = execute_route(&model, &query);
274        assert!(result.is_ok());
275    }
276
277    #[test]
278    fn test_execute_route_from_nonexistent_base_errors() {
279        let model = test_model();
280        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
281
282        let query = RouteQuery {
283            targets: TargetSelection::SystemIds(ids),
284            from: RouteFrom::Base("No Such Base".into()),
285            warp_range: None,
286            within_ly: None,
287            max_targets: None,
288            algorithm: RoutingAlgorithm::TwoOpt,
289            return_to_start: false,
290        };
291
292        assert!(execute_route(&model, &query).is_err());
293    }
294
295    #[test]
296    fn test_execute_route_with_warp_range() {
297        let model = test_model();
298        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
299
300        let query = RouteQuery {
301            targets: TargetSelection::SystemIds(ids),
302            from: RouteFrom::CurrentPosition,
303            warp_range: Some(2500.0),
304            within_ly: None,
305            max_targets: None,
306            algorithm: RoutingAlgorithm::TwoOpt,
307            return_to_start: false,
308        };
309
310        let result = execute_route(&model, &query).unwrap();
311        assert!(result.warp_range.is_some());
312        assert!(result.warp_jumps.is_some());
313    }
314
315    #[test]
316    fn test_execute_route_round_trip() {
317        let model = test_model();
318        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
319
320        let query = RouteQuery {
321            targets: TargetSelection::SystemIds(ids),
322            from: RouteFrom::CurrentPosition,
323            warp_range: None,
324            within_ly: None,
325            max_targets: None,
326            algorithm: RoutingAlgorithm::TwoOpt,
327            return_to_start: true,
328        };
329
330        let result = execute_route(&model, &query).unwrap();
331        let first = result.route.hops.first().unwrap().system_id;
332        let last = result.route.hops.last().unwrap().system_id;
333        assert_eq!(first, last);
334    }
335
336    #[test]
337    fn test_execute_route_with_biome_filter() {
338        let model = test_model();
339
340        let filter = BiomeFilter {
341            biome: Some(Biome::Lush),
342            ..Default::default()
343        };
344
345        let query = RouteQuery {
346            targets: TargetSelection::Biome(filter),
347            from: RouteFrom::CurrentPosition,
348            warp_range: None,
349            within_ly: None,
350            max_targets: Some(5),
351            algorithm: RoutingAlgorithm::NearestNeighbor,
352            return_to_start: false,
353        };
354
355        // This may or may not find Lush planets depending on test data;
356        // we just check it doesn't panic
357        let _ = execute_route(&model, &query);
358    }
359
360    #[test]
361    fn test_execute_route_max_targets_limit() {
362        let model = test_model();
363        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
364
365        let query = RouteQuery {
366            targets: TargetSelection::SystemIds(ids.clone()),
367            from: RouteFrom::CurrentPosition,
368            warp_range: None,
369            within_ly: None,
370            max_targets: Some(1),
371            algorithm: RoutingAlgorithm::TwoOpt,
372            return_to_start: false,
373        };
374
375        let result = execute_route(&model, &query).unwrap();
376        // Start + at most 1 target
377        assert!(result.targets_visited <= 1);
378    }
379
380    #[test]
381    fn test_execute_route_from_explicit_address() {
382        let model = test_model();
383        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
384
385        let addr = GalacticAddress::new(0, 0, 0, 1, 0, 0);
386        let query = RouteQuery {
387            targets: TargetSelection::SystemIds(ids),
388            from: RouteFrom::Address(addr),
389            warp_range: None,
390            within_ly: None,
391            max_targets: None,
392            algorithm: RoutingAlgorithm::TwoOpt,
393            return_to_start: false,
394        };
395
396        assert!(execute_route(&model, &query).is_ok());
397    }
398
399    #[test]
400    fn test_execute_route_nearest_neighbor_algorithm() {
401        let model = test_model();
402        let ids: Vec<SystemId> = model.systems.keys().copied().collect();
403
404        let query = RouteQuery {
405            targets: TargetSelection::SystemIds(ids),
406            from: RouteFrom::CurrentPosition,
407            warp_range: None,
408            within_ly: None,
409            max_targets: None,
410            algorithm: RoutingAlgorithm::NearestNeighbor,
411            return_to_start: false,
412        };
413
414        let result = execute_route(&model, &query).unwrap();
415        assert!(matches!(
416            result.algorithm,
417            RoutingAlgorithm::NearestNeighbor
418        ));
419    }
420
421    #[test]
422    fn test_resolve_start_current_position() {
423        let model = test_model();
424        let addr = resolve_start(&model, &RouteFrom::CurrentPosition);
425        assert!(addr.is_ok());
426    }
427
428    #[test]
429    fn test_resolve_start_base() {
430        let model = test_model();
431        let addr = resolve_start(&model, &RouteFrom::Base("Alpha Base".into()));
432        assert!(addr.is_ok());
433    }
434
435    #[test]
436    fn test_resolve_start_base_not_found() {
437        let model = test_model();
438        let addr = resolve_start(&model, &RouteFrom::Base("No Such Base".into()));
439        assert!(addr.is_err());
440    }
441
442    #[test]
443    fn test_resolve_start_explicit_address() {
444        let model = test_model();
445        let ga = GalacticAddress::new(42, 10, -5, 0x100, 0, 0);
446        let addr = resolve_start(&model, &RouteFrom::Address(ga));
447        assert!(addr.is_ok());
448        assert_eq!(addr.unwrap(), ga);
449    }
450}