Skip to main content

nms_graph/
model.rs

1//! The GalaxyModel -- central in-memory representation of the player's galaxy.
2
3use std::collections::HashMap;
4
5use petgraph::Undirected;
6use petgraph::stable_graph::{NodeIndex, StableGraph};
7use rstar::RTree;
8
9use nms_core::address::GalacticAddress;
10use nms_core::biome::Biome;
11use nms_core::delta::SaveDelta;
12use nms_core::player::{PlayerBase, PlayerState};
13use nms_core::system::{Planet, System};
14use nms_save::model::SaveRoot;
15
16use crate::extract::extract_systems;
17use crate::spatial::{SystemId, SystemPoint};
18
19/// Key for looking up a specific planet: (system, planet index).
20pub type PlanetKey = (SystemId, u8);
21
22/// The in-memory galactic model.
23///
24/// Three parallel data structures kept in sync:
25/// 1. petgraph -- topology (pathfinding, routing)
26/// 2. R-tree -- spatial (nearest-neighbor, radius queries)
27/// 3. HashMaps -- associative (name, biome, address lookups)
28#[derive(Debug)]
29pub struct GalaxyModel {
30    /// Graph topology: nodes are systems, edge weights are distance in ly.
31    pub graph: StableGraph<SystemId, f64, Undirected>,
32
33    /// Per-galaxy 3D spatial indexes of system positions.
34    pub spatial: HashMap<u8, RTree<SystemPoint>>,
35
36    /// Currently active galaxy for spatial queries.
37    pub active_galaxy: u8,
38
39    /// System data by ID.
40    pub systems: HashMap<SystemId, System>,
41
42    /// Planet data by (SystemId, planet_index).
43    pub planets: HashMap<PlanetKey, Planet>,
44
45    /// Base lookup by name (lowercase).
46    pub bases: HashMap<String, PlayerBase>,
47
48    /// Biome -> list of planets with that biome.
49    pub biome_index: HashMap<Biome, Vec<PlanetKey>>,
50
51    /// System name -> SystemId (lowercase, only for named systems).
52    pub name_index: HashMap<String, SystemId>,
53
54    /// Packed address (planet bits zeroed) -> SystemId.
55    pub address_to_id: HashMap<u64, SystemId>,
56
57    /// SystemId -> petgraph NodeIndex.
58    pub node_map: HashMap<SystemId, NodeIndex>,
59
60    /// Current player state (position, currencies).
61    pub player_state: Option<PlayerState>,
62}
63
64impl Default for GalaxyModel {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl GalaxyModel {
71    /// Create an empty `GalaxyModel`.
72    pub fn new() -> Self {
73        Self {
74            graph: StableGraph::default(),
75            spatial: HashMap::new(),
76            active_galaxy: 0,
77            systems: HashMap::new(),
78            planets: HashMap::new(),
79            bases: HashMap::new(),
80            biome_index: HashMap::new(),
81            name_index: HashMap::new(),
82            address_to_id: HashMap::new(),
83            node_map: HashMap::new(),
84            player_state: None,
85        }
86    }
87
88    /// Build a `GalaxyModel` from a parsed save file.
89    pub fn from_save(save: &SaveRoot) -> Self {
90        let extracted = extract_systems(save);
91
92        let mut graph: StableGraph<SystemId, f64, Undirected> = StableGraph::default();
93        let mut galaxy_points: HashMap<u8, Vec<SystemPoint>> = HashMap::new();
94        let mut systems = HashMap::with_capacity(extracted.len());
95        let mut planets = HashMap::new();
96        let mut biome_index: HashMap<Biome, Vec<PlanetKey>> = HashMap::new();
97        let mut name_index = HashMap::new();
98        let mut address_to_id = HashMap::new();
99        let mut node_map = HashMap::new();
100
101        for (sys_id, system) in extracted {
102            // Add to petgraph
103            let node_idx = graph.add_node(sys_id);
104            node_map.insert(sys_id, node_idx);
105
106            // Add to per-galaxy spatial point collector
107            let point = SystemPoint::from_address(&system.address);
108            let galaxy = system.address.reality_index;
109            galaxy_points.entry(galaxy).or_default().push(point);
110
111            // Add to address lookup
112            address_to_id.insert(sys_id.0, sys_id);
113
114            // Add to name index
115            if let Some(ref name) = system.name {
116                name_index.insert(name.to_lowercase(), sys_id);
117            }
118
119            // Extract planets into flat index
120            for planet in &system.planets {
121                let key = (sys_id, planet.index);
122                if let Some(biome) = planet.biome {
123                    biome_index.entry(biome).or_default().push(key);
124                }
125                planets.insert(key, planet.clone());
126            }
127
128            systems.insert(sys_id, system);
129        }
130
131        // Build per-galaxy R-trees from collected points
132        let spatial: HashMap<u8, RTree<SystemPoint>> = galaxy_points
133            .into_iter()
134            .map(|(galaxy, points)| (galaxy, RTree::bulk_load(points)))
135            .collect();
136
137        // Extract player state and determine active galaxy
138        let player_state = Some(save.to_core_player_state());
139        let active_galaxy = save.active_player_state().universe_address.reality_index;
140
141        // Extract bases
142        let ps = save.active_player_state();
143        let mut bases = HashMap::new();
144        for base in &ps.persistent_player_bases {
145            let core_base = base.to_core_base();
146            if !core_base.name.is_empty() {
147                bases.insert(core_base.name.to_lowercase(), core_base);
148            }
149        }
150
151        let mut model = Self {
152            graph,
153            spatial,
154            active_galaxy,
155            systems,
156            planets,
157            bases,
158            biome_index,
159            name_index,
160            address_to_id,
161            node_map,
162            player_state,
163        };
164
165        model.build_edges(crate::edges::EdgeStrategy::default());
166        model
167    }
168
169    /// Number of systems in the model.
170    pub fn system_count(&self) -> usize {
171        self.systems.len()
172    }
173
174    /// Number of planets in the model.
175    pub fn planet_count(&self) -> usize {
176        self.planets.len()
177    }
178
179    /// Number of bases in the model.
180    pub fn base_count(&self) -> usize {
181        self.bases.len()
182    }
183
184    /// Look up a system by its ID.
185    pub fn system(&self, id: &SystemId) -> Option<&System> {
186        self.systems.get(id)
187    }
188
189    /// Look up a system by name (case-insensitive).
190    pub fn system_by_name(&self, name: &str) -> Option<(&SystemId, &System)> {
191        self.name_index
192            .get(&name.to_lowercase())
193            .and_then(|id| self.systems.get(id).map(|s| (id, s)))
194    }
195
196    /// Look up a base by name (case-insensitive).
197    pub fn base(&self, name: &str) -> Option<&PlayerBase> {
198        self.bases.get(&name.to_lowercase())
199    }
200
201    /// Get the player's current position, if available.
202    pub fn player_position(&self) -> Option<&GalacticAddress> {
203        self.player_state.as_ref().map(|ps| &ps.current_address)
204    }
205
206    /// Get the spatial index for the active galaxy.
207    pub fn active_spatial(&self) -> Option<&RTree<SystemPoint>> {
208        self.spatial.get(&self.active_galaxy)
209    }
210
211    /// Get the spatial index for a specific galaxy.
212    pub fn spatial_for(&self, galaxy: u8) -> Option<&RTree<SystemPoint>> {
213        self.spatial.get(&galaxy)
214    }
215
216    /// Switch the active galaxy.
217    pub fn set_active_galaxy(&mut self, galaxy: u8) {
218        self.active_galaxy = galaxy;
219    }
220
221    /// List galaxies that have at least one discovered system.
222    pub fn discovered_galaxies(&self) -> Vec<u8> {
223        let mut galaxies: Vec<u8> = self.spatial.keys().copied().collect();
224        galaxies.sort();
225        galaxies
226    }
227
228    /// Rebuild all per-galaxy R-trees from the systems HashMap.
229    pub fn rebuild_spatial(&mut self) {
230        let mut galaxy_points: HashMap<u8, Vec<SystemPoint>> = HashMap::new();
231        for system in self.systems.values() {
232            let point = SystemPoint::from_address(&system.address);
233            let galaxy = system.address.reality_index;
234            galaxy_points.entry(galaxy).or_default().push(point);
235        }
236        self.spatial = galaxy_points
237            .into_iter()
238            .map(|(galaxy, points)| (galaxy, RTree::bulk_load(points)))
239            .collect();
240    }
241
242    /// Total number of points across all per-galaxy spatial indexes.
243    pub fn spatial_size(&self) -> usize {
244        self.spatial.values().map(|t| t.size()).sum()
245    }
246
247    /// Get all planets with a given biome.
248    pub fn planets_by_biome(&self, biome: Biome) -> Vec<&Planet> {
249        self.biome_index
250            .get(&biome)
251            .map(|keys| keys.iter().filter_map(|k| self.planets.get(k)).collect())
252            .unwrap_or_default()
253    }
254
255    /// Insert a new system into all indexes.
256    pub fn insert_system(&mut self, system: System) {
257        let sys_id = SystemId::from_address(&system.address);
258
259        if self.systems.contains_key(&sys_id) {
260            return; // Already exists
261        }
262
263        let node_idx = self.graph.add_node(sys_id);
264        self.node_map.insert(sys_id, node_idx);
265
266        let point = SystemPoint::from_address(&system.address);
267        let galaxy = system.address.reality_index;
268        self.spatial.entry(galaxy).or_default().insert(point);
269
270        self.address_to_id.insert(sys_id.0, sys_id);
271
272        if let Some(ref name) = system.name {
273            self.name_index.insert(name.to_lowercase(), sys_id);
274        }
275
276        for planet in &system.planets {
277            let key = (sys_id, planet.index);
278            if let Some(biome) = planet.biome {
279                self.biome_index.entry(biome).or_default().push(key);
280            }
281            self.planets.insert(key, planet.clone());
282        }
283
284        self.systems.insert(sys_id, system);
285    }
286
287    /// Ensure the player's current system exists in the model.
288    ///
289    /// If the player's system has no discovery record, this inserts a
290    /// placeholder system at the player's address so that distance queries
291    /// correctly return 0.0 for in-system results.
292    pub fn ensure_player_system(&mut self) {
293        if let Some(ref ps) = self.player_state {
294            let player_addr = ps.current_address;
295            let player_sys_id = SystemId::from_address(&player_addr);
296            if !self.systems.contains_key(&player_sys_id) {
297                let sentinel = System::new(player_addr, None, None, None, vec![]);
298                self.insert_system(sentinel);
299            }
300        }
301    }
302
303    /// Insert a base into the model.
304    pub fn insert_base(&mut self, base: PlayerBase) {
305        if !base.name.is_empty() {
306            self.bases.insert(base.name.to_lowercase(), base);
307        }
308    }
309
310    /// Apply a delta from the file watcher to update the model incrementally.
311    ///
312    /// Inserts new systems and planets, updates player position, and
313    /// adds/updates bases. Does NOT rebuild graph edges -- call
314    /// `build_edges()` afterward if needed for routing.
315    pub fn apply_delta(&mut self, delta: &SaveDelta) {
316        // 1. Insert new systems (with their planets)
317        for system in &delta.new_systems {
318            self.insert_system(system.clone());
319        }
320
321        // 2. Insert new planets into existing systems
322        for (sys_id, planet) in &delta.new_planets {
323            let key = (*sys_id, planet.index);
324            if !self.planets.contains_key(&key) {
325                if let Some(biome) = planet.biome {
326                    self.biome_index.entry(biome).or_default().push(key);
327                }
328                self.planets.insert(key, planet.clone());
329
330                // Also add to the system's planet list
331                if let Some(system) = self.systems.get_mut(sys_id)
332                    && !system.planets.iter().any(|p| p.index == planet.index)
333                {
334                    system.planets.push(planet.clone());
335                }
336            }
337        }
338
339        // 3. Update player position
340        if let Some(ref moved) = delta.player_moved
341            && let Some(ref mut ps) = self.player_state
342        {
343            ps.current_address = moved.to;
344        }
345
346        // 4. Insert new bases
347        for base in &delta.new_bases {
348            self.insert_base(base.clone());
349        }
350
351        // 5. Update modified bases (insert_base overwrites by name)
352        for base in &delta.modified_bases {
353            self.insert_base(base.clone());
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    /// Helper: build a minimal SaveRoot JSON and parse it.
363    fn minimal_save() -> SaveRoot {
364        let json = r#"{
365            "Version": 4720,
366            "Platform": "Mac|Final",
367            "ActiveContext": "Main",
368            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
369            "BaseContext": {
370                "GameMode": 1,
371                "PlayerStateData": {
372                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 100, "VoxelY": 50, "VoxelZ": -200, "SolarSystemIndex": 42, "PlanetIndex": 0}},
373                    "Units": 1000000, "Nanites": 5000, "Specials": 200,
374                    "PersistentPlayerBases": [
375                        {
376                            "BaseVersion": 8, "GalacticAddress": "0x050003AB8C07",
377                            "Position": [0.0, 0.0, 0.0], "Forward": [1.0, 0.0, 0.0],
378                            "LastUpdateTimestamp": 1700000000, "Objects": [], "RID": "",
379                            "Owner": {"LID": "", "UID": "123", "USN": "Test", "PTK": "ST", "TS": 0},
380                            "Name": "Home Base",
381                            "BaseType": {"PersistentBaseTypes": "HomePlanetBase"},
382                            "LastEditedById": "", "LastEditedByUsername": ""
383                        }
384                    ]
385                }
386            },
387            "ExpeditionContext": {
388                "GameMode": 6,
389                "PlayerStateData": {
390                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}},
391                    "Units": 0, "Nanites": 0, "Specials": 0,
392                    "PersistentPlayerBases": []
393                }
394            },
395            "DiscoveryManagerData": {
396                "DiscoveryData-v1": {
397                    "ReserveStore": 100, "ReserveManaged": 100,
398                    "Store": {
399                        "Record": [
400                            {"DD": {"UA": "0x002A32F38064", "DT": "SolarSystem", "VP": ["0xAAAA"]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
401                            {"DD": {"UA": "0x050003AB8C07", "DT": "SolarSystem", "VP": ["0xABCD"]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
402                            {"DD": {"UA": "0x150003AB8C07", "DT": "Planet", "VP": ["0xDEAD", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
403                            {"DD": {"UA": "0x0A0002001234", "DT": "SolarSystem", "VP": ["0x1234"]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
404                        ]
405                    }
406                }
407            }
408        }"#;
409        nms_save::parse_save(json.as_bytes()).unwrap()
410    }
411
412    #[test]
413    fn test_from_save_basic_counts() {
414        let save = minimal_save();
415        let model = GalaxyModel::from_save(&save);
416        // 3 systems: player's system discovery + 2 others
417        assert_eq!(model.system_count(), 3);
418        assert_eq!(model.planet_count(), 1);
419        assert_eq!(model.base_count(), 1);
420    }
421
422    #[test]
423    fn test_from_save_base_lookup() {
424        let save = minimal_save();
425        let model = GalaxyModel::from_save(&save);
426        let base = model.base("Home Base").unwrap();
427        assert_eq!(base.name, "Home Base");
428    }
429
430    #[test]
431    fn test_from_save_base_lookup_case_insensitive() {
432        let save = minimal_save();
433        let model = GalaxyModel::from_save(&save);
434        assert!(model.base("home base").is_some());
435        assert!(model.base("HOME BASE").is_some());
436    }
437
438    #[test]
439    fn test_from_save_player_position() {
440        let save = minimal_save();
441        let model = GalaxyModel::from_save(&save);
442        let pos = model.player_position().unwrap();
443        assert_eq!(pos.voxel_x(), 100);
444        assert_eq!(pos.voxel_y(), 50);
445        assert_eq!(pos.voxel_z(), -200);
446    }
447
448    #[test]
449    fn test_from_save_spatial_index_populated() {
450        let save = minimal_save();
451        let model = GalaxyModel::from_save(&save);
452        // 2 from discovery + 1 sentinel
453        assert_eq!(model.spatial_size(), 3);
454    }
455
456    #[test]
457    fn test_from_save_graph_nodes() {
458        let save = minimal_save();
459        let model = GalaxyModel::from_save(&save);
460        // 2 from discovery + 1 sentinel
461        assert_eq!(model.graph.node_count(), 3);
462    }
463
464    #[test]
465    fn test_from_save_biome_index() {
466        let save = minimal_save();
467        let model = GalaxyModel::from_save(&save);
468        let lush = model.planets_by_biome(Biome::Lush);
469        assert_eq!(lush.len(), 1);
470    }
471
472    #[test]
473    fn test_from_save_biome_index_empty() {
474        let save = minimal_save();
475        let model = GalaxyModel::from_save(&save);
476        let toxic = model.planets_by_biome(Biome::Toxic);
477        assert!(toxic.is_empty());
478    }
479
480    #[test]
481    fn test_insert_system_adds_to_all_indexes() {
482        let save = minimal_save();
483        let mut model = GalaxyModel::from_save(&save);
484        let count_before = model.system_count();
485
486        let addr = GalacticAddress::new(500, 10, -300, 0x999, 0, 0);
487        let system = System::new(
488            addr,
489            Some("New System".to_string()),
490            None,
491            None,
492            vec![Planet::new(0, Some(Biome::Lava), None, false, None, None)],
493        );
494        model.insert_system(system);
495
496        assert_eq!(model.system_count(), count_before + 1);
497        assert!(model.system_by_name("New System").is_some());
498        assert_eq!(model.spatial_size(), count_before + 1);
499        assert_eq!(model.graph.node_count(), count_before + 1);
500        assert_eq!(model.planets_by_biome(Biome::Lava).len(), 1);
501    }
502
503    #[test]
504    fn test_insert_duplicate_system_is_noop() {
505        let save = minimal_save();
506        let mut model = GalaxyModel::from_save(&save);
507        let count_before = model.system_count();
508
509        // Insert a system with the same address as an existing one
510        let existing_id = *model.systems.keys().next().unwrap();
511        let existing = model.systems.get(&existing_id).unwrap().clone();
512        model.insert_system(existing);
513
514        assert_eq!(model.system_count(), count_before);
515    }
516
517    #[test]
518    fn test_system_not_found_returns_none() {
519        let save = minimal_save();
520        let model = GalaxyModel::from_save(&save);
521        assert!(model.system(&SystemId(0xDEADBEEF)).is_none());
522    }
523
524    #[test]
525    fn test_system_by_name_not_found() {
526        let save = minimal_save();
527        let model = GalaxyModel::from_save(&save);
528        assert!(model.system_by_name("No Such System").is_none());
529    }
530
531    #[test]
532    fn test_base_not_found() {
533        let save = minimal_save();
534        let model = GalaxyModel::from_save(&save);
535        assert!(model.base("No Such Base").is_none());
536    }
537
538    #[test]
539    fn test_from_save_address_to_id() {
540        let save = minimal_save();
541        let model = GalaxyModel::from_save(&save);
542        // Every system should have an address_to_id entry
543        for &sys_id in model.systems.keys() {
544            assert!(model.address_to_id.contains_key(&sys_id.0));
545        }
546    }
547
548    #[test]
549    fn test_from_save_node_map() {
550        let save = minimal_save();
551        let model = GalaxyModel::from_save(&save);
552        // Every system should have a node_map entry
553        for &sys_id in model.systems.keys() {
554            assert!(model.node_map.contains_key(&sys_id));
555        }
556    }
557
558    #[test]
559    fn test_apply_delta_empty_is_noop() {
560        let save = minimal_save();
561        let mut model = GalaxyModel::from_save(&save);
562        let sys_count = model.system_count();
563        let planet_count = model.planet_count();
564        let base_count = model.base_count();
565
566        model.apply_delta(&nms_core::delta::SaveDelta::empty());
567
568        assert_eq!(model.system_count(), sys_count);
569        assert_eq!(model.planet_count(), planet_count);
570        assert_eq!(model.base_count(), base_count);
571    }
572
573    #[test]
574    fn test_apply_delta_new_system() {
575        let save = minimal_save();
576        let mut model = GalaxyModel::from_save(&save);
577        let count_before = model.system_count();
578
579        let delta = nms_core::delta::SaveDelta {
580            new_systems: vec![System::new(
581                GalacticAddress::new(500, 10, -300, 0x999, 0, 0),
582                Some("Delta System".into()),
583                None,
584                None,
585                vec![Planet::new(0, Some(Biome::Lava), None, false, None, None)],
586            )],
587            ..nms_core::delta::SaveDelta::empty()
588        };
589
590        model.apply_delta(&delta);
591        assert_eq!(model.system_count(), count_before + 1);
592        assert!(model.system_by_name("Delta System").is_some());
593    }
594
595    #[test]
596    fn test_apply_delta_player_moved() {
597        let save = minimal_save();
598        let mut model = GalaxyModel::from_save(&save);
599        let old_pos = *model.player_position().unwrap();
600        let new_addr = GalacticAddress::new(999, 0, 0, 1, 0, 0);
601
602        let delta = nms_core::delta::SaveDelta {
603            player_moved: Some(nms_core::delta::PlayerMoved {
604                from: old_pos,
605                to: new_addr,
606            }),
607            ..nms_core::delta::SaveDelta::empty()
608        };
609
610        model.apply_delta(&delta);
611        assert_eq!(model.player_position().unwrap().voxel_x(), 999);
612    }
613
614    #[test]
615    fn test_apply_delta_new_base() {
616        let save = minimal_save();
617        let mut model = GalaxyModel::from_save(&save);
618        let base_count_before = model.base_count();
619
620        let addr = GalacticAddress::new(100, 50, -200, 42, 0, 0);
621        let base = nms_core::player::PlayerBase::new(
622            "Delta Base".into(),
623            nms_core::player::BaseType::HomePlanetBase,
624            addr,
625            [0.0, 0.0, 0.0],
626            None,
627        );
628
629        let delta = nms_core::delta::SaveDelta {
630            new_bases: vec![base],
631            ..nms_core::delta::SaveDelta::empty()
632        };
633
634        model.apply_delta(&delta);
635        assert_eq!(model.base_count(), base_count_before + 1);
636        assert!(model.base("Delta Base").is_some());
637    }
638
639    #[test]
640    fn test_apply_delta_new_planet_to_existing_system() {
641        let save = minimal_save();
642        let mut model = GalaxyModel::from_save(&save);
643        let planet_count_before = model.planet_count();
644
645        let sys_id = *model.systems.keys().next().unwrap();
646        let planet = Planet::new(7, Some(Biome::Frozen), None, false, None, None);
647
648        let delta = nms_core::delta::SaveDelta {
649            new_planets: vec![(sys_id, planet)],
650            ..nms_core::delta::SaveDelta::empty()
651        };
652
653        model.apply_delta(&delta);
654        assert_eq!(model.planet_count(), planet_count_before + 1);
655        assert_eq!(model.planets_by_biome(Biome::Frozen).len(), 1);
656    }
657
658    #[test]
659    fn test_insert_system_unnamed() {
660        let save = minimal_save();
661        let mut model = GalaxyModel::from_save(&save);
662        let name_count_before = model.name_index.len();
663
664        let addr = GalacticAddress::new(600, 20, -400, 0xAAA, 0, 0);
665        let system = System::new(addr, None, None, None, vec![]);
666        model.insert_system(system);
667
668        // No new name index entry for unnamed system
669        assert_eq!(model.name_index.len(), name_count_before);
670    }
671
672    // -- Multi-galaxy spatial tests (milestone 7.4) --
673
674    #[test]
675    fn test_active_galaxy_defaults_to_zero() {
676        let model = GalaxyModel::new();
677        assert_eq!(model.active_galaxy, 0);
678    }
679
680    #[test]
681    fn test_set_active_galaxy() {
682        let mut model = GalaxyModel::new();
683        model.set_active_galaxy(9);
684        assert_eq!(model.active_galaxy, 9);
685    }
686
687    #[test]
688    fn test_active_spatial_empty_model_returns_none() {
689        let model = GalaxyModel::new();
690        assert!(model.active_spatial().is_none());
691    }
692
693    #[test]
694    fn test_spatial_for_unknown_galaxy_returns_none() {
695        let model = GalaxyModel::new();
696        assert!(model.spatial_for(42).is_none());
697    }
698
699    #[test]
700    fn test_discovered_galaxies_empty() {
701        let model = GalaxyModel::new();
702        assert!(model.discovered_galaxies().is_empty());
703    }
704
705    #[test]
706    fn test_insert_system_separate_galaxies() {
707        let mut model = GalaxyModel::new();
708
709        // Euclid (galaxy 0)
710        let addr0 = GalacticAddress::new(10, 0, 0, 0x100, 0, 0);
711        let sys0 = System::new(addr0, Some("Euclid Sys".into()), None, None, vec![]);
712        model.insert_system(sys0);
713
714        // Eissentam (galaxy 9)
715        let addr9 = GalacticAddress::new(20, 0, 0, 0x200, 0, 9);
716        let sys9 = System::new(addr9, Some("Eissentam Sys".into()), None, None, vec![]);
717        model.insert_system(sys9);
718
719        assert_eq!(model.system_count(), 2);
720        assert_eq!(model.spatial_size(), 2);
721
722        // Galaxy 0 has 1 point
723        let tree0 = model.spatial_for(0).unwrap();
724        assert_eq!(tree0.size(), 1);
725
726        // Galaxy 9 has 1 point
727        let tree9 = model.spatial_for(9).unwrap();
728        assert_eq!(tree9.size(), 1);
729
730        // No galaxy 5
731        assert!(model.spatial_for(5).is_none());
732    }
733
734    #[test]
735    fn test_discovered_galaxies_sorted() {
736        let mut model = GalaxyModel::new();
737
738        let addr9 = GalacticAddress::new(0, 0, 0, 0x100, 0, 9);
739        model.insert_system(System::new(addr9, None, None, None, vec![]));
740
741        let addr0 = GalacticAddress::new(0, 0, 0, 0x200, 0, 0);
742        model.insert_system(System::new(addr0, None, None, None, vec![]));
743
744        let addr2 = GalacticAddress::new(0, 0, 0, 0x300, 0, 2);
745        model.insert_system(System::new(addr2, None, None, None, vec![]));
746
747        let galaxies = model.discovered_galaxies();
748        assert_eq!(galaxies, vec![0, 2, 9]);
749    }
750
751    #[test]
752    fn test_rebuild_spatial() {
753        let mut model = GalaxyModel::new();
754
755        let addr0 = GalacticAddress::new(10, 0, 0, 0x100, 0, 0);
756        model.insert_system(System::new(addr0, None, None, None, vec![]));
757
758        let addr9 = GalacticAddress::new(20, 0, 0, 0x200, 0, 9);
759        model.insert_system(System::new(addr9, None, None, None, vec![]));
760
761        // Clear spatial and rebuild
762        model.spatial.clear();
763        assert_eq!(model.spatial_size(), 0);
764
765        model.rebuild_spatial();
766        assert_eq!(model.spatial_size(), 2);
767        assert_eq!(model.spatial_for(0).unwrap().size(), 1);
768        assert_eq!(model.spatial_for(9).unwrap().size(), 1);
769    }
770
771    #[test]
772    fn test_active_spatial_returns_correct_tree() {
773        let mut model = GalaxyModel::new();
774
775        let addr0 = GalacticAddress::new(10, 0, 0, 0x100, 0, 0);
776        model.insert_system(System::new(addr0, Some("E1".into()), None, None, vec![]));
777
778        let addr9 = GalacticAddress::new(20, 0, 0, 0x200, 0, 9);
779        model.insert_system(System::new(addr9, Some("E2".into()), None, None, vec![]));
780
781        // Default active galaxy is 0
782        assert_eq!(model.active_spatial().unwrap().size(), 1);
783
784        model.set_active_galaxy(9);
785        assert_eq!(model.active_spatial().unwrap().size(), 1);
786
787        model.set_active_galaxy(42);
788        assert!(model.active_spatial().is_none());
789    }
790
791    #[test]
792    fn test_player_system_distance_zero() {
793        // Player is at (100, 50, -200, SSI=42); multi_system_save has a
794        // matching SolarSystem discovery record at UA 0x002A32F38064.
795        let json = std::fs::read_to_string("../../data/test/multi_system_save.json").unwrap();
796        let save = nms_save::parse_save(json.as_bytes()).unwrap();
797        let model = GalaxyModel::from_save(&save);
798
799        let player_pos = model.player_position().unwrap();
800        assert_eq!(player_pos.voxel_x(), 100);
801        assert_eq!(player_pos.voxel_y(), 50);
802        assert_eq!(player_pos.voxel_z(), -200);
803        assert_eq!(player_pos.solar_system_index(), 42);
804
805        // The nearest system should be the player's own system at distance 0.
806        let nearest = model.nearest_systems(player_pos, 1);
807        assert!(
808            !nearest.is_empty(),
809            "Expected at least one system near player"
810        );
811        let (_, distance) = &nearest[0];
812        assert_eq!(
813            *distance, 0.0,
814            "Player's own system should be at distance 0.0, got {distance}"
815        );
816    }
817
818    #[test]
819    fn test_ensure_player_system_inserts_sentinel() {
820        // Build a model where the player's system has no discovery record.
821        let json = r#"{
822            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
823            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
824            "BaseContext": {"GameMode": 1, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 999, "VoxelY": 10, "VoxelZ": -500, "SolarSystemIndex": 77, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
825            "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": []}},
826            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
827        }"#;
828        let save = nms_save::parse_save(json.as_bytes()).unwrap();
829        let mut model = GalaxyModel::from_save(&save);
830
831        let player_pos = *model.player_position().unwrap();
832        let player_sys_id = SystemId::from_address(&player_pos);
833        assert!(model.system(&player_sys_id).is_none());
834
835        model.ensure_player_system();
836        assert!(model.system(&player_sys_id).is_some());
837
838        let nearest = model.nearest_systems(&player_pos, 1);
839        assert!(!nearest.is_empty());
840        assert_eq!(nearest[0].1, 0.0);
841    }
842}