use nms_core::address::LY_PER_VOXEL;
use rstar::PointDistance;
use crate::model::GalaxyModel;
use crate::spatial::SystemId;
#[derive(Debug, Clone, Copy)]
pub enum EdgeStrategy {
Knn { k: usize },
WarpRange { max_ly: f64 },
}
impl Default for EdgeStrategy {
fn default() -> Self {
EdgeStrategy::Knn { k: 10 }
}
}
impl GalaxyModel {
pub fn build_edges(&mut self, strategy: EdgeStrategy) {
let edge_ids: Vec<_> = self.graph.edge_indices().collect();
for edge_id in edge_ids {
self.graph.remove_edge(edge_id);
}
match strategy {
EdgeStrategy::Knn { k } => self.build_knn_edges(k),
EdgeStrategy::WarpRange { max_ly } => self.build_warp_range_edges(max_ly),
}
}
fn build_knn_edges(&mut self, k: usize) {
let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
for &sys_id in &system_ids {
let system = match self.systems.get(&sys_id) {
Some(s) => s,
None => continue,
};
let sys_addr = system.address;
let galaxy = sys_addr.reality_index;
let spatial = match self.spatial.get(&galaxy) {
Some(s) => s,
None => continue,
};
let query_point = [
sys_addr.voxel_x() as f64,
sys_addr.voxel_y() as f64,
sys_addr.voxel_z() as f64,
];
let neighbor_ids: Vec<SystemId> = spatial
.nearest_neighbor_iter(&query_point)
.filter(|sp| sp.id != sys_id)
.take(k)
.map(|sp| sp.id)
.collect();
let neighbors: Vec<_> = neighbor_ids
.into_iter()
.filter_map(|nid| {
let neighbor_sys = self.systems.get(&nid)?;
let dist_ly = sys_addr.distance_ly(&neighbor_sys.address);
Some((nid, dist_ly))
})
.collect();
let from_node = match self.node_map.get(&sys_id) {
Some(&n) => n,
None => continue,
};
for (neighbor_id, dist_ly) in neighbors {
let to_node = match self.node_map.get(&neighbor_id) {
Some(&n) => n,
None => continue,
};
if self.graph.find_edge(from_node, to_node).is_none() {
self.graph.add_edge(from_node, to_node, dist_ly);
}
}
}
}
fn build_warp_range_edges(&mut self, max_ly: f64) {
let system_ids: Vec<SystemId> = self.systems.keys().copied().collect();
for &sys_id in &system_ids {
let system = match self.systems.get(&sys_id) {
Some(s) => s,
None => continue,
};
let sys_addr = system.address;
let galaxy = sys_addr.reality_index;
let spatial = match self.spatial.get(&galaxy) {
Some(s) => s,
None => continue,
};
let query_point = [
sys_addr.voxel_x() as f64,
sys_addr.voxel_y() as f64,
sys_addr.voxel_z() as f64,
];
let voxel_radius = max_ly / LY_PER_VOXEL + 1.0;
let voxel_radius_sq = voxel_radius * voxel_radius;
let neighbor_ids: Vec<SystemId> = spatial
.nearest_neighbor_iter(&query_point)
.take_while(|sp| sp.distance_2(&query_point) <= voxel_radius_sq)
.filter(|sp| sp.id != sys_id)
.map(|sp| sp.id)
.collect();
let neighbors: Vec<_> = neighbor_ids
.into_iter()
.filter_map(|nid| {
let neighbor_sys = self.systems.get(&nid)?;
let dist_ly = sys_addr.distance_ly(&neighbor_sys.address);
if dist_ly <= max_ly {
Some((nid, dist_ly))
} else {
None
}
})
.collect();
let from_node = match self.node_map.get(&sys_id) {
Some(&n) => n,
None => continue,
};
for (neighbor_id, dist_ly) in neighbors {
let to_node = match self.node_map.get(&neighbor_id) {
Some(&n) => n,
None => continue,
};
if self.graph.find_edge(from_node, to_node).is_none() {
self.graph.add_edge(from_node, to_node, dist_ly);
}
}
}
}
pub fn connect_new_system(&mut self, sys_id: SystemId, k: usize) {
let system = match self.systems.get(&sys_id) {
Some(s) => s,
None => return,
};
let sys_addr = system.address;
let galaxy = sys_addr.reality_index;
let spatial = match self.spatial.get(&galaxy) {
Some(s) => s,
None => return,
};
let query_point = [
sys_addr.voxel_x() as f64,
sys_addr.voxel_y() as f64,
sys_addr.voxel_z() as f64,
];
let neighbor_ids: Vec<SystemId> = spatial
.nearest_neighbor_iter(&query_point)
.filter(|sp| sp.id != sys_id)
.take(k)
.map(|sp| sp.id)
.collect();
let neighbors: Vec<_> = neighbor_ids
.into_iter()
.filter_map(|nid| {
let neighbor_sys = self.systems.get(&nid)?;
let dist_ly = sys_addr.distance_ly(&neighbor_sys.address);
Some((nid, dist_ly))
})
.collect();
let from_node = match self.node_map.get(&sys_id) {
Some(&n) => n,
None => return,
};
for (neighbor_id, dist_ly) in neighbors {
let to_node = match self.node_map.get(&neighbor_id) {
Some(&n) => n,
None => continue,
};
if self.graph.find_edge(from_node, to_node).is_none() {
self.graph.add_edge(from_node, to_node, dist_ly);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nms_core::address::GalacticAddress;
use nms_core::biome::Biome;
use nms_core::system::{Planet, System};
fn line_model(n: usize, spacing: i16) -> GalaxyModel {
let json = r#"{
"Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
"CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
"BaseContext": {"GameMode": 1, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
"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": []}},
"DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": []}}}
}"#;
let save = nms_save::parse_save(json.as_bytes()).unwrap();
let mut model = GalaxyModel::from_save(&save);
for i in 0..n {
let x = (i as i16) * spacing;
let ssi = (i + 1) as u16;
let addr = GalacticAddress::new(x, 0, 0, ssi, 0, 0);
let planet = Planet::new(0, Some(Biome::Barren), None, false, None, None);
let system = System::new(addr, None, None, None, vec![planet]);
model.insert_system(system);
}
model
}
#[test]
fn test_knn_edges_created() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 2 });
assert!(model.graph.edge_count() > 0);
assert!(model.graph.edge_count() <= 10);
}
#[test]
fn test_knn_edges_all_nodes_connected() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 2 });
for &node_idx in model.node_map.values() {
let degree = model.graph.edges(node_idx).count();
assert!(degree >= 1, "Node with 0 edges found");
}
}
#[test]
fn test_warp_range_edges_respects_distance() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::WarpRange { max_ly: 5000.0 });
for edge in model.graph.edge_indices() {
let weight = model.graph[edge];
assert!(weight <= 5000.0, "Edge weight {weight} exceeds warp range");
}
}
#[test]
fn test_warp_range_zero_no_edges() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::WarpRange { max_ly: 0.0 });
assert_eq!(model.graph.edge_count(), 0);
}
#[test]
fn test_build_edges_clears_previous() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 4 });
let count1 = model.graph.edge_count();
model.build_edges(EdgeStrategy::Knn { k: 1 });
let count2 = model.graph.edge_count();
assert!(count2 < count1);
}
#[test]
fn test_connect_new_system_adds_edges() {
let mut model = line_model(3, 10);
model.build_edges(EdgeStrategy::Knn { k: 2 });
let edges_before = model.graph.edge_count();
let addr = GalacticAddress::new(5, 0, 0, 0xFFF, 0, 0);
let system = System::new(addr, None, None, None, vec![]);
let sys_id = crate::spatial::SystemId::from_address(&addr);
model.insert_system(system);
model.connect_new_system(sys_id, 2);
assert!(model.graph.edge_count() > edges_before);
}
#[test]
fn test_no_self_loops() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 4 });
for edge in model.graph.edge_indices() {
let (a, b) = model.graph.edge_endpoints(edge).unwrap();
assert_ne!(a, b, "Self-loop detected");
}
}
#[test]
fn test_no_duplicate_edges() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 4 });
let mut seen = std::collections::HashSet::new();
for edge in model.graph.edge_indices() {
let (a, b) = model.graph.edge_endpoints(edge).unwrap();
let key = if a < b { (a, b) } else { (b, a) };
assert!(seen.insert(key), "Duplicate edge found: {key:?}");
}
}
#[test]
fn test_edge_strategy_default_is_knn_10() {
let strategy = EdgeStrategy::default();
match strategy {
EdgeStrategy::Knn { k } => assert_eq!(k, 10),
_ => panic!("Default should be Knn"),
}
}
#[test]
fn test_warp_range_large_connects_all() {
let mut model = line_model(3, 10);
model.build_edges(EdgeStrategy::WarpRange { max_ly: 100_000.0 });
assert_eq!(model.graph.edge_count(), 3);
}
#[test]
fn test_connect_new_system_nonexistent_is_noop() {
let mut model = line_model(3, 10);
model.build_edges(EdgeStrategy::Knn { k: 2 });
let edges_before = model.graph.edge_count();
model.connect_new_system(SystemId(0xDEADBEEF), 2);
assert_eq!(model.graph.edge_count(), edges_before);
}
#[test]
fn test_edge_weights_are_positive() {
let mut model = line_model(5, 10);
model.build_edges(EdgeStrategy::Knn { k: 2 });
for edge in model.graph.edge_indices() {
let weight = model.graph[edge];
assert!(weight > 0.0, "Edge weight should be positive, got {weight}");
}
}
}