Skip to main content

nms_graph/
query.rs

1//! Spatial query methods on the GalaxyModel.
2
3use nms_core::address::{GalacticAddress, LY_PER_VOXEL};
4use nms_core::biome::{Biome, BiomeSubType};
5use nms_core::system::Planet;
6use rstar::PointDistance;
7
8use crate::error::GraphError;
9use crate::model::{GalaxyModel, PlanetKey};
10use crate::spatial::SystemId;
11
12/// Filter criteria for planet queries.
13#[derive(Debug, Clone, Default)]
14pub struct BiomeFilter {
15    pub biome: Option<Biome>,
16    pub biome_subtype: Option<BiomeSubType>,
17    pub infested: Option<bool>,
18    pub named_only: bool,
19}
20
21impl GalaxyModel {
22    /// Resolve a reference point to a `GalacticAddress`.
23    ///
24    /// Accepts:
25    /// - A direct address (returned as-is)
26    /// - A base name (looked up in the base index)
27    /// - None for both (uses player position)
28    pub fn resolve_position(
29        &self,
30        address: Option<&GalacticAddress>,
31        base_name: Option<&str>,
32    ) -> Result<GalacticAddress, GraphError> {
33        if let Some(addr) = address {
34            return Ok(*addr);
35        }
36        if let Some(name) = base_name {
37            return self
38                .base(name)
39                .map(|b| b.address)
40                .ok_or_else(|| GraphError::BaseNotFound(name.to_string()));
41        }
42        self.player_position()
43            .copied()
44            .ok_or(GraphError::NoPlayerPosition)
45    }
46
47    /// Find the N nearest systems to a reference point.
48    ///
49    /// Uses the active galaxy's spatial index. Returns empty if the active
50    /// galaxy has no systems.
51    ///
52    /// Returns `(SystemId, distance_in_ly)` pairs sorted by distance ascending.
53    pub fn nearest_systems(&self, from: &GalacticAddress, n: usize) -> Vec<(SystemId, f64)> {
54        let Some(spatial) = self.active_spatial() else {
55            return Vec::new();
56        };
57
58        let query_point = [
59            from.voxel_x() as f64,
60            from.voxel_y() as f64,
61            from.voxel_z() as f64,
62        ];
63
64        let mut results: Vec<(SystemId, f64)> = spatial
65            .nearest_neighbor_iter(&query_point)
66            .take(n * 3)
67            .filter_map(|sp| {
68                let system = self.systems.get(&sp.id)?;
69                let ly = from.distance_ly(&system.address);
70                Some((sp.id, ly))
71            })
72            .collect();
73
74        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
75        results.truncate(n);
76        results
77    }
78
79    /// Find all systems within a radius (in light-years) of a reference point.
80    ///
81    /// Uses the active galaxy's spatial index. Returns empty if the active
82    /// galaxy has no systems.
83    ///
84    /// Returns `(SystemId, distance_in_ly)` pairs sorted by distance ascending.
85    pub fn systems_within_radius(
86        &self,
87        from: &GalacticAddress,
88        radius_ly: f64,
89    ) -> Vec<(SystemId, f64)> {
90        let Some(spatial) = self.active_spatial() else {
91            return Vec::new();
92        };
93
94        let query_point = [
95            from.voxel_x() as f64,
96            from.voxel_y() as f64,
97            from.voxel_z() as f64,
98        ];
99        // Over-estimate the voxel radius to account for SSI-aware distance
100        // adjustments. Adding 1.0 voxel ensures same-voxel systems are included.
101        let voxel_radius = radius_ly / LY_PER_VOXEL + 1.0;
102        let voxel_radius_sq = voxel_radius * voxel_radius;
103
104        let mut results: Vec<(SystemId, f64)> = spatial
105            .nearest_neighbor_iter(&query_point)
106            .take_while(|sp| sp.distance_2(&query_point) <= voxel_radius_sq)
107            .filter_map(|sp| {
108                let system = self.systems.get(&sp.id)?;
109                let ly = from.distance_ly(&system.address);
110                if ly <= radius_ly {
111                    Some((sp.id, ly))
112                } else {
113                    None
114                }
115            })
116            .collect();
117
118        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
119        results
120    }
121
122    /// Find the N nearest planets to a reference point, with optional filtering.
123    ///
124    /// Uses the active galaxy's spatial index. Returns empty if the active
125    /// galaxy has no systems.
126    ///
127    /// Iterates systems by proximity, then checks their planets against the filter.
128    /// Returns `(PlanetKey, &Planet, system_distance_ly)` tuples.
129    pub fn nearest_planets<'a>(
130        &'a self,
131        from: &GalacticAddress,
132        n: usize,
133        filter: &BiomeFilter,
134    ) -> Vec<(PlanetKey, &'a Planet, f64)> {
135        let Some(spatial) = self.active_spatial() else {
136            return Vec::new();
137        };
138
139        let query_point = [
140            from.voxel_x() as f64,
141            from.voxel_y() as f64,
142            from.voxel_z() as f64,
143        ];
144
145        let mut results = Vec::with_capacity(n);
146
147        // Over-collect candidates to allow re-sorting by SSI-aware distance.
148        for sp in spatial.nearest_neighbor_iter(&query_point) {
149            if results.len() >= n * 3 {
150                break;
151            }
152
153            if let Some(system) = self.systems.get(&sp.id) {
154                let dist_ly = from.distance_ly(&system.address);
155
156                for planet in &system.planets {
157                    if results.len() >= n * 3 {
158                        break;
159                    }
160                    if matches_filter(planet, filter) {
161                        let key = (sp.id, planet.index);
162                        results.push((key, planet, dist_ly));
163                    }
164                }
165            }
166        }
167
168        results.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap());
169        results.truncate(n);
170        results
171    }
172
173    /// Find all planets within a radius that match a filter.
174    pub fn planets_within_radius<'a>(
175        &'a self,
176        from: &GalacticAddress,
177        radius_ly: f64,
178        filter: &BiomeFilter,
179    ) -> Vec<(PlanetKey, &'a Planet, f64)> {
180        let systems = self.systems_within_radius(from, radius_ly);
181        let mut results = Vec::new();
182
183        for (sys_id, dist_ly) in systems {
184            if let Some(system) = self.systems.get(&sys_id) {
185                for planet in &system.planets {
186                    if matches_filter(planet, filter) {
187                        let key = (sys_id, planet.index);
188                        results.push((key, planet, dist_ly));
189                    }
190                }
191            }
192        }
193
194        results
195    }
196}
197
198/// Check if a planet matches the given filter criteria.
199fn matches_filter(planet: &Planet, filter: &BiomeFilter) -> bool {
200    if let Some(biome) = filter.biome
201        && planet.biome != Some(biome)
202    {
203        return false;
204    }
205    if let Some(subtype) = filter.biome_subtype
206        && planet.biome_subtype != Some(subtype)
207    {
208        return false;
209    }
210    if let Some(infested) = filter.infested
211        && planet.infested != infested
212    {
213        return false;
214    }
215    if filter.named_only && planet.name.is_none() {
216        return false;
217    }
218    true
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use nms_core::address::GalacticAddress;
225    use nms_core::biome::Biome;
226    use nms_core::system::{Planet, System};
227
228    /// Build a model with systems at known positions for spatial testing.
229    fn spatial_test_model() -> GalaxyModel {
230        let json = r#"{
231            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
232            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
233            "BaseContext": {
234                "GameMode": 1,
235                "PlayerStateData": {
236                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
237                    "Units": 0, "Nanites": 0, "Specials": 0,
238                    "PersistentPlayerBases": [
239                        {"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": "Test Base", "BaseType": {"PersistentBaseTypes": "HomePlanetBase"}, "LastEditedById": "", "LastEditedByUsername": ""}
240                    ]
241                }
242            },
243            "ExpeditionContext": {
244                "GameMode": 6,
245                "PlayerStateData": {
246                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}},
247                    "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []
248                }
249            },
250            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
251        }"#;
252        let save = nms_save::parse_save(json.as_bytes()).unwrap();
253        let mut model = GalaxyModel::from_save(&save);
254
255        // Insert systems at known positions along the X axis
256        let positions = [
257            (10, 0, 0, 0x100, "Near"), // 10 voxels = 4000 ly
258            (50, 0, 0, 0x200, "Mid"),  // 50 voxels = 20000 ly
259            (200, 0, 0, 0x300, "Far"), // 200 voxels = 80000 ly
260        ];
261
262        for (x, y, z, ssi, name) in positions {
263            let addr = GalacticAddress::new(x, y, z, ssi, 0, 0);
264            let planet = Planet::new(0, Some(Biome::Lush), None, false, None, None);
265            let system = System::new(addr, Some(name.into()), None, None, vec![planet]);
266            model.insert_system(system);
267        }
268
269        // Add a Scorched infested planet to "Near"
270        let near_addr = GalacticAddress::new(10, 0, 0, 0x100, 1, 0);
271        let near_id = crate::spatial::SystemId::from_address(&near_addr);
272        let scorched = Planet::new(1, Some(Biome::Scorched), None, true, None, None);
273        let key = (near_id, 1);
274        model.planets.insert(key, scorched.clone());
275        model
276            .biome_index
277            .entry(Biome::Scorched)
278            .or_default()
279            .push(key);
280        // Also add to the system's planet list
281        if let Some(sys) = model.systems.get_mut(&near_id) {
282            sys.planets.push(scorched);
283        }
284
285        model
286    }
287
288    #[test]
289    fn test_nearest_systems_returns_sorted() {
290        let model = spatial_test_model();
291        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
292        let results = model.nearest_systems(&origin, 10);
293        for i in 1..results.len() {
294            assert!(results[i].1 >= results[i - 1].1);
295        }
296    }
297
298    #[test]
299    fn test_nearest_systems_limit() {
300        let model = spatial_test_model();
301        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
302        let results = model.nearest_systems(&origin, 2);
303        assert_eq!(results.len(), 2);
304    }
305
306    #[test]
307    fn test_nearest_systems_distances_are_in_ly() {
308        let model = spatial_test_model();
309        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
310        let results = model.nearest_systems(&origin, 10);
311        // "Near" is at voxel 10 = 4000 ly; find the result with ~4000 ly
312        let near_result = results.iter().find(|(_, d)| (*d - 4000.0).abs() < 1.0);
313        assert!(near_result.is_some(), "Expected a system at ~4000 ly");
314    }
315
316    #[test]
317    fn test_systems_within_radius_filters() {
318        let model = spatial_test_model();
319        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
320        // 10 voxels = 4000 ly, so radius of 5000 should include "Near" but not "Mid"
321        let results = model.systems_within_radius(&origin, 5000.0);
322        assert!(!results.is_empty());
323        for (_, dist) in &results {
324            assert!(*dist <= 5000.0);
325        }
326    }
327
328    #[test]
329    fn test_systems_within_radius_excludes_far() {
330        let model = spatial_test_model();
331        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
332        // Radius 5000 ly should not include "Mid" (20000 ly) or "Far" (80000 ly)
333        let results = model.systems_within_radius(&origin, 5000.0);
334        for (_, dist) in &results {
335            assert!(*dist < 20000.0, "Far system should be excluded");
336        }
337    }
338
339    #[test]
340    fn test_systems_within_radius_zero() {
341        let model = spatial_test_model();
342        let origin = GalacticAddress::new(999, 127, 999, 1, 0, 0);
343        let results = model.systems_within_radius(&origin, 0.0);
344        assert!(results.is_empty());
345    }
346
347    #[test]
348    fn test_nearest_planets_with_biome_filter() {
349        let model = spatial_test_model();
350        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
351        let filter = BiomeFilter {
352            biome: Some(Biome::Scorched),
353            ..Default::default()
354        };
355        let results = model.nearest_planets(&origin, 10, &filter);
356        assert!(!results.is_empty());
357        for (_, planet, _) in &results {
358            assert_eq!(planet.biome, Some(Biome::Scorched));
359        }
360    }
361
362    #[test]
363    fn test_nearest_planets_with_infested_filter() {
364        let model = spatial_test_model();
365        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
366        let filter = BiomeFilter {
367            infested: Some(true),
368            ..Default::default()
369        };
370        let results = model.nearest_planets(&origin, 10, &filter);
371        assert!(!results.is_empty());
372        for (_, planet, _) in &results {
373            assert!(planet.infested);
374        }
375    }
376
377    #[test]
378    fn test_nearest_planets_no_filter() {
379        let model = spatial_test_model();
380        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
381        let filter = BiomeFilter::default();
382        let results = model.nearest_planets(&origin, 5, &filter);
383        assert!(!results.is_empty());
384        assert!(results.len() <= 5);
385    }
386
387    #[test]
388    fn test_nearest_planets_limit_respected() {
389        let model = spatial_test_model();
390        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
391        let filter = BiomeFilter::default();
392        let results = model.nearest_planets(&origin, 1, &filter);
393        assert_eq!(results.len(), 1);
394    }
395
396    #[test]
397    fn test_planets_within_radius() {
398        let model = spatial_test_model();
399        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
400        let filter = BiomeFilter {
401            biome: Some(Biome::Lush),
402            ..Default::default()
403        };
404        // 5000 ly should reach "Near" (4000 ly) but not "Mid" (20000 ly)
405        let results = model.planets_within_radius(&origin, 5000.0, &filter);
406        for (_, planet, dist) in &results {
407            assert_eq!(planet.biome, Some(Biome::Lush));
408            assert!(*dist <= 5000.0);
409        }
410    }
411
412    #[test]
413    fn test_planets_within_radius_no_match() {
414        let model = spatial_test_model();
415        let origin = GalacticAddress::new(0, 0, 0, 1, 0, 0);
416        let filter = BiomeFilter {
417            biome: Some(Biome::Lava),
418            ..Default::default()
419        };
420        let results = model.planets_within_radius(&origin, 100000.0, &filter);
421        assert!(results.is_empty());
422    }
423
424    #[test]
425    fn test_resolve_position_direct_address() {
426        let model = spatial_test_model();
427        let addr = GalacticAddress::new(42, 0, 0, 1, 0, 0);
428        let resolved = model.resolve_position(Some(&addr), None).unwrap();
429        assert_eq!(resolved, addr);
430    }
431
432    #[test]
433    fn test_resolve_position_from_base() {
434        let model = spatial_test_model();
435        let resolved = model.resolve_position(None, Some("Test Base")).unwrap();
436        // Base was inserted with address 0x001000000064
437        assert_eq!(resolved.packed(), 0x001000000064);
438    }
439
440    #[test]
441    fn test_resolve_position_base_not_found() {
442        let model = spatial_test_model();
443        let result = model.resolve_position(None, Some("No Such Base"));
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn test_resolve_position_player_position() {
449        let model = spatial_test_model();
450        let resolved = model.resolve_position(None, None).unwrap();
451        // Player is at origin (0,0,0)
452        assert_eq!(resolved.voxel_x(), 0);
453        assert_eq!(resolved.voxel_y(), 0);
454        assert_eq!(resolved.voxel_z(), 0);
455    }
456
457    #[test]
458    fn test_resolve_position_no_player_state_errors() {
459        let json = r#"{
460            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
461            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 0},
462            "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": []}},
463            "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": []}},
464            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
465        }"#;
466        let save = nms_save::parse_save(json.as_bytes()).unwrap();
467        let mut model = GalaxyModel::from_save(&save);
468        model.player_state = None;
469        assert!(model.resolve_position(None, None).is_err());
470    }
471
472    #[test]
473    fn test_resolve_position_address_takes_priority() {
474        let model = spatial_test_model();
475        let addr = GalacticAddress::new(42, 10, -5, 0x999, 0, 0);
476        // Even with a base name, direct address wins
477        let resolved = model
478            .resolve_position(Some(&addr), Some("Test Base"))
479            .unwrap();
480        assert_eq!(resolved, addr);
481    }
482
483    #[test]
484    fn test_matches_filter_all_pass() {
485        let planet = Planet::new(0, Some(Biome::Lush), None, false, Some("Eden".into()), None);
486        let filter = BiomeFilter::default();
487        assert!(matches_filter(&planet, &filter));
488    }
489
490    #[test]
491    fn test_matches_filter_biome_mismatch() {
492        let planet = Planet::new(0, Some(Biome::Lush), None, false, None, None);
493        let filter = BiomeFilter {
494            biome: Some(Biome::Toxic),
495            ..Default::default()
496        };
497        assert!(!matches_filter(&planet, &filter));
498    }
499
500    #[test]
501    fn test_matches_filter_biome_match() {
502        let planet = Planet::new(0, Some(Biome::Toxic), None, false, None, None);
503        let filter = BiomeFilter {
504            biome: Some(Biome::Toxic),
505            ..Default::default()
506        };
507        assert!(matches_filter(&planet, &filter));
508    }
509
510    #[test]
511    fn test_matches_filter_infested_mismatch() {
512        let planet = Planet::new(0, Some(Biome::Lush), None, false, None, None);
513        let filter = BiomeFilter {
514            infested: Some(true),
515            ..Default::default()
516        };
517        assert!(!matches_filter(&planet, &filter));
518    }
519
520    #[test]
521    fn test_matches_filter_named_only() {
522        let unnamed = Planet::new(0, Some(Biome::Lush), None, false, None, None);
523        let named = Planet::new(0, Some(Biome::Lush), None, false, Some("X".into()), None);
524        let filter = BiomeFilter {
525            named_only: true,
526            ..Default::default()
527        };
528        assert!(!matches_filter(&unnamed, &filter));
529        assert!(matches_filter(&named, &filter));
530    }
531
532    #[test]
533    fn test_matches_filter_combined() {
534        let planet = Planet::new(
535            0,
536            Some(Biome::Scorched),
537            None,
538            true,
539            Some("Inferno".into()),
540            None,
541        );
542        let filter = BiomeFilter {
543            biome: Some(Biome::Scorched),
544            biome_subtype: None,
545            infested: Some(true),
546            named_only: true,
547        };
548        assert!(matches_filter(&planet, &filter));
549    }
550}