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::player::{PlayerBase, PlayerState};
12use nms_core::system::{Planet, System};
13use nms_save::model::SaveRoot;
14
15use crate::extract::extract_systems;
16use crate::spatial::{SystemId, SystemPoint};
17
18/// Key for looking up a specific planet: (system, planet index).
19pub type PlanetKey = (SystemId, u8);
20
21/// The in-memory galactic model.
22///
23/// Three parallel data structures kept in sync:
24/// 1. petgraph -- topology (pathfinding, routing)
25/// 2. R-tree -- spatial (nearest-neighbor, radius queries)
26/// 3. HashMaps -- associative (name, biome, address lookups)
27#[derive(Debug)]
28pub struct GalaxyModel {
29    /// Graph topology: nodes are systems, edge weights are distance in ly.
30    pub graph: StableGraph<SystemId, f64, Undirected>,
31
32    /// 3D spatial index of system positions.
33    pub spatial: RTree<SystemPoint>,
34
35    /// System data by ID.
36    pub systems: HashMap<SystemId, System>,
37
38    /// Planet data by (SystemId, planet_index).
39    pub planets: HashMap<PlanetKey, Planet>,
40
41    /// Base lookup by name (lowercase).
42    pub bases: HashMap<String, PlayerBase>,
43
44    /// Biome -> list of planets with that biome.
45    pub biome_index: HashMap<Biome, Vec<PlanetKey>>,
46
47    /// System name -> SystemId (lowercase, only for named systems).
48    pub name_index: HashMap<String, SystemId>,
49
50    /// Packed address (planet bits zeroed) -> SystemId.
51    pub address_to_id: HashMap<u64, SystemId>,
52
53    /// SystemId -> petgraph NodeIndex.
54    pub node_map: HashMap<SystemId, NodeIndex>,
55
56    /// Current player state (position, currencies).
57    pub player_state: Option<PlayerState>,
58}
59
60impl Default for GalaxyModel {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl GalaxyModel {
67    /// Create an empty `GalaxyModel`.
68    pub fn new() -> Self {
69        Self {
70            graph: StableGraph::default(),
71            spatial: RTree::new(),
72            systems: HashMap::new(),
73            planets: HashMap::new(),
74            bases: HashMap::new(),
75            biome_index: HashMap::new(),
76            name_index: HashMap::new(),
77            address_to_id: HashMap::new(),
78            node_map: HashMap::new(),
79            player_state: None,
80        }
81    }
82
83    /// Build a `GalaxyModel` from a parsed save file.
84    pub fn from_save(save: &SaveRoot) -> Self {
85        let extracted = extract_systems(save);
86
87        let mut graph: StableGraph<SystemId, f64, Undirected> = StableGraph::default();
88        let mut spatial_points = Vec::with_capacity(extracted.len());
89        let mut systems = HashMap::with_capacity(extracted.len());
90        let mut planets = HashMap::new();
91        let mut biome_index: HashMap<Biome, Vec<PlanetKey>> = HashMap::new();
92        let mut name_index = HashMap::new();
93        let mut address_to_id = HashMap::new();
94        let mut node_map = HashMap::new();
95
96        for (sys_id, system) in extracted {
97            // Add to petgraph
98            let node_idx = graph.add_node(sys_id);
99            node_map.insert(sys_id, node_idx);
100
101            // Add to spatial index
102            let point = SystemPoint::from_address(&system.address);
103            spatial_points.push(point);
104
105            // Add to address lookup
106            address_to_id.insert(sys_id.0, sys_id);
107
108            // Add to name index
109            if let Some(ref name) = system.name {
110                name_index.insert(name.to_lowercase(), sys_id);
111            }
112
113            // Extract planets into flat index
114            for planet in &system.planets {
115                let key = (sys_id, planet.index);
116                if let Some(biome) = planet.biome {
117                    biome_index.entry(biome).or_default().push(key);
118                }
119                planets.insert(key, planet.clone());
120            }
121
122            systems.insert(sys_id, system);
123        }
124
125        // Build R-tree from collected points
126        let spatial = RTree::bulk_load(spatial_points);
127
128        // Extract player state
129        let player_state = Some(save.to_core_player_state());
130
131        // Extract bases
132        let ps = save.active_player_state();
133        let mut bases = HashMap::new();
134        for base in &ps.persistent_player_bases {
135            let core_base = base.to_core_base();
136            if !core_base.name.is_empty() {
137                bases.insert(core_base.name.to_lowercase(), core_base);
138            }
139        }
140
141        let mut model = Self {
142            graph,
143            spatial,
144            systems,
145            planets,
146            bases,
147            biome_index,
148            name_index,
149            address_to_id,
150            node_map,
151            player_state,
152        };
153
154        model.build_edges(crate::edges::EdgeStrategy::default());
155        model
156    }
157
158    /// Number of systems in the model.
159    pub fn system_count(&self) -> usize {
160        self.systems.len()
161    }
162
163    /// Number of planets in the model.
164    pub fn planet_count(&self) -> usize {
165        self.planets.len()
166    }
167
168    /// Number of bases in the model.
169    pub fn base_count(&self) -> usize {
170        self.bases.len()
171    }
172
173    /// Look up a system by its ID.
174    pub fn system(&self, id: &SystemId) -> Option<&System> {
175        self.systems.get(id)
176    }
177
178    /// Look up a system by name (case-insensitive).
179    pub fn system_by_name(&self, name: &str) -> Option<(&SystemId, &System)> {
180        self.name_index
181            .get(&name.to_lowercase())
182            .and_then(|id| self.systems.get(id).map(|s| (id, s)))
183    }
184
185    /// Look up a base by name (case-insensitive).
186    pub fn base(&self, name: &str) -> Option<&PlayerBase> {
187        self.bases.get(&name.to_lowercase())
188    }
189
190    /// Get the player's current position, if available.
191    pub fn player_position(&self) -> Option<&GalacticAddress> {
192        self.player_state.as_ref().map(|ps| &ps.current_address)
193    }
194
195    /// Get all planets with a given biome.
196    pub fn planets_by_biome(&self, biome: Biome) -> Vec<&Planet> {
197        self.biome_index
198            .get(&biome)
199            .map(|keys| keys.iter().filter_map(|k| self.planets.get(k)).collect())
200            .unwrap_or_default()
201    }
202
203    /// Insert a new system into all indexes.
204    pub fn insert_system(&mut self, system: System) {
205        let sys_id = SystemId::from_address(&system.address);
206
207        if self.systems.contains_key(&sys_id) {
208            return; // Already exists
209        }
210
211        let node_idx = self.graph.add_node(sys_id);
212        self.node_map.insert(sys_id, node_idx);
213
214        let point = SystemPoint::from_address(&system.address);
215        self.spatial.insert(point);
216
217        self.address_to_id.insert(sys_id.0, sys_id);
218
219        if let Some(ref name) = system.name {
220            self.name_index.insert(name.to_lowercase(), sys_id);
221        }
222
223        for planet in &system.planets {
224            let key = (sys_id, planet.index);
225            if let Some(biome) = planet.biome {
226                self.biome_index.entry(biome).or_default().push(key);
227            }
228            self.planets.insert(key, planet.clone());
229        }
230
231        self.systems.insert(sys_id, system);
232    }
233
234    /// Insert a base into the model.
235    pub fn insert_base(&mut self, base: PlayerBase) {
236        if !base.name.is_empty() {
237            self.bases.insert(base.name.to_lowercase(), base);
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    /// Helper: build a minimal SaveRoot JSON and parse it.
247    fn minimal_save() -> SaveRoot {
248        let json = r#"{
249            "Version": 4720,
250            "Platform": "Mac|Final",
251            "ActiveContext": "Main",
252            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
253            "BaseContext": {
254                "GameMode": 1,
255                "PlayerStateData": {
256                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 100, "VoxelY": 50, "VoxelZ": -200, "SolarSystemIndex": 42, "PlanetIndex": 0}},
257                    "Units": 1000000, "Nanites": 5000, "Specials": 200,
258                    "PersistentPlayerBases": [
259                        {
260                            "BaseVersion": 8, "GalacticAddress": "0x050003AB8C07",
261                            "Position": [0.0, 0.0, 0.0], "Forward": [1.0, 0.0, 0.0],
262                            "LastUpdateTimestamp": 1700000000, "Objects": [], "RID": "",
263                            "Owner": {"LID": "", "UID": "123", "USN": "Test", "PTK": "ST", "TS": 0},
264                            "Name": "Home Base",
265                            "BaseType": {"PersistentBaseTypes": "HomePlanetBase"},
266                            "LastEditedById": "", "LastEditedByUsername": ""
267                        }
268                    ]
269                }
270            },
271            "ExpeditionContext": {
272                "GameMode": 6,
273                "PlayerStateData": {
274                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}},
275                    "Units": 0, "Nanites": 0, "Specials": 0,
276                    "PersistentPlayerBases": []
277                }
278            },
279            "DiscoveryManagerData": {
280                "DiscoveryData-v1": {
281                    "ReserveStore": 100, "ReserveManaged": 100,
282                    "Store": {
283                        "Record": [
284                            {"DD": {"UA": "0x050003AB8C07", "DT": "SolarSystem", "VP": ["0xABCD"]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
285                            {"DD": {"UA": "0x150003AB8C07", "DT": "Planet", "VP": ["0xDEAD", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
286                            {"DD": {"UA": "0x0A0002001234", "DT": "SolarSystem", "VP": ["0x1234"]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
287                        ]
288                    }
289                }
290            }
291        }"#;
292        nms_save::parse_save(json.as_bytes()).unwrap()
293    }
294
295    #[test]
296    fn test_from_save_basic_counts() {
297        let save = minimal_save();
298        let model = GalaxyModel::from_save(&save);
299        assert_eq!(model.system_count(), 2);
300        assert_eq!(model.planet_count(), 1);
301        assert_eq!(model.base_count(), 1);
302    }
303
304    #[test]
305    fn test_from_save_base_lookup() {
306        let save = minimal_save();
307        let model = GalaxyModel::from_save(&save);
308        let base = model.base("Home Base").unwrap();
309        assert_eq!(base.name, "Home Base");
310    }
311
312    #[test]
313    fn test_from_save_base_lookup_case_insensitive() {
314        let save = minimal_save();
315        let model = GalaxyModel::from_save(&save);
316        assert!(model.base("home base").is_some());
317        assert!(model.base("HOME BASE").is_some());
318    }
319
320    #[test]
321    fn test_from_save_player_position() {
322        let save = minimal_save();
323        let model = GalaxyModel::from_save(&save);
324        let pos = model.player_position().unwrap();
325        assert_eq!(pos.voxel_x(), 100);
326        assert_eq!(pos.voxel_y(), 50);
327        assert_eq!(pos.voxel_z(), -200);
328    }
329
330    #[test]
331    fn test_from_save_spatial_index_populated() {
332        let save = minimal_save();
333        let model = GalaxyModel::from_save(&save);
334        assert_eq!(model.spatial.size(), 2);
335    }
336
337    #[test]
338    fn test_from_save_graph_nodes() {
339        let save = minimal_save();
340        let model = GalaxyModel::from_save(&save);
341        assert_eq!(model.graph.node_count(), 2);
342    }
343
344    #[test]
345    fn test_from_save_biome_index() {
346        let save = minimal_save();
347        let model = GalaxyModel::from_save(&save);
348        let lush = model.planets_by_biome(Biome::Lush);
349        assert_eq!(lush.len(), 1);
350    }
351
352    #[test]
353    fn test_from_save_biome_index_empty() {
354        let save = minimal_save();
355        let model = GalaxyModel::from_save(&save);
356        let toxic = model.planets_by_biome(Biome::Toxic);
357        assert!(toxic.is_empty());
358    }
359
360    #[test]
361    fn test_insert_system_adds_to_all_indexes() {
362        let save = minimal_save();
363        let mut model = GalaxyModel::from_save(&save);
364        let count_before = model.system_count();
365
366        let addr = GalacticAddress::new(500, 10, -300, 0x999, 0, 0);
367        let system = System::new(
368            addr,
369            Some("New System".to_string()),
370            None,
371            None,
372            vec![Planet::new(0, Some(Biome::Lava), None, false, None, None)],
373        );
374        model.insert_system(system);
375
376        assert_eq!(model.system_count(), count_before + 1);
377        assert!(model.system_by_name("New System").is_some());
378        assert_eq!(model.spatial.size(), count_before + 1);
379        assert_eq!(model.graph.node_count(), count_before + 1);
380        assert_eq!(model.planets_by_biome(Biome::Lava).len(), 1);
381    }
382
383    #[test]
384    fn test_insert_duplicate_system_is_noop() {
385        let save = minimal_save();
386        let mut model = GalaxyModel::from_save(&save);
387        let count_before = model.system_count();
388
389        // Insert a system with the same address as an existing one
390        let existing_id = *model.systems.keys().next().unwrap();
391        let existing = model.systems.get(&existing_id).unwrap().clone();
392        model.insert_system(existing);
393
394        assert_eq!(model.system_count(), count_before);
395    }
396
397    #[test]
398    fn test_system_not_found_returns_none() {
399        let save = minimal_save();
400        let model = GalaxyModel::from_save(&save);
401        assert!(model.system(&SystemId(0xDEADBEEF)).is_none());
402    }
403
404    #[test]
405    fn test_system_by_name_not_found() {
406        let save = minimal_save();
407        let model = GalaxyModel::from_save(&save);
408        assert!(model.system_by_name("No Such System").is_none());
409    }
410
411    #[test]
412    fn test_base_not_found() {
413        let save = minimal_save();
414        let model = GalaxyModel::from_save(&save);
415        assert!(model.base("No Such Base").is_none());
416    }
417
418    #[test]
419    fn test_from_save_address_to_id() {
420        let save = minimal_save();
421        let model = GalaxyModel::from_save(&save);
422        // Every system should have an address_to_id entry
423        for &sys_id in model.systems.keys() {
424            assert!(model.address_to_id.contains_key(&sys_id.0));
425        }
426    }
427
428    #[test]
429    fn test_from_save_node_map() {
430        let save = minimal_save();
431        let model = GalaxyModel::from_save(&save);
432        // Every system should have a node_map entry
433        for &sys_id in model.systems.keys() {
434            assert!(model.node_map.contains_key(&sys_id));
435        }
436    }
437
438    #[test]
439    fn test_insert_system_unnamed() {
440        let save = minimal_save();
441        let mut model = GalaxyModel::from_save(&save);
442        let name_count_before = model.name_index.len();
443
444        let addr = GalacticAddress::new(600, 20, -400, 0xAAA, 0, 0);
445        let system = System::new(addr, None, None, None, vec![]);
446        model.insert_system(system);
447
448        // No new name index entry for unnamed system
449        assert_eq!(model.name_index.len(), name_count_before);
450    }
451}