1use nms_core::address::GalacticAddress;
4use nms_core::biome::Biome;
5use nms_core::galaxy::Galaxy;
6use nms_graph::GalaxyModel;
7use nms_graph::query::BiomeFilter;
8use nms_graph::route::RoutingAlgorithm;
9use nms_query::display::{format_find_results, format_route, format_show_result, format_stats};
10use nms_query::find::{FindQuery, ReferencePoint, execute_find};
11use nms_query::route::{RouteFrom, RouteQuery, TargetSelection, execute_route};
12use nms_query::show::{ShowQuery, execute_show};
13use nms_query::stats::{StatsQuery, execute_stats};
14
15use crate::commands::{Action, SetTarget, ShowTarget};
16use crate::session::SessionState;
17
18pub fn dispatch(
20 action: &Action,
21 model: &GalaxyModel,
22 session: &mut SessionState,
23) -> Result<String, String> {
24 match action {
25 Action::Find {
26 biome,
27 infested,
28 within,
29 nearest,
30 named,
31 discoverer,
32 from,
33 } => {
34 let biome = biome
35 .as_ref()
36 .map(|s| s.parse::<Biome>())
37 .transpose()
38 .map_err(|e| format!("Invalid biome: {e}"))?
39 .or(session.biome_filter);
40
41 let reference = match from {
42 Some(name) => ReferencePoint::Base(name.clone()),
43 None => ReferencePoint::CurrentPosition,
44 };
45
46 let query = FindQuery {
47 biome,
48 infested: if *infested { Some(true) } else { None },
49 within_ly: *within,
50 nearest: *nearest,
51 name_pattern: None,
52 discoverer: discoverer.clone(),
53 named_only: *named,
54 from: reference,
55 };
56
57 let results = execute_find(model, &query).map_err(|e| e.to_string())?;
58 Ok(format_find_results(&results))
59 }
60
61 Action::Show { target } => dispatch_show(model, target),
62
63 Action::Stats {
64 biomes,
65 discoveries,
66 } => {
67 let query = StatsQuery {
68 biomes: *biomes || !*discoveries,
69 discoveries: *discoveries || !*biomes,
70 };
71 let result = execute_stats(model, &query);
72 Ok(format_stats(&result))
73 }
74
75 Action::Route {
76 biome,
77 targets,
78 from,
79 warp_range,
80 within,
81 max_targets,
82 algo,
83 round_trip,
84 } => dispatch_route(
85 model,
86 session,
87 biome,
88 targets,
89 from,
90 warp_range,
91 within,
92 max_targets,
93 algo,
94 round_trip,
95 ),
96
97 Action::Set { target } => dispatch_set(model, session, target),
98 Action::Reset { target } => Ok(dispatch_reset(model, session, target)),
99 Action::Status => Ok(session.format_status()),
100
101 Action::Info => {
102 let systems = model.systems.len();
103 let planets = model.planets.len();
104 let bases = model.bases.len();
105 let pos = model
106 .player_state
107 .as_ref()
108 .map(|ps| format!("{}", ps.current_address))
109 .unwrap_or_else(|| "unknown".into());
110 Ok(format!(
111 "Loaded model: {systems} systems, {planets} planets, {bases} bases\n\
112 Current position: {pos}\n"
113 ))
114 }
115
116 Action::Help => Ok(help_text()),
117
118 Action::Exit | Action::Quit => Ok(String::new()),
119
120 Action::Convert {
121 glyphs,
122 coords,
123 ga,
124 voxel,
125 ssi,
126 planet,
127 galaxy,
128 } => dispatch_convert(glyphs, coords, ga, voxel, *ssi, *planet, galaxy),
129 }
130}
131
132#[allow(clippy::too_many_arguments)]
133fn dispatch_route(
134 model: &GalaxyModel,
135 session: &SessionState,
136 biome: &Option<String>,
137 targets: &[String],
138 from: &Option<String>,
139 warp_range: &Option<f64>,
140 within: &Option<f64>,
141 max_targets: &Option<usize>,
142 algo: &Option<String>,
143 round_trip: &bool,
144) -> Result<String, String> {
145 let target_selection = if !targets.is_empty() {
147 TargetSelection::Named(targets.to_vec())
148 } else {
149 let biome_val = biome
150 .as_ref()
151 .map(|s| s.parse::<Biome>())
152 .transpose()
153 .map_err(|e| format!("Invalid biome: {e}"))?
154 .or(session.biome_filter);
155
156 match biome_val {
157 Some(b) => TargetSelection::Biome(BiomeFilter {
158 biome: Some(b),
159 ..Default::default()
160 }),
161 None => return Err("Specify --target or --biome for route planning".into()),
162 }
163 };
164
165 let route_from = match from {
167 Some(name) => RouteFrom::Base(name.clone()),
168 None => match &session.position {
169 Some(pos) => RouteFrom::Address(*pos.address()),
170 None => RouteFrom::CurrentPosition,
171 },
172 };
173
174 let effective_warp_range = (*warp_range).or(session.warp_range);
176
177 let algorithm = match algo.as_deref() {
179 Some("nn") | Some("nearest-neighbor") => RoutingAlgorithm::NearestNeighbor,
180 Some("2opt") | Some("two-opt") | None => RoutingAlgorithm::TwoOpt,
181 Some(other) => {
182 return Err(format!(
183 "Unknown algorithm: \"{other}\". Use: nn, nearest-neighbor, 2opt, two-opt"
184 ));
185 }
186 };
187
188 let query = RouteQuery {
190 targets: target_selection,
191 from: route_from,
192 warp_range: effective_warp_range,
193 within_ly: *within,
194 max_targets: *max_targets,
195 algorithm,
196 return_to_start: *round_trip,
197 };
198
199 let result = execute_route(model, &query).map_err(|e| e.to_string())?;
200 Ok(format_route(&result, model))
201}
202
203fn dispatch_show(model: &GalaxyModel, target: &ShowTarget) -> Result<String, String> {
204 let query = match target {
205 ShowTarget::System { name } => ShowQuery::System(name.clone()),
206 ShowTarget::Base { name } => ShowQuery::Base(name.clone()),
207 };
208 let result = execute_show(model, &query).map_err(|e| e.to_string())?;
209 Ok(format_show_result(&result))
210}
211
212fn dispatch_set(
213 model: &GalaxyModel,
214 session: &mut SessionState,
215 target: &SetTarget,
216) -> Result<String, String> {
217 match target {
218 SetTarget::Position { name } => session.set_position_base(name, model),
219 SetTarget::Biome { name } => {
220 let biome: Biome = name.parse().map_err(|e| format!("Invalid biome: {e}"))?;
221 Ok(session.set_biome_filter(biome))
222 }
223 SetTarget::WarpRange { ly } => Ok(session.set_warp_range(*ly)),
224 }
225}
226
227fn dispatch_reset(model: &GalaxyModel, session: &mut SessionState, target: &str) -> String {
228 match target.to_lowercase().as_str() {
229 "position" | "pos" => session.reset_position(model),
230 "biome" => session.clear_biome_filter().into(),
231 "warp-range" | "warp" => session.clear_warp_range().into(),
232 "all" | "" => session.reset_all(model).into(),
233 other => format!("Unknown reset target: {other}. Use: position, biome, warp-range, all"),
234 }
235}
236
237fn dispatch_convert(
238 glyphs: &Option<String>,
239 coords: &Option<String>,
240 ga: &Option<String>,
241 voxel: &Option<String>,
242 ssi: Option<u16>,
243 planet: u8,
244 galaxy: &str,
245) -> Result<String, String> {
246 let reality_index = resolve_galaxy(galaxy)?;
247
248 let addr = if let Some(g) = glyphs {
249 parse_glyphs(g, reality_index)?
250 } else if let Some(c) = coords {
251 GalacticAddress::from_signal_booster(c.trim(), planet, reality_index)
252 .map_err(|e| format!("Invalid coordinates: {e}"))?
253 } else if let Some(a) = ga {
254 parse_glyphs(a, reality_index)?
255 } else if let Some(v) = voxel {
256 let solar_system_index = ssi.ok_or("--ssi is required when using --voxel")?;
257 parse_voxel(v, solar_system_index, planet, reality_index)?
258 } else {
259 return Err("Specify --glyphs, --coords, --ga, or --voxel".into());
260 };
261
262 Ok(format_all_formats(&addr))
263}
264
265fn parse_glyphs(input: &str, reality_index: u8) -> Result<GalacticAddress, String> {
266 let hex = input.trim();
267 let hex = hex
268 .strip_prefix("0x")
269 .or_else(|| hex.strip_prefix("0X"))
270 .unwrap_or(hex);
271
272 if hex.len() != 12 {
273 return Err(format!(
274 "Portal glyphs must be exactly 12 hex digits, got {} (\"{hex}\")",
275 hex.len(),
276 ));
277 }
278
279 let packed =
280 u64::from_str_radix(hex, 16).map_err(|_| format!("Invalid hex in glyphs: \"{hex}\""))?;
281
282 Ok(GalacticAddress::from_packed(packed, reality_index))
283}
284
285fn parse_voxel(
286 input: &str,
287 solar_system_index: u16,
288 planet_index: u8,
289 reality_index: u8,
290) -> Result<GalacticAddress, String> {
291 let parts: Vec<&str> = input.trim().split(',').collect();
292 if parts.len() != 3 {
293 return Err(format!(
294 "Voxel position must be X,Y,Z (3 comma-separated integers), got \"{input}\""
295 ));
296 }
297
298 let x: i16 = parts[0]
299 .trim()
300 .parse()
301 .map_err(|_| format!("Invalid voxel X: \"{}\"", parts[0].trim()))?;
302 let y: i8 = parts[1]
303 .trim()
304 .parse()
305 .map_err(|_| format!("Invalid voxel Y: \"{}\"", parts[1].trim()))?;
306 let z: i16 = parts[2]
307 .trim()
308 .parse()
309 .map_err(|_| format!("Invalid voxel Z: \"{}\"", parts[2].trim()))?;
310
311 Ok(GalacticAddress::new(
312 x,
313 y,
314 z,
315 solar_system_index,
316 planet_index,
317 reality_index,
318 ))
319}
320
321fn resolve_galaxy(input: &str) -> Result<u8, String> {
322 let trimmed = input.trim();
323
324 if let Ok(idx) = trimmed.parse::<u16>() {
325 if idx > 255 {
326 return Err(format!("Galaxy index out of range: {idx} (must be 0-255)"));
327 }
328 return Ok(idx as u8);
329 }
330
331 let lower = trimmed.to_lowercase();
332 for i in 0..=255u8 {
333 let galaxy = Galaxy::by_index(i);
334 if galaxy.name.to_lowercase() == lower {
335 return Ok(i);
336 }
337 }
338
339 Err(format!(
340 "Unknown galaxy: \"{trimmed}\". Use a number 0-255 or a name like \"Euclid\"."
341 ))
342}
343
344fn format_all_formats(addr: &GalacticAddress) -> String {
345 let galaxy = Galaxy::by_index(addr.reality_index);
346
347 format!(
348 "NMS Copilot -- Coordinate Conversion\n\
349 =====================================\n\
350 \n\
351 \x20 Portal Glyphs: {:012X}\n\
352 \x20 Signal Booster: {}\n\
353 \x20 Galactic Address: 0x{:012X}\n\
354 \x20 Voxel Position: X={}, Y={}, Z={}\n\
355 \x20 System Index: {} (0x{:03X})\n\
356 \x20 Planet Index: {}\n\
357 \x20 Galaxy: {} ({})\n",
358 addr.packed(),
359 addr.to_signal_booster(),
360 addr.packed(),
361 addr.voxel_x(),
362 addr.voxel_y(),
363 addr.voxel_z(),
364 addr.solar_system_index(),
365 addr.solar_system_index(),
366 addr.planet_index(),
367 galaxy.name,
368 addr.reality_index,
369 )
370}
371
372fn help_text() -> String {
373 "\
374NMS Copilot -- Interactive Galaxy Explorer
375
376Commands:
377 find Search planets by biome, distance, name
378 route Plan a route through discovered systems
379 show Show system or base details
380 stats Display aggregate galaxy statistics
381 convert Convert between coordinate formats
382 set Set session context (position, biome, warp-range)
383 reset Reset session state (position, biome, warp-range, all)
384 status Show current session state
385 info Show loaded model summary
386 help Show this help message
387 exit/quit Exit the REPL
388
389Examples:
390 find --biome Lush --nearest 5
391 route --biome Lush --warp-range 2500
392 route --target \"Alpha Base\" --target \"Beta Base\"
393 show system 0x050003AB8C07
394 show base \"Acadia National Park\"
395 stats --biomes
396 convert --glyphs 01717D8A4EA2
397 set biome Lush
398 set position \"Home Base\"
399 set warp-range 2500
400 reset biome
401 status
402"
403 .into()
404}