1use 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
18pub type PlanetKey = (SystemId, u8);
20
21#[derive(Debug)]
28pub struct GalaxyModel {
29 pub graph: StableGraph<SystemId, f64, Undirected>,
31
32 pub spatial: RTree<SystemPoint>,
34
35 pub systems: HashMap<SystemId, System>,
37
38 pub planets: HashMap<PlanetKey, Planet>,
40
41 pub bases: HashMap<String, PlayerBase>,
43
44 pub biome_index: HashMap<Biome, Vec<PlanetKey>>,
46
47 pub name_index: HashMap<String, SystemId>,
49
50 pub address_to_id: HashMap<u64, SystemId>,
52
53 pub node_map: HashMap<SystemId, NodeIndex>,
55
56 pub player_state: Option<PlayerState>,
58}
59
60impl Default for GalaxyModel {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl GalaxyModel {
67 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 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 let node_idx = graph.add_node(sys_id);
99 node_map.insert(sys_id, node_idx);
100
101 let point = SystemPoint::from_address(&system.address);
103 spatial_points.push(point);
104
105 address_to_id.insert(sys_id.0, sys_id);
107
108 if let Some(ref name) = system.name {
110 name_index.insert(name.to_lowercase(), sys_id);
111 }
112
113 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 let spatial = RTree::bulk_load(spatial_points);
127
128 let player_state = Some(save.to_core_player_state());
130
131 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 pub fn system_count(&self) -> usize {
160 self.systems.len()
161 }
162
163 pub fn planet_count(&self) -> usize {
165 self.planets.len()
166 }
167
168 pub fn base_count(&self) -> usize {
170 self.bases.len()
171 }
172
173 pub fn system(&self, id: &SystemId) -> Option<&System> {
175 self.systems.get(id)
176 }
177
178 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 pub fn base(&self, name: &str) -> Option<&PlayerBase> {
187 self.bases.get(&name.to_lowercase())
188 }
189
190 pub fn player_position(&self) -> Option<&GalacticAddress> {
192 self.player_state.as_ref().map(|ps| &ps.current_address)
193 }
194
195 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 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; }
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 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 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 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 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 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 assert_eq!(model.name_index.len(), name_count_before);
450 }
451}