1use nms_core::BaseType;
4use nms_core::address::{GalacticAddress, PortalAddress};
5use nms_core::biome::Biome;
6use nms_core::galaxy::{Galaxy, GalaxyType};
7use nms_graph::GalaxyModel;
8use nms_graph::query::BiomeFilter;
9use nms_graph::route::RoutingAlgorithm;
10use nms_query::display::{
11 format_find_results, format_route, format_show_result, format_stats, hex_to_emoji,
12};
13use nms_query::find::{FindQuery, ReferencePoint, execute_find};
14use nms_query::route::{RouteFrom, RouteQuery, TargetSelection, execute_route};
15use nms_query::show::{ShowQuery, execute_show};
16use nms_query::stats::{StatsQuery, execute_stats};
17use nms_query::table::{Builder, build_table, nms_theme};
18use nms_query::theme::Theme;
19
20use nms_core::biome::{ALL_BIOME_SUBTYPES, ALL_BIOMES};
21use nms_core::glyph::GLYPH_TABLE;
22
23use crate::commands::{Action, ListTarget, SetTarget, ShowTarget};
24use crate::session::SessionState;
25
26pub fn dispatch(
28 action: &Action,
29 model: &GalaxyModel,
30 session: &mut SessionState,
31) -> Result<String, String> {
32 match action {
33 Action::Find {
34 biome,
35 infested,
36 within,
37 nearest,
38 named,
39 discoverer,
40 from,
41 } => {
42 let biome = biome
43 .as_ref()
44 .map(|s| s.parse::<Biome>())
45 .transpose()
46 .map_err(|e| format!("Invalid biome: {e}"))?
47 .or(session.biome_filter);
48
49 let reference = match from {
50 Some(name) => ReferencePoint::Base(name.clone()),
51 None => ReferencePoint::CurrentPosition,
52 };
53
54 let query = FindQuery {
55 biome,
56 biome_subtype: None,
57 infested: if *infested { Some(true) } else { None },
58 within_ly: *within,
59 nearest: *nearest,
60 name_pattern: None,
61 discoverer: discoverer.clone(),
62 named_only: *named,
63 from: reference,
64 };
65
66 let results = execute_find(model, &query).map_err(|e| e.to_string())?;
67 let theme = Theme::default_dark();
68 Ok(format_find_results(&results, &theme))
69 }
70
71 Action::List { target } => dispatch_list(model, target),
72
73 Action::Show { target } => dispatch_show(model, target),
74
75 Action::Stats {
76 biomes,
77 discoveries,
78 } => {
79 let query = StatsQuery {
80 biomes: *biomes || !*discoveries,
81 discoveries: *discoveries || !*biomes,
82 };
83 let result = execute_stats(model, &query);
84 let theme = Theme::default_dark();
85 Ok(format_stats(&result, &theme))
86 }
87
88 Action::Route {
89 biome,
90 targets,
91 from,
92 warp_range,
93 within,
94 max_targets,
95 algo,
96 round_trip,
97 } => dispatch_route(
98 model,
99 session,
100 biome,
101 targets,
102 from,
103 warp_range,
104 within,
105 max_targets,
106 algo,
107 round_trip,
108 ),
109
110 Action::Set { target } => dispatch_set(model, session, target),
111 Action::Reset { target } => Ok(dispatch_reset(model, session, target)),
112 Action::Status => Ok(session.format_status()),
113
114 Action::Info => {
115 let systems = model.systems.len();
116 let planets = model.planets.len();
117 let bases = model.bases.len();
118 let pos = model
119 .player_state
120 .as_ref()
121 .map(|ps| format!("{}", ps.current_address))
122 .unwrap_or_else(|| "unknown".into());
123 Ok(format!(
124 "Loaded model: {systems} systems, {planets} planets, {bases} bases\n\
125 Current position: {pos}\n"
126 ))
127 }
128
129 Action::Help => Ok(help_text()),
130
131 Action::Map | Action::Exit | Action::Quit => Ok(String::new()),
132
133 Action::Convert {
134 glyphs,
135 coords,
136 ga,
137 voxel,
138 ssi,
139 planet,
140 galaxy,
141 } => dispatch_convert(glyphs, coords, ga, voxel, *ssi, *planet, galaxy),
142 }
143}
144
145#[allow(clippy::too_many_arguments)]
146fn dispatch_route(
147 model: &GalaxyModel,
148 session: &SessionState,
149 biome: &Option<String>,
150 targets: &[String],
151 from: &Option<String>,
152 warp_range: &Option<f64>,
153 within: &Option<f64>,
154 max_targets: &Option<usize>,
155 algo: &Option<String>,
156 round_trip: &bool,
157) -> Result<String, String> {
158 let target_selection = if !targets.is_empty() {
160 TargetSelection::Named(targets.to_vec())
161 } else {
162 let biome_val = biome
163 .as_ref()
164 .map(|s| s.parse::<Biome>())
165 .transpose()
166 .map_err(|e| format!("Invalid biome: {e}"))?
167 .or(session.biome_filter);
168
169 match biome_val {
170 Some(b) => TargetSelection::Biome(BiomeFilter {
171 biome: Some(b),
172 ..Default::default()
173 }),
174 None => return Err("Specify --target or --biome for route planning".into()),
175 }
176 };
177
178 let route_from = match from {
180 Some(name) => RouteFrom::Base(name.clone()),
181 None => match &session.position {
182 Some(pos) => RouteFrom::Address(*pos.address()),
183 None => RouteFrom::CurrentPosition,
184 },
185 };
186
187 let effective_warp_range = (*warp_range).or(session.warp_range);
189
190 let algorithm = match algo.as_deref() {
192 Some("nn") | Some("nearest-neighbor") => RoutingAlgorithm::NearestNeighbor,
193 Some("2opt") | Some("two-opt") | None => RoutingAlgorithm::TwoOpt,
194 Some(other) => {
195 return Err(format!(
196 "Unknown algorithm: \"{other}\". Use: nn, nearest-neighbor, 2opt, two-opt"
197 ));
198 }
199 };
200
201 let query = RouteQuery {
203 targets: target_selection,
204 from: route_from,
205 warp_range: effective_warp_range,
206 within_ly: *within,
207 max_targets: *max_targets,
208 algorithm,
209 return_to_start: *round_trip,
210 };
211
212 let result = execute_route(model, &query).map_err(|e| e.to_string())?;
213 let theme = Theme::default_dark();
214 Ok(format_route(&result, model, &theme))
215}
216
217fn dispatch_list(model: &GalaxyModel, target: &ListTarget) -> Result<String, String> {
218 let theme = nms_theme();
219
220 match target {
221 ListTarget::Galaxies { galaxy_type } => {
222 let type_filter = galaxy_type
223 .as_ref()
224 .map(|t| {
225 t.parse::<GalaxyType>()
226 .map_err(|e| format!("Invalid galaxy type: {e}"))
227 })
228 .transpose()?;
229
230 let mut builder = Builder::default();
231 builder.push_record(["Index", "Name", "Type"]);
232
233 for i in 0..=255u8 {
234 let g = Galaxy::by_index(i);
235 if let Some(ref tf) = type_filter
236 && g.galaxy_type != *tf
237 {
238 continue;
239 }
240 builder.push_record([
241 g.index.to_string(),
242 g.name.to_string(),
243 g.galaxy_type.to_string(),
244 ]);
245 }
246 builder.push_record(["", "", ""]);
247 Ok(build_table(builder, &["GALAXIES"], &theme, "Galaxies"))
248 }
249
250 ListTarget::Biomes => {
251 let mut builder = Builder::default();
252 builder.push_record(["Name", "Variants"]);
253 for biome in ALL_BIOMES {
254 let biome_name = biome.to_string();
255 let variants: Vec<String> = ALL_BIOME_SUBTYPES
256 .iter()
257 .filter_map(|sub| {
258 let sub_name = format!("{sub:?}");
259 sub_name
260 .strip_prefix(&biome_name)
261 .map(|suffix| suffix.to_string())
262 })
263 .collect();
264 let variants_str = if variants.is_empty() {
265 "—".to_string()
266 } else {
267 variants.join(", ")
268 };
269 builder.push_record([biome_name, variants_str]);
270 }
271 builder.push_record(["".to_string(), "".to_string()]);
272 Ok(build_table(builder, &["BIOMES"], &theme, "Biomes"))
273 }
274
275 ListTarget::Glyphs => {
276 let mut builder = Builder::default();
277 builder.push_record(["Hex", "Name", "Abbreviation", "Emoji Approximation"]);
278 for info in &GLYPH_TABLE {
279 builder.push_record([
280 info.hex_char.to_string(),
281 info.name.to_string(),
282 info.abbrev.to_string(),
283 info.emoji.to_string(),
284 ]);
285 }
286 builder.push_record(["", "", "", ""]);
287 Ok(build_table(
288 builder,
289 &["PORTAL", "GLYPHS"],
290 &theme,
291 "Glyphs",
292 ))
293 }
294
295 ListTarget::Bases { limit, all } => {
296 if model.bases.is_empty() {
297 return Ok(" No bases found.\n".into());
298 }
299 let mut bases: Vec<_> = model.bases.values().collect();
300 bases.sort_by_cached_key(|base| base.name.to_lowercase());
301
302 let total = bases.len();
303 let effective_limit = if *all || *limit == 0 { total } else { *limit };
304 let showing = total.min(effective_limit);
305
306 let mut builder = Builder::default();
307 builder.push_record(["Name", "Type", "Galaxy", "Address", "Portal Glyphs"]);
308
309 for base in bases.iter().take(effective_limit) {
310 let galaxy = Galaxy::by_index(base.address.reality_index);
311 let hex = format!("{:012X}", base.address.packed());
312 let glyphs = hex_to_emoji(&hex);
313 builder.push_record([
314 base.name.clone(),
315 base_type_label(&base.base_type).to_string(),
316 galaxy.name.to_string(),
317 hex,
318 glyphs,
319 ]);
320 }
321 builder.push_record(["", "", "", "", ""]);
322
323 let mut out = build_table(builder, &["BASES"], &theme, "Bases");
324 if showing < total {
325 out.push_str(&format!(
326 "\n Showing {showing} of {total} bases (use --all to show all)"
327 ));
328 }
329 Ok(out)
330 }
331
332 ListTarget::TerrainTypes => {
333 let terrain_types: &[(u8, &str, &str)] = &[
334 (0, "None", "No specific terrain type"),
335 (1, "Standard", "Default terrain generation"),
336 (2, "HighQuality", "Enhanced terrain detail"),
337 (3, "Structure", "Structural formations"),
338 (4, "Beam", "Beam-shaped formations"),
339 (5, "Hexagon", "Hexagonal terrain patterns"),
340 (6, "FractCube", "Fractal cube formations"),
341 (7, "Bubble", "Bubble-shaped terrain"),
342 (8, "Shards", "Shard crystal formations"),
343 (9, "Contour", "Contoured terrain features"),
344 (10, "Shell", "Shell-shaped formations"),
345 (11, "BoneSpire", "Bone spire formations"),
346 (12, "WireCell", "Wire cell structures"),
347 (13, "HydroGarden", "Hydroponic garden terrain"),
348 (14, "HugePlant", "Giant plant formations"),
349 (15, "HugeLush", "Giant lush vegetation"),
350 (16, "HugeRing", "Giant ring formations"),
351 (17, "HugeRock", "Giant rock formations"),
352 (18, "HugeScorch", "Giant scorched formations"),
353 (19, "HugeToxic", "Giant toxic formations"),
354 (20, "Variant_A", "Terrain variant A"),
355 (21, "Variant_B", "Terrain variant B"),
356 (22, "Variant_C", "Terrain variant C"),
357 (23, "Variant_D", "Terrain variant D"),
358 (24, "Infested", "Infested terrain generation"),
359 (25, "Swamp", "Swamp terrain generation"),
360 (26, "Lava", "Volcanic lava terrain"),
361 (27, "Worlds", "Worlds terrain generation"),
362 (28, "Remix_A", "Terrain remix A"),
363 (29, "Remix_B", "Terrain remix B"),
364 (30, "Remix_C", "Terrain remix C"),
365 (31, "Remix_D", "Terrain remix D"),
366 ];
367
368 let mut builder = Builder::default();
369 builder.push_record(["Index", "Name", "Description"]);
370 for (idx, name, desc) in terrain_types {
371 builder.push_record([idx.to_string(), name.to_string(), desc.to_string()]);
372 }
373 builder.push_record(["", "", ""]);
374 Ok(build_table(builder, &["TERRAIN", "TYPES"], &theme, "Types"))
375 }
376
377 ListTarget::Systems { limit, all } => {
378 if model.systems.is_empty() {
379 return Ok(" No systems found.\n".into());
380 }
381 let mut systems: Vec<_> = model.systems.values().collect();
382 systems.sort_by(|a, b| {
383 let a_name = a.name.as_deref().unwrap_or("");
384 let b_name = b.name.as_deref().unwrap_or("");
385 match (a_name.is_empty(), b_name.is_empty()) {
386 (true, false) => std::cmp::Ordering::Greater,
387 (false, true) => std::cmp::Ordering::Less,
388 _ => a_name.to_lowercase().cmp(&b_name.to_lowercase()),
389 }
390 });
391
392 let total = systems.len();
393 let effective_limit = if *all || *limit == 0 { total } else { *limit };
394 let showing = total.min(effective_limit);
395
396 let mut builder = Builder::default();
397 builder.push_record(["Name", "Discovered Planets", "Address", "Portal Glyphs"]);
398
399 for sys in systems.iter().take(effective_limit) {
400 let name = sys.name.as_deref().unwrap_or("-");
401 let planet_count = sys.planets.len();
402 let hex = format!("{:012X}", sys.address.packed());
403 let glyphs = hex_to_emoji(&hex);
404 builder.push_record([name.to_string(), planet_count.to_string(), hex, glyphs]);
405 }
406 builder.push_record(["", "", "", ""]);
407
408 let mut out = build_table(builder, &["SYSTEMS"], &theme, "Systems");
409 if showing < total {
410 out.push_str(&format!(
411 "\n Showing {showing} of {total} systems (use --all to show all)"
412 ));
413 }
414 Ok(out)
415 }
416 }
417}
418
419fn dispatch_show(model: &GalaxyModel, target: &ShowTarget) -> Result<String, String> {
420 let query = match target {
421 ShowTarget::System { name } => ShowQuery::System(name.clone()),
422 ShowTarget::Base { name } => ShowQuery::Base(name.clone()),
423 };
424 let result = execute_show(model, &query).map_err(|e| e.to_string())?;
425 let theme = Theme::default_dark();
426 Ok(format_show_result(&result, &theme))
427}
428
429fn dispatch_set(
430 model: &GalaxyModel,
431 session: &mut SessionState,
432 target: &SetTarget,
433) -> Result<String, String> {
434 match target {
435 SetTarget::Position { name } => session.set_position_base(name, model),
436 SetTarget::Biome { name } => {
437 let biome: Biome = name.parse().map_err(|e| format!("Invalid biome: {e}"))?;
438 Ok(session.set_biome_filter(biome))
439 }
440 SetTarget::WarpRange { ly } => Ok(session.set_warp_range(*ly)),
441 }
442}
443
444fn dispatch_reset(model: &GalaxyModel, session: &mut SessionState, target: &str) -> String {
445 match target.to_lowercase().as_str() {
446 "position" | "pos" => session.reset_position(model),
447 "biome" => session.clear_biome_filter().into(),
448 "warp-range" | "warp" => session.clear_warp_range().into(),
449 "all" | "" => session.reset_all(model).into(),
450 other => format!("Unknown reset target: {other}. Use: position, biome, warp-range, all"),
451 }
452}
453
454fn dispatch_convert(
455 glyphs: &Option<String>,
456 coords: &Option<String>,
457 ga: &Option<String>,
458 voxel: &Option<String>,
459 ssi: Option<u16>,
460 planet: u8,
461 galaxy: &str,
462) -> Result<String, String> {
463 let reality_index = resolve_galaxy(galaxy)?;
464
465 let addr = if let Some(g) = glyphs {
466 parse_glyphs(g, reality_index)?
467 } else if let Some(c) = coords {
468 GalacticAddress::from_signal_booster(c.trim(), planet, reality_index)
469 .map_err(|e| format!("Invalid coordinates: {e}"))?
470 } else if let Some(a) = ga {
471 parse_glyphs(a, reality_index)?
472 } else if let Some(v) = voxel {
473 let solar_system_index = ssi.ok_or("--ssi is required when using --voxel")?;
474 parse_voxel(v, solar_system_index, planet, reality_index)?
475 } else {
476 return Err("Specify --glyphs, --coords, --ga, or --voxel".into());
477 };
478
479 Ok(format_all_formats(&addr))
480}
481
482fn parse_glyphs(input: &str, reality_index: u8) -> Result<GalacticAddress, String> {
483 let trimmed = input.trim();
484 let trimmed = trimmed
485 .strip_prefix("0x")
486 .or_else(|| trimmed.strip_prefix("0X"))
487 .unwrap_or(trimmed);
488
489 let portal =
490 PortalAddress::parse_mixed(trimmed).map_err(|e| format!("Invalid portal glyphs: {e}"))?;
491
492 let ga = portal.to_galactic_address();
493 Ok(GalacticAddress::from_packed(ga.packed(), reality_index))
494}
495
496fn parse_voxel(
497 input: &str,
498 solar_system_index: u16,
499 planet_index: u8,
500 reality_index: u8,
501) -> Result<GalacticAddress, String> {
502 let parts: Vec<&str> = input.trim().split(',').collect();
503 if parts.len() != 3 {
504 return Err(format!(
505 "Voxel position must be X,Y,Z (3 comma-separated integers), got \"{input}\""
506 ));
507 }
508
509 let x: i16 = parts[0]
510 .trim()
511 .parse()
512 .map_err(|_| format!("Invalid voxel X: \"{}\"", parts[0].trim()))?;
513 let y: i8 = parts[1]
514 .trim()
515 .parse()
516 .map_err(|_| format!("Invalid voxel Y: \"{}\"", parts[1].trim()))?;
517 let z: i16 = parts[2]
518 .trim()
519 .parse()
520 .map_err(|_| format!("Invalid voxel Z: \"{}\"", parts[2].trim()))?;
521
522 Ok(GalacticAddress::new(
523 x,
524 y,
525 z,
526 solar_system_index,
527 planet_index,
528 reality_index,
529 ))
530}
531
532fn resolve_galaxy(input: &str) -> Result<u8, String> {
533 let trimmed = input.trim();
534
535 if let Ok(idx) = trimmed.parse::<u16>() {
536 if idx > 255 {
537 return Err(format!("Galaxy index out of range: {idx} (must be 0-255)"));
538 }
539 return Ok(idx as u8);
540 }
541
542 let lower = trimmed.to_lowercase();
543 for i in 0..=255u8 {
544 let galaxy = Galaxy::by_index(i);
545 if galaxy.name.to_lowercase() == lower {
546 return Ok(i);
547 }
548 }
549
550 Err(format!(
551 "Unknown galaxy: \"{trimmed}\". Use a number 0-255 or a name like \"Euclid\"."
552 ))
553}
554
555fn base_type_label(bt: &BaseType) -> &'static str {
556 match bt {
557 BaseType::HomePlanetBase => "home",
558 BaseType::FreighterBase => "freighter",
559 BaseType::ExternalPlanetBase => "external",
560 _ => "unknown",
561 }
562}
563
564fn format_all_formats(addr: &GalacticAddress) -> String {
565 let galaxy = Galaxy::by_index(addr.reality_index);
566 let portal = addr.to_portal_address();
567 let theme = nms_theme();
568
569 let mut builder = Builder::default();
570 builder.push_record(["Format", "Value"]);
571 builder.push_record(["Portal Glyphs", &portal.to_emoji_string()]);
572 builder.push_record(["Hex Glyphs", &format!("{:012X}", addr.packed())]);
573 builder.push_record(["Abbreviated", &portal.to_abbrev_string()]);
574 builder.push_record(["Signal Booster", &addr.to_signal_booster()]);
575 builder.push_record(["Galactic Address", &format!("0x{:012X}", addr.packed())]);
576 builder.push_record([
577 "Voxel Position",
578 &format!(
579 "X={}, Y={}, Z={}",
580 addr.voxel_x(),
581 addr.voxel_y(),
582 addr.voxel_z()
583 ),
584 ]);
585 builder.push_record([
586 "System Index",
587 &format!(
588 "{} (0x{:03X})",
589 addr.solar_system_index(),
590 addr.solar_system_index()
591 ),
592 ]);
593 builder.push_record(["Planet Index", &addr.planet_index().to_string()]);
594 builder.push_record([
595 "Galaxy",
596 &format!("{} ({})", galaxy.name, addr.reality_index),
597 ]);
598 builder.push_record(["", ""]);
599
600 build_table(
601 builder,
602 &["COORDINATE", "CONVERSIONS"],
603 &theme,
604 "Conversions",
605 )
606}
607
608fn help_text() -> String {
609 "\
610NMS Copilot -- Interactive Galaxy Explorer
611
612Commands:
613 find Search planets by biome, distance, name
614 list List galaxies, biomes, glyphs, bases, systems, terrain-types
615 map Open interactive galaxy map
616 route Plan a route through discovered systems
617 show Show system or base details
618 stats Display aggregate galaxy statistics
619 convert Convert between coordinate formats
620 set Set session context (position, biome, warp-range)
621 reset Reset session state (position, biome, warp-range, all)
622 status Show current session state
623 info Show loaded model summary
624 help Show this help message
625 exit/quit Exit the REPL
626
627Live updates are shown between commands when file watching is enabled.
628
629Examples:
630 find --biome Lush --nearest 5
631 route --biome Lush --warp-range 2500
632 route --target \"Alpha Base\" --target \"Beta Base\"
633 show system 0x050003AB8C07
634 show base \"Acadia National Park\"
635 stats --biomes
636 convert --glyphs 01717D8A4EA2
637 set biome Lush
638 set position \"Home Base\"
639 set warp-range 2500
640 reset biome
641 status
642"
643 .into()
644}