1use 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
25pub 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
70fn 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
259fn 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
271pub(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
305pub(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
336pub(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
358async 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 {
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 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 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}