1use std::collections::HashSet;
4
5use nms_core::address::GalacticAddress;
6use nms_graph::query::BiomeFilter;
7use nms_graph::route::{Route, RoutingAlgorithm};
8use nms_graph::{GalaxyModel, GraphError, SystemId};
9
10#[derive(Debug, Clone)]
12pub enum TargetSelection {
13 Biome(BiomeFilter),
15 Named(Vec<String>),
17 SystemIds(Vec<SystemId>),
19}
20
21#[derive(Debug, Clone, Default)]
23pub enum RouteFrom {
24 #[default]
26 CurrentPosition,
27 Base(String),
29 Address(GalacticAddress),
31}
32
33#[derive(Debug, Clone)]
35pub struct RouteQuery {
36 pub targets: TargetSelection,
38 pub from: RouteFrom,
40 pub warp_range: Option<f64>,
42 pub within_ly: Option<f64>,
44 pub max_targets: Option<usize>,
46 pub algorithm: RoutingAlgorithm,
48 pub return_to_start: bool,
50}
51
52#[derive(Debug, Clone)]
54pub struct RouteResult {
55 pub route: Route,
57 pub warp_range: Option<f64>,
59 pub warp_jumps: Option<usize>,
61 pub algorithm: RoutingAlgorithm,
63 pub targets_visited: usize,
65}
66
67fn resolve_start(model: &GalaxyModel, from: &RouteFrom) -> Result<GalacticAddress, GraphError> {
69 match from {
70 RouteFrom::CurrentPosition => model
71 .player_position()
72 .copied()
73 .ok_or(GraphError::NoPlayerPosition),
74 RouteFrom::Base(name) => model
75 .base(name)
76 .map(|b| b.address)
77 .ok_or_else(|| GraphError::BaseNotFound(name.clone())),
78 RouteFrom::Address(addr) => Ok(*addr),
79 }
80}
81
82fn resolve_named_targets(
84 model: &GalaxyModel,
85 names: &[String],
86) -> Result<Vec<SystemId>, Box<dyn std::error::Error>> {
87 let mut ids = Vec::with_capacity(names.len());
88 for name in names {
89 if let Some(base) = model.base(name) {
91 ids.push(SystemId::from_address(&base.address));
92 continue;
93 }
94 if let Some((id, _)) = model.system_by_name(name) {
96 ids.push(*id);
97 continue;
98 }
99 return Err(format!("Target not found: \"{name}\"").into());
100 }
101 Ok(ids)
102}
103
104fn resolve_biome_targets(
106 model: &GalaxyModel,
107 filter: &BiomeFilter,
108 from: &GalacticAddress,
109 within_ly: Option<f64>,
110 max_targets: Option<usize>,
111) -> Vec<SystemId> {
112 let limit = max_targets.unwrap_or(100) * 2; let planet_matches = if let Some(radius) = within_ly {
115 model.planets_within_radius(from, radius, filter)
116 } else {
117 model.nearest_planets(from, limit, filter)
118 };
119
120 let mut seen = HashSet::new();
122 let mut ids = Vec::new();
123 for (key, _, _) in &planet_matches {
124 if seen.insert(key.0) {
125 ids.push(key.0);
126 }
127 }
128
129 ids
130}
131
132pub fn execute_route(
137 model: &GalaxyModel,
138 query: &RouteQuery,
139) -> Result<RouteResult, Box<dyn std::error::Error>> {
140 let start_addr = resolve_start(model, &query.from)?;
142
143 let nearest = model.nearest_systems(&start_addr, 1);
145 let start_id = nearest
146 .first()
147 .map(|(id, _)| *id)
148 .ok_or("No systems in model")?;
149
150 let mut target_ids = match &query.targets {
152 TargetSelection::Biome(filter) => resolve_biome_targets(
153 model,
154 filter,
155 &start_addr,
156 query.within_ly,
157 query.max_targets,
158 ),
159 TargetSelection::Named(names) => resolve_named_targets(model, names)?,
160 TargetSelection::SystemIds(ids) => ids.clone(),
161 };
162
163 target_ids.retain(|id| *id != start_id);
165
166 if let Some(max) = query.max_targets {
168 target_ids.truncate(max);
169 }
170
171 let route = match query.algorithm {
173 RoutingAlgorithm::NearestNeighbor => {
174 model.tsp_nearest_neighbor(start_id, &target_ids, query.return_to_start)?
175 }
176 RoutingAlgorithm::TwoOpt => {
177 model.tsp_two_opt(start_id, &target_ids, query.return_to_start)?
178 }
179 };
180
181 let route = if let Some(warp_range) = query.warp_range {
183 model.constrain_hops(&route, warp_range)
184 } else {
185 route
186 };
187
188 let warp_jumps = query
190 .warp_range
191 .map(|wr| GalaxyModel::warp_jump_count(&route, wr));
192
193 let targets_visited = route.hops.iter().skip(1).filter(|h| !h.is_waypoint).count();
195
196 Ok(RouteResult {
197 route,
198 warp_range: query.warp_range,
199 warp_jumps,
200 algorithm: query.algorithm,
201 targets_visited,
202 })
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use nms_core::biome::Biome;
209
210 fn test_model() -> GalaxyModel {
211 let json = r#"{
212 "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
213 "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
214 "BaseContext": {
215 "GameMode": 1,
216 "PlayerStateData": {
217 "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
218 "Units": 0, "Nanites": 0, "Specials": 0,
219 "PersistentPlayerBases": [{"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": "Alpha Base", "BaseType": {"PersistentBaseTypes": "HomePlanetBase"}, "LastEditedById": "", "LastEditedByUsername": ""}]
220 }
221 },
222 "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": []}},
223 "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
224 {"DD": {"UA": "0x001000000064", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
225 {"DD": {"UA": "0x101000000064", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
226 {"DD": {"UA": "0x002000000C80", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
227 {"DD": {"UA": "0x102000000C80", "DT": "Planet", "VP": ["0xCD", 1]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
228 {"DD": {"UA": "0x003000001900", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
229 {"DD": {"UA": "0x103000001900", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
230 ]}}}
231 }"#;
232 nms_save::parse_save(json.as_bytes())
233 .map(|save| GalaxyModel::from_save(&save))
234 .unwrap()
235 }
236
237 #[test]
238 fn test_execute_route_with_system_ids() {
239 let model = test_model();
240 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
241 assert!(ids.len() >= 3, "Need at least 3 systems for routing");
242
243 let query = RouteQuery {
244 targets: TargetSelection::SystemIds(ids),
245 from: RouteFrom::CurrentPosition,
246 warp_range: None,
247 within_ly: None,
248 max_targets: None,
249 algorithm: RoutingAlgorithm::TwoOpt,
250 return_to_start: false,
251 };
252
253 let result = execute_route(&model, &query).unwrap();
254 assert!(!result.route.hops.is_empty());
255 assert!(result.targets_visited > 0);
256 }
257
258 #[test]
259 fn test_execute_route_from_base() {
260 let model = test_model();
261 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
262
263 let query = RouteQuery {
264 targets: TargetSelection::SystemIds(ids),
265 from: RouteFrom::Base("Alpha Base".into()),
266 warp_range: None,
267 within_ly: None,
268 max_targets: None,
269 algorithm: RoutingAlgorithm::NearestNeighbor,
270 return_to_start: false,
271 };
272
273 let result = execute_route(&model, &query);
274 assert!(result.is_ok());
275 }
276
277 #[test]
278 fn test_execute_route_from_nonexistent_base_errors() {
279 let model = test_model();
280 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
281
282 let query = RouteQuery {
283 targets: TargetSelection::SystemIds(ids),
284 from: RouteFrom::Base("No Such Base".into()),
285 warp_range: None,
286 within_ly: None,
287 max_targets: None,
288 algorithm: RoutingAlgorithm::TwoOpt,
289 return_to_start: false,
290 };
291
292 assert!(execute_route(&model, &query).is_err());
293 }
294
295 #[test]
296 fn test_execute_route_with_warp_range() {
297 let model = test_model();
298 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
299
300 let query = RouteQuery {
301 targets: TargetSelection::SystemIds(ids),
302 from: RouteFrom::CurrentPosition,
303 warp_range: Some(2500.0),
304 within_ly: None,
305 max_targets: None,
306 algorithm: RoutingAlgorithm::TwoOpt,
307 return_to_start: false,
308 };
309
310 let result = execute_route(&model, &query).unwrap();
311 assert!(result.warp_range.is_some());
312 assert!(result.warp_jumps.is_some());
313 }
314
315 #[test]
316 fn test_execute_route_round_trip() {
317 let model = test_model();
318 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
319
320 let query = RouteQuery {
321 targets: TargetSelection::SystemIds(ids),
322 from: RouteFrom::CurrentPosition,
323 warp_range: None,
324 within_ly: None,
325 max_targets: None,
326 algorithm: RoutingAlgorithm::TwoOpt,
327 return_to_start: true,
328 };
329
330 let result = execute_route(&model, &query).unwrap();
331 let first = result.route.hops.first().unwrap().system_id;
332 let last = result.route.hops.last().unwrap().system_id;
333 assert_eq!(first, last);
334 }
335
336 #[test]
337 fn test_execute_route_with_biome_filter() {
338 let model = test_model();
339
340 let filter = BiomeFilter {
341 biome: Some(Biome::Lush),
342 ..Default::default()
343 };
344
345 let query = RouteQuery {
346 targets: TargetSelection::Biome(filter),
347 from: RouteFrom::CurrentPosition,
348 warp_range: None,
349 within_ly: None,
350 max_targets: Some(5),
351 algorithm: RoutingAlgorithm::NearestNeighbor,
352 return_to_start: false,
353 };
354
355 let _ = execute_route(&model, &query);
358 }
359
360 #[test]
361 fn test_execute_route_max_targets_limit() {
362 let model = test_model();
363 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
364
365 let query = RouteQuery {
366 targets: TargetSelection::SystemIds(ids.clone()),
367 from: RouteFrom::CurrentPosition,
368 warp_range: None,
369 within_ly: None,
370 max_targets: Some(1),
371 algorithm: RoutingAlgorithm::TwoOpt,
372 return_to_start: false,
373 };
374
375 let result = execute_route(&model, &query).unwrap();
376 assert!(result.targets_visited <= 1);
378 }
379
380 #[test]
381 fn test_execute_route_from_explicit_address() {
382 let model = test_model();
383 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
384
385 let addr = GalacticAddress::new(0, 0, 0, 1, 0, 0);
386 let query = RouteQuery {
387 targets: TargetSelection::SystemIds(ids),
388 from: RouteFrom::Address(addr),
389 warp_range: None,
390 within_ly: None,
391 max_targets: None,
392 algorithm: RoutingAlgorithm::TwoOpt,
393 return_to_start: false,
394 };
395
396 assert!(execute_route(&model, &query).is_ok());
397 }
398
399 #[test]
400 fn test_execute_route_nearest_neighbor_algorithm() {
401 let model = test_model();
402 let ids: Vec<SystemId> = model.systems.keys().copied().collect();
403
404 let query = RouteQuery {
405 targets: TargetSelection::SystemIds(ids),
406 from: RouteFrom::CurrentPosition,
407 warp_range: None,
408 within_ly: None,
409 max_targets: None,
410 algorithm: RoutingAlgorithm::NearestNeighbor,
411 return_to_start: false,
412 };
413
414 let result = execute_route(&model, &query).unwrap();
415 assert!(matches!(
416 result.algorithm,
417 RoutingAlgorithm::NearestNeighbor
418 ));
419 }
420
421 #[test]
422 fn test_resolve_start_current_position() {
423 let model = test_model();
424 let addr = resolve_start(&model, &RouteFrom::CurrentPosition);
425 assert!(addr.is_ok());
426 }
427
428 #[test]
429 fn test_resolve_start_base() {
430 let model = test_model();
431 let addr = resolve_start(&model, &RouteFrom::Base("Alpha Base".into()));
432 assert!(addr.is_ok());
433 }
434
435 #[test]
436 fn test_resolve_start_base_not_found() {
437 let model = test_model();
438 let addr = resolve_start(&model, &RouteFrom::Base("No Such Base".into()));
439 assert!(addr.is_err());
440 }
441
442 #[test]
443 fn test_resolve_start_explicit_address() {
444 let model = test_model();
445 let ga = GalacticAddress::new(42, 10, -5, 0x100, 0, 0);
446 let addr = resolve_start(&model, &RouteFrom::Address(ga));
447 assert!(addr.is_ok());
448 assert_eq!(addr.unwrap(), ga);
449 }
450}