Skip to main content

nms_copilot/
dispatch.rs

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