Skip to main content

nms_copilot/
dispatch.rs

1//! Command dispatch -- executes REPL commands against the loaded GalaxyModel.
2
3use 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
26/// Execute a parsed REPL action against the model, returning output text.
27pub 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    // 1. Determine targets: --target > --biome > session biome
159    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    // 2. Determine from: --from > session position > CurrentPosition
179    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    // 3. Determine warp_range: --warp-range > session warp_range > None
188    let effective_warp_range = (*warp_range).or(session.warp_range);
189
190    // 4. Parse algorithm
191    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    // 5. Build query and execute
202    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}