1use 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#[derive(Debug, Clone, Default)]
11pub enum ReferencePoint {
12 #[default]
14 CurrentPosition,
15 Base(String),
17 Address(GalacticAddress),
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct FindQuery {
24 pub biome: Option<Biome>,
26 pub infested: Option<bool>,
28 pub within_ly: Option<f64>,
30 pub nearest: Option<usize>,
32 pub name_pattern: Option<String>,
34 pub discoverer: Option<String>,
36 pub named_only: bool,
38 pub from: ReferencePoint,
40}
41
42#[derive(Debug, Clone)]
44pub struct FindResult {
45 pub planet: Planet,
47 pub system: System,
49 pub distance_ly: f64,
51 pub portal_hex: String,
53}
54
55pub fn execute_find(model: &GalaxyModel, query: &FindQuery) -> Result<Vec<FindResult>, GraphError> {
59 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 let planet_matches = if let Some(n) = query.nearest {
80 model.nearest_planets(&from, n * 2, &biome_filter) } else if let Some(radius) = query.within_ly {
82 model.planets_within_radius(&from, radius, &biome_filter)
83 } else {
84 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 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 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 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 results.sort_by(|a, b| a.distance_ly.partial_cmp(&b.distance_ly).unwrap());
163
164 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}