1use 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#[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 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 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 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 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 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 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 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
198fn 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 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 let positions = [
257 (10, 0, 0, 0x100, "Near"), (50, 0, 0, 0x200, "Mid"), (200, 0, 0, 0x300, "Far"), ];
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 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 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 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 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 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 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 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 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 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}