1use rstar::PointDistance;
10
11use crate::model::GalaxyModel;
12use crate::spatial::SystemId;
13
14#[derive(Debug, Clone, Copy)]
16pub enum EdgeStrategy {
17 Knn { k: usize },
19 WarpRange { max_ly: f64 },
21}
22
23impl Default for EdgeStrategy {
24 fn default() -> Self {
25 EdgeStrategy::Knn { k: 10 }
26 }
27}
28
29impl GalaxyModel {
30 pub fn build_edges(&mut self, strategy: EdgeStrategy) {
32 let edge_ids: Vec<_> = self.graph.edge_indices().collect();
34 for edge_id in edge_ids {
35 self.graph.remove_edge(edge_id);
36 }
37
38 match strategy {
39 EdgeStrategy::Knn { k } => self.build_knn_edges(k),
40 EdgeStrategy::WarpRange { max_ly } => self.build_warp_range_edges(max_ly),
41 }
42 }
43
44 fn build_knn_edges(&mut self, k: usize) {
50 let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
51
52 for &sys_id in &system_ids {
53 let system = match self.systems.get(&sys_id) {
54 Some(s) => s,
55 None => continue,
56 };
57
58 let query_point = [
59 system.address.voxel_x() as f64,
60 system.address.voxel_y() as f64,
61 system.address.voxel_z() as f64,
62 ];
63
64 let neighbors: Vec<_> = self
66 .spatial
67 .nearest_neighbor_iter(&query_point)
68 .filter(|sp| sp.id != sys_id)
69 .take(k)
70 .map(|sp| {
71 let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
72 (sp.id, dist_ly)
73 })
74 .collect();
75
76 let from_node = match self.node_map.get(&sys_id) {
77 Some(&n) => n,
78 None => continue,
79 };
80
81 for (neighbor_id, dist_ly) in neighbors {
82 let to_node = match self.node_map.get(&neighbor_id) {
83 Some(&n) => n,
84 None => continue,
85 };
86
87 if self.graph.find_edge(from_node, to_node).is_none() {
89 self.graph.add_edge(from_node, to_node, dist_ly);
90 }
91 }
92 }
93 }
94
95 fn build_warp_range_edges(&mut self, max_ly: f64) {
100 let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
101
102 for &sys_id in &system_ids {
103 let system = match self.systems.get(&sys_id) {
104 Some(s) => s,
105 None => continue,
106 };
107
108 let query_point = [
109 system.address.voxel_x() as f64,
110 system.address.voxel_y() as f64,
111 system.address.voxel_z() as f64,
112 ];
113
114 let voxel_radius = max_ly / 400.0;
115 let voxel_radius_sq = voxel_radius * voxel_radius;
116
117 let neighbors: Vec<_> = self
118 .spatial
119 .nearest_neighbor_iter(&query_point)
120 .take_while(|sp| sp.distance_2(&query_point) <= voxel_radius_sq)
121 .filter(|sp| sp.id != sys_id)
122 .map(|sp| {
123 let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
124 (sp.id, dist_ly)
125 })
126 .collect();
127
128 let from_node = match self.node_map.get(&sys_id) {
129 Some(&n) => n,
130 None => continue,
131 };
132
133 for (neighbor_id, dist_ly) in neighbors {
134 let to_node = match self.node_map.get(&neighbor_id) {
135 Some(&n) => n,
136 None => continue,
137 };
138
139 if self.graph.find_edge(from_node, to_node).is_none() {
140 self.graph.add_edge(from_node, to_node, dist_ly);
141 }
142 }
143 }
144 }
145
146 pub fn connect_new_system(&mut self, sys_id: SystemId, k: usize) {
151 let system = match self.systems.get(&sys_id) {
152 Some(s) => s,
153 None => return,
154 };
155
156 let query_point = [
157 system.address.voxel_x() as f64,
158 system.address.voxel_y() as f64,
159 system.address.voxel_z() as f64,
160 ];
161
162 let neighbors: Vec<_> = self
163 .spatial
164 .nearest_neighbor_iter(&query_point)
165 .filter(|sp| sp.id != sys_id)
166 .take(k)
167 .map(|sp| {
168 let dist_ly = sp.distance_2(&query_point).sqrt() * 400.0;
169 (sp.id, dist_ly)
170 })
171 .collect();
172
173 let from_node = match self.node_map.get(&sys_id) {
174 Some(&n) => n,
175 None => return,
176 };
177
178 for (neighbor_id, dist_ly) in neighbors {
179 let to_node = match self.node_map.get(&neighbor_id) {
180 Some(&n) => n,
181 None => continue,
182 };
183
184 if self.graph.find_edge(from_node, to_node).is_none() {
185 self.graph.add_edge(from_node, to_node, dist_ly);
186 }
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use nms_core::address::GalacticAddress;
195 use nms_core::biome::Biome;
196 use nms_core::system::{Planet, System};
197
198 fn line_model(n: usize, spacing: i16) -> GalaxyModel {
200 let json = r#"{
201 "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
202 "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
203 "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": []}},
204 "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": []}},
205 "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
206 }"#;
207 let save = nms_save::parse_save(json.as_bytes()).unwrap();
208 let mut model = GalaxyModel::from_save(&save);
209
210 for i in 0..n {
211 let x = (i as i16) * spacing;
212 let ssi = (i + 1) as u16;
213 let addr = GalacticAddress::new(x, 0, 0, ssi, 0, 0);
214 let planet = Planet::new(0, Some(Biome::Barren), None, false, None, None);
215 let system = System::new(addr, None, None, None, vec![planet]);
216 model.insert_system(system);
217 }
218
219 model
220 }
221
222 #[test]
223 fn test_knn_edges_created() {
224 let mut model = line_model(5, 10);
225 model.build_edges(EdgeStrategy::Knn { k: 2 });
226 assert!(model.graph.edge_count() > 0);
227 assert!(model.graph.edge_count() <= 10);
228 }
229
230 #[test]
231 fn test_knn_edges_all_nodes_connected() {
232 let mut model = line_model(5, 10);
233 model.build_edges(EdgeStrategy::Knn { k: 2 });
234 for (_, &node_idx) in &model.node_map {
235 let degree = model.graph.edges(node_idx).count();
236 assert!(degree >= 1, "Node with 0 edges found");
237 }
238 }
239
240 #[test]
241 fn test_warp_range_edges_respects_distance() {
242 let mut model = line_model(5, 10);
243 model.build_edges(EdgeStrategy::WarpRange { max_ly: 5000.0 });
245 for edge in model.graph.edge_indices() {
246 let weight = model.graph[edge];
247 assert!(weight <= 5000.0, "Edge weight {weight} exceeds warp range");
248 }
249 }
250
251 #[test]
252 fn test_warp_range_zero_no_edges() {
253 let mut model = line_model(5, 10);
254 model.build_edges(EdgeStrategy::WarpRange { max_ly: 0.0 });
255 assert_eq!(model.graph.edge_count(), 0);
256 }
257
258 #[test]
259 fn test_build_edges_clears_previous() {
260 let mut model = line_model(5, 10);
261 model.build_edges(EdgeStrategy::Knn { k: 4 });
262 let count1 = model.graph.edge_count();
263 model.build_edges(EdgeStrategy::Knn { k: 1 });
264 let count2 = model.graph.edge_count();
265 assert!(count2 < count1);
266 }
267
268 #[test]
269 fn test_connect_new_system_adds_edges() {
270 let mut model = line_model(3, 10);
271 model.build_edges(EdgeStrategy::Knn { k: 2 });
272 let edges_before = model.graph.edge_count();
273
274 let addr = GalacticAddress::new(5, 0, 0, 0xFFF, 0, 0);
276 let system = System::new(addr, None, None, None, vec![]);
277 let sys_id = crate::spatial::SystemId::from_address(&addr);
278 model.insert_system(system);
279 model.connect_new_system(sys_id, 2);
280
281 assert!(model.graph.edge_count() > edges_before);
282 }
283
284 #[test]
285 fn test_no_self_loops() {
286 let mut model = line_model(5, 10);
287 model.build_edges(EdgeStrategy::Knn { k: 4 });
288 for edge in model.graph.edge_indices() {
289 let (a, b) = model.graph.edge_endpoints(edge).unwrap();
290 assert_ne!(a, b, "Self-loop detected");
291 }
292 }
293
294 #[test]
295 fn test_no_duplicate_edges() {
296 let mut model = line_model(5, 10);
297 model.build_edges(EdgeStrategy::Knn { k: 4 });
298 let mut seen = std::collections::HashSet::new();
299 for edge in model.graph.edge_indices() {
300 let (a, b) = model.graph.edge_endpoints(edge).unwrap();
301 let key = if a < b { (a, b) } else { (b, a) };
302 assert!(seen.insert(key), "Duplicate edge found: {key:?}");
303 }
304 }
305
306 #[test]
307 fn test_edge_strategy_default_is_knn_10() {
308 let strategy = EdgeStrategy::default();
309 match strategy {
310 EdgeStrategy::Knn { k } => assert_eq!(k, 10),
311 _ => panic!("Default should be Knn"),
312 }
313 }
314
315 #[test]
316 fn test_warp_range_large_connects_all() {
317 let mut model = line_model(3, 10);
318 model.build_edges(EdgeStrategy::WarpRange { max_ly: 100_000.0 });
320 assert_eq!(model.graph.edge_count(), 3);
322 }
323
324 #[test]
325 fn test_connect_new_system_nonexistent_is_noop() {
326 let mut model = line_model(3, 10);
327 model.build_edges(EdgeStrategy::Knn { k: 2 });
328 let edges_before = model.graph.edge_count();
329
330 model.connect_new_system(SystemId(0xDEADBEEF), 2);
332
333 assert_eq!(model.graph.edge_count(), edges_before);
334 }
335
336 #[test]
337 fn test_edge_weights_are_positive() {
338 let mut model = line_model(5, 10);
339 model.build_edges(EdgeStrategy::Knn { k: 2 });
340 for edge in model.graph.edge_indices() {
341 let weight = model.graph[edge];
342 assert!(weight > 0.0, "Edge weight should be positive, got {weight}");
343 }
344 }
345}