Skip to main content

nms_query/
find.rs

1//! Planet/system search queries.
2
3use nms_core::address::GalacticAddress;
4use nms_core::biome::Biome;
5use nms_core::system::{Planet, System};
6use nms_graph::query::BiomeFilter;
7use nms_graph::{GalaxyModel, GraphError};
8
9/// How to determine the "from" point for distance calculations.
10#[derive(Debug, Clone, Default)]
11pub enum ReferencePoint {
12    /// Use the player's current position from the save file.
13    #[default]
14    CurrentPosition,
15    /// Use a named base's position.
16    Base(String),
17    /// Use an explicit galactic address.
18    Address(GalacticAddress),
19}
20
21/// Parameters for a planet/system search.
22#[derive(Debug, Clone, Default)]
23pub struct FindQuery {
24    /// Filter by biome type.
25    pub biome: Option<Biome>,
26    /// Filter by infested flag.
27    pub infested: Option<bool>,
28    /// Only include results within this radius (light-years).
29    pub within_ly: Option<f64>,
30    /// Return at most this many results (nearest first).
31    pub nearest: Option<usize>,
32    /// Filter by planet/system name pattern (case-insensitive substring).
33    pub name_pattern: Option<String>,
34    /// Filter by discoverer username (case-insensitive substring).
35    pub discoverer: Option<String>,
36    /// Only include named planets/systems.
37    pub named_only: bool,
38    /// Reference point for distance calculations.
39    pub from: ReferencePoint,
40}
41
42/// A single result from a find query.
43#[derive(Debug, Clone)]
44pub struct FindResult {
45    /// The matching planet.
46    pub planet: Planet,
47    /// The system containing the planet.
48    pub system: System,
49    /// Distance from the reference point in light-years.
50    pub distance_ly: f64,
51    /// Portal glyphs as hex (12 digits). Caller renders as emoji.
52    pub portal_hex: String,
53}
54
55/// Execute a find query against the galaxy model.
56///
57/// Returns results sorted by distance ascending.
58pub fn execute_find(model: &GalaxyModel, query: &FindQuery) -> Result<Vec<FindResult>, GraphError> {
59    // Resolve the reference point
60    let from = match &query.from {
61        ReferencePoint::CurrentPosition => model
62            .player_position()
63            .copied()
64            .ok_or(GraphError::NoPlayerPosition)?,
65        ReferencePoint::Base(name) => model
66            .base(name)
67            .map(|b| b.address)
68            .ok_or_else(|| GraphError::BaseNotFound(name.clone()))?,
69        ReferencePoint::Address(addr) => *addr,
70    };
71
72    let biome_filter = BiomeFilter {
73        biome: query.biome,
74        infested: query.infested,
75        named_only: query.named_only,
76    };
77
78    // Choose between nearest-N or within-radius
79    let planet_matches = if let Some(n) = query.nearest {
80        model.nearest_planets(&from, n * 2, &biome_filter) // over-fetch for post-filtering
81    } else if let Some(radius) = query.within_ly {
82        model.planets_within_radius(&from, radius, &biome_filter)
83    } else {
84        // No spatial constraint: get all matching planets
85        let mut all = Vec::new();
86        if let Some(biome) = query.biome {
87            for planet in model.planets_by_biome(biome) {
88                for (&sys_id, system) in &model.systems {
89                    if system.planets.iter().any(|p| p.index == planet.index) {
90                        let dist = from.distance_ly(&system.address);
91                        all.push(((sys_id, planet.index), planet, dist));
92                        break;
93                    }
94                }
95            }
96        } else {
97            for (&sys_id, system) in &model.systems {
98                for planet in &system.planets {
99                    let dist = from.distance_ly(&system.address);
100                    all.push(((sys_id, planet.index), planet, dist));
101                }
102            }
103        }
104        all
105    };
106
107    let mut results: Vec<FindResult> = planet_matches
108        .into_iter()
109        .filter_map(|(key, planet, dist)| {
110            let system = model.system(&key.0)?;
111
112            // Apply name pattern filter
113            if let Some(ref pattern) = query.name_pattern {
114                let pattern_lower = pattern.to_lowercase();
115                let name_matches = planet
116                    .name
117                    .as_ref()
118                    .map(|n| n.to_lowercase().contains(&pattern_lower))
119                    .unwrap_or(false)
120                    || system
121                        .name
122                        .as_ref()
123                        .map(|n| n.to_lowercase().contains(&pattern_lower))
124                        .unwrap_or(false);
125                if !name_matches {
126                    return None;
127                }
128            }
129
130            // Apply discoverer filter
131            if let Some(ref disc) = query.discoverer {
132                let disc_lower = disc.to_lowercase();
133                let disc_matches = system
134                    .discoverer
135                    .as_ref()
136                    .map(|d| d.to_lowercase().contains(&disc_lower))
137                    .unwrap_or(false);
138                if !disc_matches {
139                    return None;
140                }
141            }
142
143            // Apply within_ly if both nearest and within are specified
144            if let Some(radius) = query.within_ly {
145                if dist > radius {
146                    return None;
147                }
148            }
149
150            let portal_hex = format!("{:012X}", system.address.packed());
151
152            Some(FindResult {
153                planet: planet.clone(),
154                system: system.clone(),
155                distance_ly: dist,
156                portal_hex,
157            })
158        })
159        .collect();
160
161    // Sort by distance
162    results.sort_by(|a, b| a.distance_ly.partial_cmp(&b.distance_ly).unwrap());
163
164    // Apply nearest limit
165    if let Some(n) = query.nearest {
166        results.truncate(n);
167    }
168
169    Ok(results)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn test_model() -> GalaxyModel {
177        let json = r#"{
178            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
179            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
180            "BaseContext": {
181                "GameMode": 1,
182                "PlayerStateData": {
183                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
184                    "Units": 0, "Nanites": 0, "Specials": 0,
185                    "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": ""}]
186                }
187            },
188            "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": []}},
189            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
190                {"DD": {"UA": "0x001000000064", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
191                {"DD": {"UA": "0x101000000064", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
192                {"DD": {"UA": "0x002000000C80", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
193                {"DD": {"UA": "0x102000000C80", "DT": "Planet", "VP": ["0xCD", 1]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
194            ]}}}
195        }"#;
196        nms_save::parse_save(json.as_bytes())
197            .map(|save| GalaxyModel::from_save(&save))
198            .unwrap()
199    }
200
201    #[test]
202    fn test_find_all_planets() {
203        let model = test_model();
204        let query = FindQuery::default();
205        let results = execute_find(&model, &query).unwrap();
206        assert!(!results.is_empty());
207    }
208
209    #[test]
210    fn test_find_by_biome() {
211        let model = test_model();
212        let query = FindQuery {
213            biome: Some(Biome::Lush),
214            ..Default::default()
215        };
216        let results = execute_find(&model, &query).unwrap();
217        for r in &results {
218            assert_eq!(r.planet.biome, Some(Biome::Lush));
219        }
220    }
221
222    #[test]
223    fn test_find_nearest_limit() {
224        let model = test_model();
225        let query = FindQuery {
226            nearest: Some(1),
227            ..Default::default()
228        };
229        let results = execute_find(&model, &query).unwrap();
230        assert!(results.len() <= 1);
231    }
232
233    #[test]
234    fn test_find_from_base() {
235        let model = test_model();
236        let query = FindQuery {
237            from: ReferencePoint::Base("Alpha Base".into()),
238            ..Default::default()
239        };
240        let results = execute_find(&model, &query);
241        assert!(results.is_ok());
242    }
243
244    #[test]
245    fn test_find_from_nonexistent_base_errors() {
246        let model = test_model();
247        let query = FindQuery {
248            from: ReferencePoint::Base("No Such Base".into()),
249            ..Default::default()
250        };
251        assert!(execute_find(&model, &query).is_err());
252    }
253
254    #[test]
255    fn test_find_results_sorted_by_distance() {
256        let model = test_model();
257        let query = FindQuery::default();
258        let results = execute_find(&model, &query).unwrap();
259        for i in 1..results.len() {
260            assert!(results[i].distance_ly >= results[i - 1].distance_ly);
261        }
262    }
263
264    #[test]
265    fn test_find_portal_hex_is_12_digits() {
266        let model = test_model();
267        let query = FindQuery::default();
268        let results = execute_find(&model, &query).unwrap();
269        for r in &results {
270            assert_eq!(r.portal_hex.len(), 12);
271        }
272    }
273
274    #[test]
275    fn test_find_by_discoverer() {
276        let model = test_model();
277        let query = FindQuery {
278            discoverer: Some("Explorer".into()),
279            ..Default::default()
280        };
281        let results = execute_find(&model, &query).unwrap();
282        for r in &results {
283            assert!(r.system.discoverer.as_ref().unwrap().contains("Explorer"));
284        }
285    }
286}