Skip to main content

nms_copilot/mcp/
tools.rs

1//! NMS Copilot MCP tools.
2//!
3//! Each tool wraps a function from `nms-query`, translating between
4//! JSON tool arguments and typed query structs.
5
6use std::sync::Arc;
7
8use fabryk_mcp::model::{CallToolResult, Content, ErrorData, Tool};
9use fabryk_mcp::{ToolRegistry, ToolResult, empty_input_schema};
10use serde_json::{Value, json};
11use tokio::sync::RwLock;
12
13use nms_core::address::GalacticAddress;
14use nms_core::biome::Biome;
15use nms_core::galaxy::Galaxy;
16use nms_graph::BiomeFilter;
17use nms_graph::GalaxyModel;
18use nms_graph::RoutingAlgorithm;
19use nms_query::display::{format_distance, hex_to_emoji};
20use nms_query::find::{FindQuery, ReferencePoint, execute_find};
21use nms_query::route::{RouteFrom, RouteQuery, TargetSelection, execute_route};
22use nms_query::show::{ShowQuery, ShowResult, execute_show};
23use nms_query::stats::{StatsQuery, execute_stats};
24
25/// All NMS tools backed by a shared GalaxyModel.
26///
27/// Uses `RwLock` to support live updates from the file watcher.
28/// Tool handlers acquire a read lock; the watcher takes a write lock
29/// to apply deltas.
30pub struct NmsTools {
31    model: Arc<RwLock<GalaxyModel>>,
32}
33
34impl NmsTools {
35    pub fn new(model: Arc<RwLock<GalaxyModel>>) -> Self {
36        Self { model }
37    }
38}
39
40impl ToolRegistry for NmsTools {
41    fn tools(&self) -> Vec<Tool> {
42        vec![
43            search_planets_tool(),
44            plan_route_tool(),
45            where_am_i_tool(),
46            whats_nearby_tool(),
47            show_system_tool(),
48            show_base_tool(),
49            convert_coordinates_tool(),
50            galaxy_stats_tool(),
51        ]
52    }
53
54    fn call(&self, name: &str, args: Value) -> Option<ToolResult> {
55        let model = Arc::clone(&self.model);
56        match name {
57            "search_planets" => Some(Box::pin(handle_search_planets(model, args))),
58            "plan_route" => Some(Box::pin(handle_plan_route(model, args))),
59            "where_am_i" => Some(Box::pin(handle_where_am_i(model, args))),
60            "whats_nearby" => Some(Box::pin(handle_whats_nearby(model, args))),
61            "show_system" => Some(Box::pin(handle_show_system(model, args))),
62            "show_base" => Some(Box::pin(handle_show_base(model, args))),
63            "convert_coordinates" => Some(Box::pin(handle_convert(model, args))),
64            "galaxy_stats" => Some(Box::pin(handle_galaxy_stats(model, args))),
65            _ => None,
66        }
67    }
68}
69
70// ── Tool Definitions ────────────────────────────────────────────
71
72fn schema(json: Value) -> Arc<serde_json::Map<String, Value>> {
73    match json {
74        Value::Object(map) => Arc::new(map),
75        _ => unreachable!("schema must be a JSON object"),
76    }
77}
78
79fn search_planets_tool() -> Tool {
80    Tool::new(
81        "search_planets",
82        "Search planets by biome, distance, discoverer, or name.",
83        schema(json!({
84            "type": "object",
85            "properties": {
86                "biome": {
87                    "type": "string",
88                    "description": "Biome type (Lush, Toxic, Scorched, Radioactive, Frozen, Barren, Dead, Weird, Swamp, Lava, etc.)"
89                },
90                "within_ly": {
91                    "type": "number",
92                    "description": "Maximum distance in light-years from reference point"
93                },
94                "nearest": {
95                    "type": "integer",
96                    "description": "Return only the N nearest results"
97                },
98                "discoverer": {
99                    "type": "string",
100                    "description": "Filter by discoverer username (substring match)"
101                },
102                "named_only": {
103                    "type": "boolean",
104                    "description": "Only include named planets/systems"
105                },
106                "from_base": {
107                    "type": "string",
108                    "description": "Measure distance from this base name (default: player position)"
109                },
110                "infested": {
111                    "type": "boolean",
112                    "description": "Only include infested planets"
113                }
114            }
115        })),
116    )
117}
118
119fn plan_route_tool() -> Tool {
120    Tool::new(
121        "plan_route",
122        "Plan an optimal route through target systems.",
123        schema(json!({
124            "type": "object",
125            "properties": {
126                "biome": {
127                    "type": "string",
128                    "description": "Visit all systems with this biome type"
129                },
130                "targets": {
131                    "type": "array",
132                    "items": { "type": "string" },
133                    "description": "Specific system or base names to visit"
134                },
135                "from_base": {
136                    "type": "string",
137                    "description": "Start from this base (default: player position)"
138                },
139                "warp_range": {
140                    "type": "number",
141                    "description": "Maximum warp range per hop in light-years"
142                },
143                "within_ly": {
144                    "type": "number",
145                    "description": "Only include targets within this radius"
146                },
147                "max_targets": {
148                    "type": "integer",
149                    "description": "Maximum number of targets to include"
150                },
151                "algorithm": {
152                    "type": "string",
153                    "enum": ["2opt", "nearest-neighbor"],
154                    "description": "Routing algorithm (default: 2opt)"
155                },
156                "round_trip": {
157                    "type": "boolean",
158                    "description": "Return to starting system after visiting all targets"
159                }
160            }
161        })),
162    )
163}
164
165fn where_am_i_tool() -> Tool {
166    Tool::new(
167        "where_am_i",
168        "Get the player's current location.",
169        Arc::new(empty_input_schema()),
170    )
171}
172
173fn whats_nearby_tool() -> Tool {
174    Tool::new(
175        "whats_nearby",
176        "Find systems and planets near the player's current position.",
177        schema(json!({
178            "type": "object",
179            "properties": {
180                "count": {
181                    "type": "integer",
182                    "description": "Number of nearby results to return (default: 10)"
183                },
184                "biome": {
185                    "type": "string",
186                    "description": "Filter by biome type"
187                }
188            }
189        })),
190    )
191}
192
193fn show_system_tool() -> Tool {
194    Tool::new(
195        "show_system",
196        "Get detailed information about a star system.",
197        schema(json!({
198            "type": "object",
199            "properties": {
200                "name": {
201                    "type": "string",
202                    "description": "System name or hex address"
203                }
204            },
205            "required": ["name"]
206        })),
207    )
208}
209
210fn show_base_tool() -> Tool {
211    Tool::new(
212        "show_base",
213        "Get detailed information about a player base.",
214        schema(json!({
215            "type": "object",
216            "properties": {
217                "name": {
218                    "type": "string",
219                    "description": "Base name (case-insensitive)"
220                }
221            },
222            "required": ["name"]
223        })),
224    )
225}
226
227fn convert_coordinates_tool() -> Tool {
228    Tool::new(
229        "convert_coordinates",
230        "Convert between portal glyphs, signal booster coordinates, and galactic addresses.",
231        schema(json!({
232            "type": "object",
233            "properties": {
234                "glyphs": {
235                    "type": "string",
236                    "description": "Portal glyphs as 12 hex digits (e.g., 01717D8A4EA2)"
237                },
238                "coords": {
239                    "type": "string",
240                    "description": "Signal booster coordinates (XXXX:YYYY:ZZZZ:SSSS)"
241                },
242                "galactic_address": {
243                    "type": "string",
244                    "description": "Galactic address as hex (0x...)"
245                }
246            }
247        })),
248    )
249}
250
251fn galaxy_stats_tool() -> Tool {
252    Tool::new(
253        "galaxy_stats",
254        "Get aggregate statistics about the explored galaxy.",
255        Arc::new(empty_input_schema()),
256    )
257}
258
259// ── Helpers ─────────────────────────────────────────────────────
260
261fn text_result(json: Value) -> Result<CallToolResult, ErrorData> {
262    Ok(CallToolResult::success(vec![Content::text(
263        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string()),
264    )]))
265}
266
267fn tool_error(msg: &str) -> ErrorData {
268    ErrorData::invalid_params(msg.to_string(), None)
269}
270
271// ── Shared JSON builders (used by both tools and resources) ──────
272
273/// Build JSON for the player's current location.
274///
275/// Returns `Err` if the player position is not available.
276pub(crate) fn build_where_am_i_json(model: &GalaxyModel) -> Result<serde_json::Value, String> {
277    let addr = model
278        .player_position()
279        .ok_or_else(|| "Player position not available".to_string())?;
280
281    let portal_hex = format!("{:012X}", addr.packed());
282    let galaxy = Galaxy::by_index(addr.reality_index);
283
284    let nearest = model.nearest_systems(addr, 1);
285    let (system_name, system_planets) = nearest
286        .first()
287        .and_then(|(id, _)| model.system(id))
288        .map(|s| (s.name.as_deref().unwrap_or("-"), s.planets.len()))
289        .unwrap_or(("(unknown)", 0));
290
291    Ok(json!({
292        "system": system_name,
293        "planets_in_system": system_planets,
294        "galaxy": galaxy.name,
295        "voxel_x": addr.voxel_x(),
296        "voxel_y": addr.voxel_y(),
297        "voxel_z": addr.voxel_z(),
298        "solar_system_index": addr.solar_system_index(),
299        "portal_glyphs_hex": portal_hex,
300        "portal_glyphs_emoji": hex_to_emoji(&portal_hex),
301        "signal_booster": addr.to_signal_booster(),
302    }))
303}
304
305/// Build JSON for galaxy statistics.
306pub(crate) fn build_galaxy_stats_json(model: &GalaxyModel) -> serde_json::Value {
307    let result = execute_stats(
308        model,
309        &StatsQuery {
310            biomes: true,
311            discoveries: true,
312        },
313    );
314
315    let biome_breakdown: Vec<Value> = {
316        let mut biomes: Vec<_> = result.biome_counts.iter().collect();
317        biomes.sort_by_key(|item| std::cmp::Reverse(*item.1));
318        biomes
319            .iter()
320            .map(|(biome, count)| json!({ "biome": biome.to_string(), "count": count }))
321            .collect()
322    };
323
324    json!({
325        "systems": result.system_count,
326        "planets": result.planet_count,
327        "bases": result.base_count,
328        "named_systems": result.named_system_count,
329        "named_planets": result.named_planet_count,
330        "infested_planets": result.infested_count,
331        "biome_distribution": biome_breakdown,
332        "unknown_biome_count": result.unknown_biome_count,
333    })
334}
335
336/// Build JSON for all player bases.
337pub(crate) fn build_bases_json(model: &GalaxyModel) -> serde_json::Value {
338    let bases: Vec<Value> = model
339        .bases
340        .values()
341        .map(|b| {
342            let portal_hex = format!("{:012X}", b.address.packed());
343            json!({
344                "name": b.name,
345                "type": format!("{}", b.base_type),
346                "portal_glyphs_hex": portal_hex,
347                "portal_glyphs_emoji": hex_to_emoji(&portal_hex),
348            })
349        })
350        .collect();
351
352    json!({
353        "count": bases.len(),
354        "bases": bases,
355    })
356}
357
358// ── Tool Handlers ───────────────────────────────────────────────
359
360async fn handle_search_planets(
361    model: Arc<RwLock<GalaxyModel>>,
362    args: Value,
363) -> Result<CallToolResult, ErrorData> {
364    let model = model.read().await;
365    let biome = parse_biome_arg(&args, "biome")?;
366
367    let reference = match args.get("from_base").and_then(|v| v.as_str()) {
368        Some(name) => ReferencePoint::Base(name.into()),
369        None => ReferencePoint::CurrentPosition,
370    };
371
372    let infested = args
373        .get("infested")
374        .and_then(|v| v.as_bool())
375        .and_then(|b| b.then_some(true));
376
377    let query = FindQuery {
378        biome,
379        biome_subtype: None,
380        infested,
381        within_ly: args.get("within_ly").and_then(|v| v.as_f64()),
382        nearest: args
383            .get("nearest")
384            .and_then(|v| v.as_u64())
385            .map(|n| n as usize),
386        discoverer: args
387            .get("discoverer")
388            .and_then(|v| v.as_str())
389            .map(String::from),
390        named_only: args
391            .get("named_only")
392            .and_then(|v| v.as_bool())
393            .unwrap_or(false),
394        name_pattern: None,
395        from: reference,
396    };
397
398    let results = execute_find(&model, &query).map_err(|e| tool_error(&e.to_string()))?;
399
400    let planets: Vec<Value> = results
401        .iter()
402        .map(|r| {
403            json!({
404                "planet": r.planet.name.as_deref().unwrap_or("-"),
405                "biome": r.planet.biome.map(|b| b.to_string()),
406                "infested": r.planet.infested,
407                "system": r.system.name.as_deref().unwrap_or("-"),
408                "distance": format_distance(r.distance_ly),
409                "distance_ly": r.distance_ly,
410                "portal_glyphs_hex": &r.portal_hex,
411                "portal_glyphs_emoji": hex_to_emoji(&r.portal_hex),
412                "discoverer": r.system.discoverer.as_deref().unwrap_or("unknown"),
413            })
414        })
415        .collect();
416
417    text_result(json!({
418        "count": planets.len(),
419        "results": planets,
420    }))
421}
422
423async fn handle_plan_route(
424    model: Arc<RwLock<GalaxyModel>>,
425    args: Value,
426) -> Result<CallToolResult, ErrorData> {
427    let model = model.read().await;
428    let targets_arg = args.get("targets").and_then(|v| v.as_array()).map(|a| {
429        a.iter()
430            .filter_map(|v| v.as_str().map(String::from))
431            .collect::<Vec<_>>()
432    });
433
434    let biome_arg = parse_biome_arg(&args, "biome")?;
435
436    let targets = if let Some(names) = targets_arg {
437        if names.is_empty() {
438            return Err(tool_error("targets array is empty"));
439        }
440        TargetSelection::Named(names)
441    } else if let Some(biome) = biome_arg {
442        TargetSelection::Biome(BiomeFilter {
443            biome: Some(biome),
444            ..Default::default()
445        })
446    } else {
447        return Err(tool_error("Specify either 'biome' or 'targets'"));
448    };
449
450    let from = match args.get("from_base").and_then(|v| v.as_str()) {
451        Some(name) => RouteFrom::Base(name.into()),
452        None => RouteFrom::CurrentPosition,
453    };
454
455    let algorithm = match args.get("algorithm").and_then(|v| v.as_str()) {
456        Some("nearest-neighbor") | Some("nn") => RoutingAlgorithm::NearestNeighbor,
457        _ => RoutingAlgorithm::TwoOpt,
458    };
459
460    let query = RouteQuery {
461        targets,
462        from,
463        warp_range: args.get("warp_range").and_then(|v| v.as_f64()),
464        within_ly: args.get("within_ly").and_then(|v| v.as_f64()),
465        max_targets: args
466            .get("max_targets")
467            .and_then(|v| v.as_u64())
468            .map(|n| n as usize),
469        algorithm,
470        return_to_start: args
471            .get("round_trip")
472            .and_then(|v| v.as_bool())
473            .unwrap_or(false),
474    };
475
476    let result = execute_route(&model, &query).map_err(|e| tool_error(&e.to_string()))?;
477
478    let hops: Vec<Value> = result
479        .route
480        .hops
481        .iter()
482        .enumerate()
483        .map(|(i, hop)| {
484            let sys = model.system(&hop.system_id);
485            let sys_name = sys.and_then(|s| s.name.as_deref()).unwrap_or("-");
486            let portal_hex = sys
487                .map(|s| format!("{:012X}", s.address.packed()))
488                .unwrap_or_default();
489
490            json!({
491                "hop": i + 1,
492                "system": sys_name,
493                "is_waypoint": hop.is_waypoint,
494                "leg_distance": format_distance(hop.leg_distance_ly),
495                "leg_distance_ly": hop.leg_distance_ly,
496                "cumulative": format_distance(hop.cumulative_ly),
497                "cumulative_ly": hop.cumulative_ly,
498                "portal_glyphs_hex": portal_hex,
499                "portal_glyphs_emoji": hex_to_emoji(&portal_hex),
500            })
501        })
502        .collect();
503
504    let algo_name = match result.algorithm {
505        RoutingAlgorithm::NearestNeighbor => "nearest-neighbor",
506        RoutingAlgorithm::TwoOpt => "2-opt",
507    };
508
509    text_result(json!({
510        "hops": hops,
511        "total_distance": format_distance(result.route.total_distance_ly),
512        "total_distance_ly": result.route.total_distance_ly,
513        "targets_visited": result.targets_visited,
514        "algorithm": algo_name,
515        "warp_range": result.warp_range,
516        "warp_jumps": result.warp_jumps,
517    }))
518}
519
520async fn handle_where_am_i(
521    model: Arc<RwLock<GalaxyModel>>,
522    _args: Value,
523) -> Result<CallToolResult, ErrorData> {
524    let model = model.read().await;
525    let json = build_where_am_i_json(&model).map_err(|e| tool_error(&e))?;
526    text_result(json)
527}
528
529async fn handle_whats_nearby(
530    model: Arc<RwLock<GalaxyModel>>,
531    args: Value,
532) -> Result<CallToolResult, ErrorData> {
533    let model = model.read().await;
534    let count = args
535        .get("count")
536        .and_then(|v| v.as_u64())
537        .map(|n| n as usize)
538        .unwrap_or(10);
539
540    let biome = parse_biome_arg(&args, "biome")?;
541
542    let query = FindQuery {
543        biome,
544        nearest: Some(count),
545        from: ReferencePoint::CurrentPosition,
546        ..Default::default()
547    };
548
549    let results = execute_find(&model, &query).map_err(|e| tool_error(&e.to_string()))?;
550
551    let nearby: Vec<Value> = results
552        .iter()
553        .map(|r| {
554            json!({
555                "planet": r.planet.name.as_deref().unwrap_or("-"),
556                "biome": r.planet.biome.map(|b| b.to_string()),
557                "system": r.system.name.as_deref().unwrap_or("-"),
558                "distance": format_distance(r.distance_ly),
559                "distance_ly": r.distance_ly,
560                "portal_glyphs_emoji": hex_to_emoji(&r.portal_hex),
561            })
562        })
563        .collect();
564
565    text_result(json!({
566        "count": nearby.len(),
567        "from": "player position",
568        "results": nearby,
569    }))
570}
571
572async fn handle_show_system(
573    model: Arc<RwLock<GalaxyModel>>,
574    args: Value,
575) -> Result<CallToolResult, ErrorData> {
576    let model = model.read().await;
577    let name = args
578        .get("name")
579        .and_then(|v| v.as_str())
580        .ok_or_else(|| tool_error("'name' is required"))?;
581
582    let result = execute_show(&model, &ShowQuery::System(name.into()))
583        .map_err(|e| tool_error(&e.to_string()))?;
584
585    match result {
586        ShowResult::System(s) => {
587            let planets: Vec<Value> = s
588                .system
589                .planets
590                .iter()
591                .map(|p| {
592                    json!({
593                        "index": p.index,
594                        "name": p.name.as_deref().unwrap_or("-"),
595                        "biome": p.biome.map(|b| b.to_string()),
596                        "infested": p.infested,
597                    })
598                })
599                .collect();
600
601            text_result(json!({
602                "name": s.system.name.as_deref().unwrap_or("-"),
603                "galaxy": s.galaxy_name,
604                "discoverer": s.system.discoverer.as_deref().unwrap_or("unknown"),
605                "portal_glyphs_hex": s.portal_hex,
606                "portal_glyphs_emoji": hex_to_emoji(&s.portal_hex),
607                "distance_from_player": s.distance_from_player.map(format_distance),
608                "voxel_x": s.system.address.voxel_x(),
609                "voxel_y": s.system.address.voxel_y(),
610                "voxel_z": s.system.address.voxel_z(),
611                "planets": planets,
612            }))
613        }
614        ShowResult::Base(_) => Err(tool_error("unexpected result type")),
615    }
616}
617
618async fn handle_show_base(
619    model: Arc<RwLock<GalaxyModel>>,
620    args: Value,
621) -> Result<CallToolResult, ErrorData> {
622    let model = model.read().await;
623    let name = args
624        .get("name")
625        .and_then(|v| v.as_str())
626        .ok_or_else(|| tool_error("'name' is required"))?;
627
628    let result = execute_show(&model, &ShowQuery::Base(name.into()))
629        .map_err(|e| tool_error(&e.to_string()))?;
630
631    match result {
632        ShowResult::Base(b) => text_result(json!({
633            "name": b.base.name,
634            "type": format!("{}", b.base.base_type),
635            "galaxy": b.galaxy_name,
636            "portal_glyphs_hex": b.portal_hex,
637            "portal_glyphs_emoji": hex_to_emoji(&b.portal_hex),
638            "distance_from_player": b.distance_from_player.map(format_distance),
639            "system": b.system.as_ref().and_then(|s| s.name.as_deref()),
640            "system_planet_count": b.system.as_ref().map(|s| s.planets.len()),
641        })),
642        ShowResult::System(_) => Err(tool_error("unexpected result type")),
643    }
644}
645
646async fn handle_convert(
647    _model: Arc<RwLock<GalaxyModel>>,
648    args: Value,
649) -> Result<CallToolResult, ErrorData> {
650    let addr = if let Some(glyphs) = args.get("glyphs").and_then(|v| v.as_str()) {
651        let hex = glyphs
652            .strip_prefix("0x")
653            .or_else(|| glyphs.strip_prefix("0X"))
654            .unwrap_or(glyphs);
655        if hex.len() != 12 {
656            return Err(tool_error(&format!(
657                "Portal glyphs must be 12 hex digits, got {}",
658                hex.len()
659            )));
660        }
661        let packed =
662            u64::from_str_radix(hex, 16).map_err(|_| tool_error(&format!("Invalid hex: {hex}")))?;
663        GalacticAddress::from_packed(packed, 0)
664    } else if let Some(coords) = args.get("coords").and_then(|v| v.as_str()) {
665        GalacticAddress::from_signal_booster(coords, 0, 0)
666            .map_err(|e| tool_error(&format!("Invalid coordinates: {e}")))?
667    } else if let Some(ga) = args.get("galactic_address").and_then(|v| v.as_str()) {
668        let hex = ga
669            .strip_prefix("0x")
670            .or_else(|| ga.strip_prefix("0X"))
671            .unwrap_or(ga);
672        let packed = u64::from_str_radix(hex, 16)
673            .map_err(|_| tool_error(&format!("Invalid galactic address: {ga}")))?;
674        GalacticAddress::from_packed(packed, 0)
675    } else {
676        return Err(tool_error(
677            "Specify 'glyphs', 'coords', or 'galactic_address'",
678        ));
679    };
680
681    let portal_hex = format!("{:012X}", addr.packed());
682    let galaxy = Galaxy::by_index(addr.reality_index);
683
684    text_result(json!({
685        "portal_glyphs_hex": portal_hex,
686        "portal_glyphs_emoji": hex_to_emoji(&portal_hex),
687        "signal_booster": addr.to_signal_booster(),
688        "galactic_address": format!("0x{:012X}", addr.packed()),
689        "voxel_x": addr.voxel_x(),
690        "voxel_y": addr.voxel_y(),
691        "voxel_z": addr.voxel_z(),
692        "solar_system_index": addr.solar_system_index(),
693        "planet_index": addr.planet_index(),
694        "galaxy": galaxy.name,
695    }))
696}
697
698async fn handle_galaxy_stats(
699    model: Arc<RwLock<GalaxyModel>>,
700    _args: Value,
701) -> Result<CallToolResult, ErrorData> {
702    let model = model.read().await;
703    text_result(build_galaxy_stats_json(&model))
704}
705
706fn parse_biome_arg(args: &Value, key: &str) -> Result<Option<Biome>, ErrorData> {
707    args.get(key)
708        .and_then(|v| v.as_str())
709        .map(|s| {
710            s.parse::<Biome>()
711                .map_err(|e| tool_error(&format!("Invalid biome: {e}")))
712        })
713        .transpose()
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use nms_graph::GalaxyModel;
720    use tokio::sync::RwLock;
721
722    fn test_model() -> Arc<RwLock<GalaxyModel>> {
723        let json = r#"{
724            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
725            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
726            "BaseContext": {
727                "GameMode": 1,
728                "PlayerStateData": {
729                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
730                    "Units": 0, "Nanites": 0, "Specials": 0,
731                    "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": ""}]
732                }
733            },
734            "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": []}},
735            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
736                {"DD": {"UA": "0x001000000064", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
737                {"DD": {"UA": "0x101000000064", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Explorer", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
738                {"DD": {"UA": "0x002000000C80", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
739                {"DD": {"UA": "0x102000000C80", "DT": "Planet", "VP": ["0xCD", 1]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
740                {"DD": {"UA": "0x003000001900", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}},
741                {"DD": {"UA": "0x103000001900", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID": "", "UID": "1", "USN": "Traveler", "PTK": "ST", "TS": 1700000000}, "FL": {"U": 1}}
742            ]}}}
743        }"#;
744        Arc::new(RwLock::new(
745            nms_save::parse_save(json.as_bytes())
746                .map(|save| GalaxyModel::from_save(&save))
747                .expect("test model JSON is valid"),
748        ))
749    }
750
751    #[test]
752    fn test_tools_has_all_eight() {
753        let tools = NmsTools::new(test_model());
754        let tool_list = tools.tools();
755        let names: Vec<&str> = tool_list.iter().map(|t| t.name.as_ref()).collect();
756        assert_eq!(names.len(), 8);
757        assert!(names.contains(&"search_planets"));
758        assert!(names.contains(&"plan_route"));
759        assert!(names.contains(&"where_am_i"));
760        assert!(names.contains(&"whats_nearby"));
761        assert!(names.contains(&"show_system"));
762        assert!(names.contains(&"show_base"));
763        assert!(names.contains(&"convert_coordinates"));
764        assert!(names.contains(&"galaxy_stats"));
765    }
766
767    #[test]
768    fn test_tools_unknown_returns_none() {
769        let tools = NmsTools::new(test_model());
770        assert!(tools.call("nonexistent", json!({})).is_none());
771    }
772
773    #[test]
774    fn test_tools_tool_count() {
775        let tools = NmsTools::new(test_model());
776        assert_eq!(tools.tool_count(), 8);
777    }
778
779    #[test]
780    fn test_tools_schemas_valid() {
781        let tools = NmsTools::new(test_model());
782        fabryk_mcp::assert_tools_valid(&tools);
783    }
784
785    #[tokio::test]
786    async fn test_where_am_i_returns_position() {
787        let tools = NmsTools::new(test_model());
788        let result = tools.call("where_am_i", json!({})).unwrap().await;
789        assert!(result.is_ok());
790        let ctr = result.unwrap();
791        let text = extract_text(&ctr);
792        let v: Value = serde_json::from_str(&text).expect("valid JSON");
793        assert!(v.get("system").is_some());
794        assert!(v.get("portal_glyphs_hex").is_some());
795        assert!(v.get("galaxy").is_some());
796    }
797
798    #[tokio::test]
799    async fn test_galaxy_stats_returns_counts() {
800        let tools = NmsTools::new(test_model());
801        let result = tools.call("galaxy_stats", json!({})).unwrap().await;
802        assert!(result.is_ok());
803        let ctr = result.unwrap();
804        let text = extract_text(&ctr);
805        let v: Value = serde_json::from_str(&text).expect("valid JSON");
806        assert!(v["systems"].as_u64().unwrap() >= 3);
807        assert!(v["planets"].as_u64().unwrap() >= 3);
808    }
809
810    #[tokio::test]
811    async fn test_search_planets_all() {
812        let tools = NmsTools::new(test_model());
813        let result = tools.call("search_planets", json!({})).unwrap().await;
814        assert!(result.is_ok());
815        let ctr = result.unwrap();
816        let text = extract_text(&ctr);
817        let v: Value = serde_json::from_str(&text).expect("valid JSON");
818        assert!(v["count"].as_u64().unwrap() > 0);
819    }
820
821    #[tokio::test]
822    async fn test_search_planets_invalid_biome() {
823        let tools = NmsTools::new(test_model());
824        let result = tools
825            .call("search_planets", json!({"biome": "NotABiome"}))
826            .unwrap()
827            .await;
828        assert!(result.is_err());
829    }
830
831    #[tokio::test]
832    async fn test_whats_nearby_default() {
833        let tools = NmsTools::new(test_model());
834        let result = tools.call("whats_nearby", json!({})).unwrap().await;
835        assert!(result.is_ok());
836    }
837
838    #[tokio::test]
839    async fn test_whats_nearby_with_count() {
840        let tools = NmsTools::new(test_model());
841        let result = tools
842            .call("whats_nearby", json!({"count": 1}))
843            .unwrap()
844            .await;
845        assert!(result.is_ok());
846        let ctr = result.unwrap();
847        let text = extract_text(&ctr);
848        let v: Value = serde_json::from_str(&text).expect("valid JSON");
849        assert!(v["count"].as_u64().unwrap() <= 1);
850    }
851
852    #[tokio::test]
853    async fn test_show_base_existing() {
854        let tools = NmsTools::new(test_model());
855        let result = tools
856            .call("show_base", json!({"name": "Alpha Base"}))
857            .unwrap()
858            .await;
859        assert!(result.is_ok());
860        let ctr = result.unwrap();
861        let text = extract_text(&ctr);
862        let v: Value = serde_json::from_str(&text).expect("valid JSON");
863        assert_eq!(v["name"], "Alpha Base");
864    }
865
866    #[tokio::test]
867    async fn test_show_base_not_found() {
868        let tools = NmsTools::new(test_model());
869        let result = tools
870            .call("show_base", json!({"name": "No Such Base"}))
871            .unwrap()
872            .await;
873        assert!(result.is_err());
874    }
875
876    #[tokio::test]
877    async fn test_show_base_missing_name() {
878        let tools = NmsTools::new(test_model());
879        let result = tools.call("show_base", json!({})).unwrap().await;
880        assert!(result.is_err());
881    }
882
883    #[tokio::test]
884    async fn test_show_system_missing_name() {
885        let tools = NmsTools::new(test_model());
886        let result = tools.call("show_system", json!({})).unwrap().await;
887        assert!(result.is_err());
888    }
889
890    #[tokio::test]
891    async fn test_convert_glyphs() {
892        let tools = NmsTools::new(test_model());
893        let result = tools
894            .call("convert_coordinates", json!({"glyphs": "01717D8A4EA2"}))
895            .unwrap()
896            .await;
897        assert!(result.is_ok());
898        let ctr = result.unwrap();
899        let text = extract_text(&ctr);
900        let v: Value = serde_json::from_str(&text).expect("valid JSON");
901        assert_eq!(v["portal_glyphs_hex"], "01717D8A4EA2");
902        assert!(v.get("signal_booster").is_some());
903    }
904
905    #[tokio::test]
906    async fn test_convert_galactic_address() {
907        let tools = NmsTools::new(test_model());
908        let result = tools
909            .call(
910                "convert_coordinates",
911                json!({"galactic_address": "0x01717D8A4EA2"}),
912            )
913            .unwrap()
914            .await;
915        assert!(result.is_ok());
916    }
917
918    #[tokio::test]
919    async fn test_convert_no_input_errors() {
920        let tools = NmsTools::new(test_model());
921        let result = tools.call("convert_coordinates", json!({})).unwrap().await;
922        assert!(result.is_err());
923    }
924
925    #[tokio::test]
926    async fn test_convert_bad_glyphs_length() {
927        let tools = NmsTools::new(test_model());
928        let result = tools
929            .call("convert_coordinates", json!({"glyphs": "ABC"}))
930            .unwrap()
931            .await;
932        assert!(result.is_err());
933    }
934
935    #[tokio::test]
936    async fn test_convert_bad_hex() {
937        let tools = NmsTools::new(test_model());
938        let result = tools
939            .call("convert_coordinates", json!({"glyphs": "ZZZZZZZZZZZZ"}))
940            .unwrap()
941            .await;
942        assert!(result.is_err());
943    }
944
945    #[tokio::test]
946    async fn test_plan_route_requires_targets_or_biome() {
947        let tools = NmsTools::new(test_model());
948        let result = tools.call("plan_route", json!({})).unwrap().await;
949        assert!(result.is_err());
950    }
951
952    #[tokio::test]
953    async fn test_plan_route_empty_targets_errors() {
954        let tools = NmsTools::new(test_model());
955        let result = tools
956            .call("plan_route", json!({"targets": []}))
957            .unwrap()
958            .await;
959        assert!(result.is_err());
960    }
961
962    #[tokio::test]
963    async fn test_parse_biome_arg_valid() {
964        let args = json!({"biome": "Lush"});
965        assert_eq!(parse_biome_arg(&args, "biome").unwrap(), Some(Biome::Lush));
966    }
967
968    #[tokio::test]
969    async fn test_parse_biome_arg_invalid() {
970        let args = json!({"biome": "NotReal"});
971        assert!(parse_biome_arg(&args, "biome").is_err());
972    }
973
974    #[tokio::test]
975    async fn test_parse_biome_arg_missing() {
976        let args = json!({});
977        assert_eq!(parse_biome_arg(&args, "biome").unwrap(), None);
978    }
979
980    #[tokio::test]
981    async fn test_model_updates_after_delta() {
982        let model = test_model();
983        let count_before = model.read().await.system_count();
984
985        let new_sys = nms_core::System::new(
986            GalacticAddress::new(500, 10, -300, 0x999, 0, 0),
987            Some("New System".into()),
988            None,
989            None,
990            vec![],
991        );
992        let delta = nms_core::SaveDelta {
993            new_systems: vec![new_sys],
994            new_planets: vec![],
995            player_moved: None,
996            new_bases: vec![],
997            modified_bases: vec![],
998        };
999
1000        {
1001            let mut m = model.write().await;
1002            m.apply_delta(&delta);
1003        }
1004
1005        assert_eq!(model.read().await.system_count(), count_before + 1);
1006    }
1007
1008    #[tokio::test]
1009    async fn test_tools_see_updated_model() {
1010        let model = test_model();
1011        let tools = NmsTools::new(Arc::clone(&model));
1012
1013        let result1 = tools
1014            .call("galaxy_stats", json!({}))
1015            .unwrap()
1016            .await
1017            .unwrap();
1018        let text1 = extract_text(&result1);
1019        let v1: Value = serde_json::from_str(&text1).expect("valid JSON");
1020        let initial_count = v1["systems"].as_u64().unwrap();
1021
1022        // Apply delta
1023        {
1024            let mut m = model.write().await;
1025            let new_sys = nms_core::System::new(
1026                GalacticAddress::new(600, 20, -400, 0xAAA, 0, 0),
1027                Some("Delta System".into()),
1028                None,
1029                None,
1030                vec![],
1031            );
1032            m.apply_delta(&nms_core::SaveDelta {
1033                new_systems: vec![new_sys],
1034                new_planets: vec![],
1035                player_moved: None,
1036                new_bases: vec![],
1037                modified_bases: vec![],
1038            });
1039        }
1040
1041        // Stats should reflect new system
1042        let result2 = tools
1043            .call("galaxy_stats", json!({}))
1044            .unwrap()
1045            .await
1046            .unwrap();
1047        let text2 = extract_text(&result2);
1048        let v2: Value = serde_json::from_str(&text2).expect("valid JSON");
1049        assert_eq!(v2["systems"].as_u64().unwrap(), initial_count + 1);
1050    }
1051
1052    #[tokio::test]
1053    async fn test_concurrent_read_locks() {
1054        let model = test_model();
1055        let tools1 = NmsTools::new(Arc::clone(&model));
1056        let tools2 = NmsTools::new(Arc::clone(&model));
1057
1058        // Two concurrent tool calls should not deadlock
1059        let (r1, r2) = tokio::join!(
1060            tools1.call("where_am_i", json!({})).unwrap(),
1061            tools2.call("galaxy_stats", json!({})).unwrap(),
1062        );
1063        assert!(r1.is_ok());
1064        assert!(r2.is_ok());
1065    }
1066
1067    fn extract_text(ctr: &CallToolResult) -> String {
1068        ctr.content
1069            .iter()
1070            .filter_map(|c| c.as_text().map(|t| t.text.clone()))
1071            .collect::<Vec<_>>()
1072            .join("")
1073    }
1074}