diff --git a/server/Cargo.lock b/server/Cargo.lock index b510e031b..820c955e0 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.29" +version = "0.1.30" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bin/atlas.rs b/server/src/bin/atlas.rs deleted file mode 100644 index f346ef3b5..000000000 --- a/server/src/bin/atlas.rs +++ /dev/null @@ -1,2119 +0,0 @@ -//! Atlas CLI — query and manage celestial bodies and stations in systems.db. -//! -//! # Usage -//! -//! ```sh -//! # Via wrapper script (recommended): -//! tooling/atlas list-bodies --system "GJ 15A" -//! tooling/atlas list-bodies --type planet --inhabited -//! tooling/atlas show-system "GJ 15A" -//! tooling/atlas show-body "GJ 15Ab" -//! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab" -//! tooling/atlas stats -//! tooling/atlas corridor-status # remaining systems by corridor/hop -//! tooling/atlas populate # bulk classifier pass -//! -//! # Direct: -//! cargo run --bin atlas -- [args] -//! ``` - -use std::path::PathBuf; -use std::process; - -use clap::{Parser, Subcommand}; -use rusqlite::{params, Connection}; -use serde::Serialize; - -// --------------------------------------------------------------------------- -// CLI structure -// --------------------------------------------------------------------------- - -#[derive(Parser)] -#[command( - name = "atlas", - about = "Query and manage celestial bodies and stations in systems.db" -)] -struct Cli { - /// Path to the SQLite database (default: auto-detect from worktree) - #[arg(long, global = true)] - db: Option, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Show full system hierarchy (star → bodies → stations) - ShowSystem { - /// System ID (e.g., "GJ 15A") - system_id: String, - }, - /// Show a single body's details - ShowBody { - /// Body ID (e.g., "GJ 15Ab") - body_id: String, - }, - /// Show a single station's details - ShowStation { - /// Station ID (e.g., "GJ 15Ab-S1") - station_id: String, - }, - /// List bodies with optional filters - ListBodies { - #[arg(long)] - system: Option, - #[arg(long, value_name = "TYPE")] - r#type: Option, - #[arg(long)] - inhabited: bool, - #[arg(long)] - unnamed: bool, - }, - /// List stations with optional filters - ListStations { - #[arg(long)] - system: Option, - #[arg(long, value_name = "TYPE")] - r#type: Option, - }, - /// Add a body record - AddBody { - #[arg(long)] - id: String, - #[arg(long)] - system: String, - #[arg(long, value_name = "TYPE")] - r#type: String, - #[arg(long)] - orbit: Option, - #[arg(long)] - name: Option, - #[arg(long)] - parent: Option, - #[arg(long)] - mass_class: Option, - #[arg(long)] - atmosphere: Option, - #[arg(long)] - gravity: Option, - #[arg(long)] - biome: Option, - #[arg(long)] - inhabited: bool, - #[arg(long)] - population: Option, - }, - /// Add a station record - AddStation { - #[arg(long)] - id: String, - #[arg(long)] - system: String, - #[arg(long)] - orbits: Option, - #[arg(long, value_name = "TYPE")] - r#type: String, - #[arg(long)] - name: Option, - #[arg(long)] - population: Option, - #[arg(long)] - docking: Option, - #[arg(long)] - gate: bool, - }, - /// Database statistics - Stats, - /// Generate a body/station proposal for a single system (writes JSON file for review) - Author { - /// System ID (e.g., "GJ 71") - system_id: String, - /// Output directory for proposal JSON (default: docs/atlas/proposals/) - #[arg(long, default_value = "docs/atlas/proposals")] - outdir: String, - /// Path to wiki directory (default: wiki/star-systems/) - #[arg(long, default_value = "wiki/star-systems")] - wiki: String, - }, - /// Commit an approved proposal JSON to the database - CommitSystem { - /// Path to the proposal JSON file - path: String, - }, - /// Wipe all bodies and stations for a single system - WipeSystem { - /// System ID - system_id: String, - }, - /// List systems with optional filters - ListSystems { - /// Filter by geographic sector (e.g., "west_reach", "east_reach") - #[arg(long)] - sector: Option, - /// Filter by hop distance - #[arg(long)] - hop: Option, - /// Only show systems that have bodies authored - #[arg(long)] - finished: bool, - /// Only show systems that have NO bodies yet - #[arg(long)] - unfinished: bool, - }, - /// List systems that have no bodies yet - Unfinished { - /// Filter by geographic sector - #[arg(long)] - sector: Option, - }, - /// List unfinished systems at a specific hop distance (or next available hop) - Next { - /// Hop distance (omit to find the lowest hop with unfinished systems) - hop: Option, - /// Filter by geographic sector - #[arg(long)] - sector: Option, - }, - /// Sync body/station data into wiki page for a system (or all systems) - SyncWiki { - /// System ID (omit for all systems with bodies) - system_id: Option, - /// Path to wiki directory (default: wiki/star-systems/) - #[arg(long, default_value = "wiki/star-systems")] - wiki: String, - }, - /// Show remaining unfinished systems grouped by geographic sector and hop distance - CorridorStatus, -} - -// --------------------------------------------------------------------------- -// Output types -// --------------------------------------------------------------------------- - -#[derive(Serialize)] -struct BodyRow { - body_id: String, - system_id: String, - parent_body_id: Option, - body_type: String, - orbit_index: Option, - proper_name: Option, - mass_class: Option, - atmosphere: Option, - surface_gravity: Option, - biome_summary: Option, - hydrosphere: Option, - inhabited: bool, - population: i64, - economic_role: Option, - cultural_corridor: Option, - industrial_corridor: Option, -} - -#[derive(Serialize)] -struct StationRow { - station_id: String, - system_id: String, - orbits_body_id: Option, - station_type: String, - proper_name: Option, - population: i64, - economic_role: Option, - governance_type: Option, - docking_class: Option, - has_gate_infrastructure: bool, - district_count: i32, -} - -#[derive(Serialize)] -struct SystemSummary { - system_id: String, - proper_name: Option, - star_type: Option, - geographic_sector: Option, - habitable_planet_count: Option, - inhabited_planet_count: Option, - bodies: Vec, - stations: Vec, -} - -#[derive(Serialize)] -struct StatsOutput { - systems: i64, - bodies: i64, - bodies_by_type: Vec, - inhabited_bodies: i64, - stations: i64, - stations_by_type: Vec, - systems_with_bodies: i64, - systems_without_bodies: i64, -} - -#[derive(Serialize)] -struct TypeCount { - r#type: String, - count: i64, -} - -#[derive(Serialize, serde::Deserialize, Clone)] -struct ProposalBody { - body_id: String, - proper_name: Option, - body_type: String, - orbit_index: i32, - parent_body_id: Option, - inhabited: bool, - population: Option, - mass_class: Option, - surface_gravity: Option, - orbital_period_days: Option, - rotation_period_hours: Option, - atmosphere: Option, - biome_summary: Option, - hydrosphere: Option, - economic_role: Option, - settlement_pattern: Option, - industrial_corridor: Option, - notes: String, -} - -#[derive(Serialize, serde::Deserialize, Clone)] -struct ProposalStation { - station_id: String, - proper_name: Option, - orbits_body_id: String, - station_type: String, - population: Option, - economic_role: Option, - docking_class: Option, - has_gate_infrastructure: bool, - notes: String, -} - -#[derive(Serialize, serde::Deserialize)] -struct SystemProposal { - system_id: String, - proper_name: Option, - star_type: Option, - spectral_class: Option, - wiki_data: WikiData, - bodies: Vec, - stations: Vec, -} - -#[derive(Serialize, serde::Deserialize)] -struct WikiData { - habitable_count: i32, - inhabited_count: i32, - has_gas_giant: bool, - has_asteroid_belt: bool, - has_horizon_station: bool, - raw_bodies_line: Option, -} - -// --------------------------------------------------------------------------- -// Database helpers -// --------------------------------------------------------------------------- - -fn resolve_db_path(explicit: Option) -> PathBuf { - if let Some(p) = explicit { - return p; - } - // Walk up from CWD looking for server/data/systems.db - let mut dir = std::env::current_dir().expect("Cannot determine CWD"); - loop { - let candidate = dir.join("server").join("data").join("systems.db"); - if candidate.exists() { - return candidate; - } - if !dir.pop() { - break; - } - } - eprintln!("error: cannot find server/data/systems.db — pass --db explicitly"); - process::exit(1); -} - -fn open_db(path: &PathBuf) -> Connection { - let conn = Connection::open(path).unwrap_or_else(|e| { - eprintln!("error: cannot open {}: {}", path.display(), e); - process::exit(1); - }); - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") - .unwrap(); - conn -} - -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- - -fn cmd_show_system(conn: &Connection, system_id: &str) { - let mut stmt = conn - .prepare( - "SELECT system_id, proper_name, star_type, geographic_sector, - habitable_planet_count, inhabited_planet_count - FROM star_systems WHERE system_id = ?1", - ) - .unwrap(); - - let sys: Option = stmt - .query_row(params![system_id], |row| { - Ok(SystemSummary { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - geographic_sector: row.get(3)?, - habitable_planet_count: row.get(4)?, - inhabited_planet_count: row.get(5)?, - bodies: Vec::new(), - stations: Vec::new(), - }) - }) - .ok(); - - let Some(mut sys) = sys else { - eprintln!("error: system '{}' not found", system_id); - process::exit(1); - }; - - sys.bodies = query_bodies(conn, Some(system_id), None, false, false); - sys.stations = query_stations(conn, Some(system_id), None); - - println!("{}", serde_json::to_string_pretty(&sys).unwrap()); -} - -fn cmd_show_body(conn: &Connection, body_id: &str) { - let row = conn - .query_row( - "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, - proper_name, mass_class, atmosphere, surface_gravity, - biome_summary, hydrosphere, inhabited, population, - economic_role, cultural_corridor, industrial_corridor - FROM bodies WHERE body_id = ?1", - params![body_id], - |row| { - Ok(BodyRow { - body_id: row.get(0)?, - system_id: row.get(1)?, - parent_body_id: row.get(2)?, - body_type: row.get(3)?, - orbit_index: row.get(4)?, - proper_name: row.get(5)?, - mass_class: row.get(6)?, - atmosphere: row.get(7)?, - surface_gravity: row.get(8)?, - biome_summary: row.get(9)?, - hydrosphere: row.get(10)?, - inhabited: row.get::<_, i32>(11)? != 0, - population: row.get::<_, Option>(12)?.unwrap_or(0), - economic_role: row.get(13)?, - cultural_corridor: row.get(14)?, - industrial_corridor: row.get(15)?, - }) - }, - ) - .unwrap_or_else(|_| { - eprintln!("error: body '{}' not found", body_id); - process::exit(1); - }); - - // Also fetch stations orbiting this body - let stations = query_stations_for_body(conn, body_id); - - #[derive(Serialize)] - struct BodyDetail { - #[serde(flatten)] - body: BodyRow, - stations: Vec, - } - - let detail = BodyDetail { - body: row, - stations, - }; - println!("{}", serde_json::to_string_pretty(&detail).unwrap()); -} - -fn cmd_show_station(conn: &Connection, station_id: &str) { - let row = conn - .query_row( - "SELECT station_id, system_id, orbits_body_id, station_type, - proper_name, population, economic_role, governance_type, - docking_class, has_gate_infrastructure, district_count - FROM stations WHERE station_id = ?1", - params![station_id], - |row| { - Ok(StationRow { - station_id: row.get(0)?, - system_id: row.get(1)?, - orbits_body_id: row.get(2)?, - station_type: row.get(3)?, - proper_name: row.get(4)?, - population: row.get::<_, Option>(5)?.unwrap_or(0), - economic_role: row.get(6)?, - governance_type: row.get(7)?, - docking_class: row.get(8)?, - has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, - district_count: row.get::<_, Option>(10)?.unwrap_or(1), - }) - }, - ) - .unwrap_or_else(|_| { - eprintln!("error: station '{}' not found", station_id); - process::exit(1); - }); - - println!("{}", serde_json::to_string_pretty(&row).unwrap()); -} - -fn query_bodies( - conn: &Connection, - system: Option<&str>, - body_type: Option<&str>, - inhabited_only: bool, - unnamed_only: bool, -) -> Vec { - let mut sql = String::from( - "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, - proper_name, mass_class, atmosphere, surface_gravity, - biome_summary, hydrosphere, inhabited, population, - economic_role, cultural_corridor, industrial_corridor - FROM bodies WHERE 1=1", - ); - let mut param_values: Vec> = Vec::new(); - - if let Some(s) = system { - sql.push_str(" AND system_id = ?"); - param_values.push(Box::new(s.to_string())); - } - if let Some(t) = body_type { - sql.push_str(" AND body_type = ?"); - param_values.push(Box::new(t.to_string())); - } - if inhabited_only { - sql.push_str(" AND inhabited = 1"); - } - if unnamed_only { - sql.push_str(" AND proper_name IS NULL"); - } - sql.push_str(" ORDER BY system_id, orbit_index"); - - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let mut stmt = conn.prepare(&sql).unwrap(); - let rows = stmt - .query_map(params_ref.as_slice(), |row| { - Ok(BodyRow { - body_id: row.get(0)?, - system_id: row.get(1)?, - parent_body_id: row.get(2)?, - body_type: row.get(3)?, - orbit_index: row.get(4)?, - proper_name: row.get(5)?, - mass_class: row.get(6)?, - atmosphere: row.get(7)?, - surface_gravity: row.get(8)?, - biome_summary: row.get(9)?, - hydrosphere: row.get(10)?, - inhabited: row.get::<_, i32>(11)? != 0, - population: row.get::<_, Option>(12)?.unwrap_or(0), - economic_role: row.get(13)?, - cultural_corridor: row.get(14)?, - industrial_corridor: row.get(15)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - rows -} - -fn query_stations( - conn: &Connection, - system: Option<&str>, - station_type: Option<&str>, -) -> Vec { - let mut sql = String::from( - "SELECT station_id, system_id, orbits_body_id, station_type, - proper_name, population, economic_role, governance_type, - docking_class, has_gate_infrastructure, district_count - FROM stations WHERE 1=1", - ); - let mut param_values: Vec> = Vec::new(); - - if let Some(s) = system { - sql.push_str(" AND system_id = ?"); - param_values.push(Box::new(s.to_string())); - } - if let Some(t) = station_type { - sql.push_str(" AND station_type = ?"); - param_values.push(Box::new(t.to_string())); - } - sql.push_str(" ORDER BY system_id, station_id"); - - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - let mut stmt = conn.prepare(&sql).unwrap(); - stmt.query_map(params_ref.as_slice(), |row| { - Ok(StationRow { - station_id: row.get(0)?, - system_id: row.get(1)?, - orbits_body_id: row.get(2)?, - station_type: row.get(3)?, - proper_name: row.get(4)?, - population: row.get::<_, Option>(5)?.unwrap_or(0), - economic_role: row.get(6)?, - governance_type: row.get(7)?, - docking_class: row.get(8)?, - has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, - district_count: row.get::<_, Option>(10)?.unwrap_or(1), - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() -} - -fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec { - let mut stmt = conn - .prepare( - "SELECT station_id, system_id, orbits_body_id, station_type, - proper_name, population, economic_role, governance_type, - docking_class, has_gate_infrastructure, district_count - FROM stations WHERE orbits_body_id = ?1 ORDER BY station_id", - ) - .unwrap(); - stmt.query_map(params![body_id], |row| { - Ok(StationRow { - station_id: row.get(0)?, - system_id: row.get(1)?, - orbits_body_id: row.get(2)?, - station_type: row.get(3)?, - proper_name: row.get(4)?, - population: row.get::<_, Option>(5)?.unwrap_or(0), - economic_role: row.get(6)?, - governance_type: row.get(7)?, - docking_class: row.get(8)?, - has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, - district_count: row.get::<_, Option>(10)?.unwrap_or(1), - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() -} - -fn cmd_list_bodies( - conn: &Connection, - system: Option<&str>, - body_type: Option<&str>, - inhabited: bool, - unnamed: bool, -) { - let bodies = query_bodies(conn, system, body_type, inhabited, unnamed); - println!("{}", serde_json::to_string_pretty(&bodies).unwrap()); -} - -fn cmd_list_stations(conn: &Connection, system: Option<&str>, station_type: Option<&str>) { - let stations = query_stations(conn, system, station_type); - println!("{}", serde_json::to_string_pretty(&stations).unwrap()); -} - -fn cmd_add_body(conn: &Connection, args: &Commands) { - let Commands::AddBody { - id, - system, - r#type, - orbit, - name, - parent, - mass_class, - atmosphere, - gravity, - biome, - inhabited, - population, - } = args - else { - unreachable!() - }; - - conn.execute( - "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, - proper_name, mass_class, atmosphere, surface_gravity, - biome_summary, inhabited, population) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - params![ - id, - system, - parent, - r#type, - orbit, - name, - mass_class, - atmosphere, - gravity, - biome, - *inhabited as i32, - population.unwrap_or(0), - ], - ) - .unwrap_or_else(|e| { - eprintln!("error: {}", e); - process::exit(1); - }); - - println!(r#"{{"ok": true, "body_id": "{}"}}"#, id); -} - -fn cmd_add_station(conn: &Connection, args: &Commands) { - let Commands::AddStation { - id, - system, - orbits, - r#type, - name, - population, - docking, - gate, - } = args - else { - unreachable!() - }; - - conn.execute( - "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, - proper_name, population, docking_class, has_gate_infrastructure) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - id, - system, - orbits, - r#type, - name, - population.unwrap_or(0), - docking, - *gate as i32, - ], - ) - .unwrap_or_else(|e| { - eprintln!("error: {}", e); - process::exit(1); - }); - - println!(r#"{{"ok": true, "station_id": "{}"}}"#, id); -} - -fn cmd_stats(conn: &Connection) { - let systems: i64 = conn - .query_row("SELECT COUNT(*) FROM star_systems", [], |r| r.get(0)) - .unwrap(); - let bodies: i64 = conn - .query_row("SELECT COUNT(*) FROM bodies", [], |r| r.get(0)) - .unwrap(); - let inhabited_bodies: i64 = conn - .query_row("SELECT COUNT(*) FROM bodies WHERE inhabited = 1", [], |r| { - r.get(0) - }) - .unwrap(); - let stations: i64 = conn - .query_row("SELECT COUNT(*) FROM stations", [], |r| r.get(0)) - .unwrap(); - let systems_with_bodies: i64 = conn - .query_row("SELECT COUNT(DISTINCT system_id) FROM bodies", [], |r| { - r.get(0) - }) - .unwrap(); - - let mut bodies_by_type = Vec::new(); - { - let mut stmt = conn - .prepare("SELECT body_type, COUNT(*) FROM bodies GROUP BY body_type ORDER BY body_type") - .unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(TypeCount { - r#type: row.get(0)?, - count: row.get(1)?, - }) - }) - .unwrap(); - for r in rows.flatten() { - bodies_by_type.push(r); - } - } - - let mut stations_by_type = Vec::new(); - { - let mut stmt = conn - .prepare("SELECT station_type, COUNT(*) FROM stations GROUP BY station_type ORDER BY station_type") - .unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(TypeCount { - r#type: row.get(0)?, - count: row.get(1)?, - }) - }) - .unwrap(); - for r in rows.flatten() { - stations_by_type.push(r); - } - } - - let stats = StatsOutput { - systems, - bodies, - bodies_by_type, - inhabited_bodies, - stations, - stations_by_type, - systems_with_bodies, - systems_without_bodies: systems - systems_with_bodies, - }; - println!("{}", serde_json::to_string_pretty(&stats).unwrap()); -} - -fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option, i32, i32) { - // Try to find the wiki page for this system - let sid_slug = system_id.replace(' ', "-"); - let wiki_path = std::path::Path::new(wiki_dir) - .join(&sid_slug) - .join("index.md"); - if !wiki_path.exists() { - return (None, 0, 0); - } - let content = std::fs::read_to_string(&wiki_path).unwrap_or_default(); - // Look for "| **Bodies** | X habitable · Y inhabited |" - for line in content.lines() { - if line.contains("**Bodies**") { - let raw = line.to_string(); - let mut hab = 0i32; - let mut inh = 0i32; - // Parse "N habitable" - if let Some(pos) = line.find("habitable") { - let before = &line[..pos]; - let parts: Vec<&str> = before.split_whitespace().collect(); - if let Some(n) = parts.last() { - hab = n.parse().unwrap_or(0); - } - } - // Parse "N inhabited" - if let Some(pos) = line.find("inhabited") { - let before = &line[..pos]; - let parts: Vec<&str> = before.split_whitespace().collect(); - if let Some(n) = parts.last() { - inh = n.parse().unwrap_or(0); - } - } - return (Some(raw), hab, inh); - } - } - (None, 0, 0) -} - -fn generate_body_matrix( - system_id: &str, - star_type: Option<&str>, - spectral: Option<&str>, - wiki_hab: i32, - wiki_inh: i32, - db_gas_giant: bool, - db_belt: bool, - has_horizon: bool, -) -> (Vec, Vec) { - let sid = system_id.replace(' ', ""); - let mut bodies = Vec::new(); - let mut stations = Vec::new(); - let mut orbit = 1; - - // Determine planet count from star type if wiki doesn't specify - let spectral_char = spectral.and_then(|s| s.chars().next()).unwrap_or('M'); - - // Base planet count by spectral type. - // Sol has 8. TRAPPIST-1 (M-dwarf) has 7. Minimum 6 for any star. - let total_planets = if wiki_hab > 0 || wiki_inh > 0 { - // Wiki has data — use it as the inhabited/habitable core, pad to realistic count - let known = wiki_hab.max(wiki_inh); - match spectral_char { - 'O' | 'B' | 'A' => (known + 4).max(6), // hot stars — fewer but still 6+ - 'F' => (known + 5).max(8), // bright — wide system, 8+ - 'G' => (known + 5).max(8), // sol-like — 8 is baseline - 'K' => (known + 4).max(7), // cooler — 7+ typical - 'M' => (known + 4).max(6), // compact but TRAPPIST-1 has 7 - _ => (known + 4).max(6), - } - } else { - // No wiki data — generate realistic count - match spectral_char { - 'O' | 'B' | 'A' => 6, - 'F' => 8, - 'G' => 8, - 'K' => 7, - 'M' => 6, - _ => 6, - } - }; - - // Determine if binary — affects naming - let is_binary = star_type == Some("binary"); - - // Place inner barren rocky planets - let inner_barren = if total_planets > wiki_inh + 1 { 1 } else { 0 }; - for _ in 0..inner_barren { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = if is_binary { - format!("{}A{}", sid, letter) // circumbinary assumed for now - } else { - format!("{}{}", sid, letter) - }; - bodies.push(ProposalBody { - body_id, - body_type: "planet".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: Some("terrestrial".into()), - surface_gravity: Some(0.38), - orbital_period_days: Some(88.0), - rotation_period_hours: Some(1408.0), - atmosphere: Some("none".into()), - biome_summary: Some("barren".into()), - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "inner rocky, uninhabited".into(), - }); - orbit += 1; - } - - // Place inhabited planets - for i in 0..wiki_inh { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = if is_binary { - format!("{}A{}", sid, letter) - } else { - format!("{}{}", sid, letter) - }; - bodies.push(ProposalBody { - body_id, - body_type: "planet".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: true, - proper_name: None, - population: None, - mass_class: Some("terrestrial".into()), - surface_gravity: Some(0.9), - orbital_period_days: Some(365.0), - rotation_period_hours: Some(24.0), - atmosphere: Some("standard".into()), - biome_summary: Some("temperate".into()), - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: format!("inhabited planet {}/{}", i + 1, wiki_inh), - }); - orbit += 1; - } - - // Place habitable-but-uninhabited planets (hab > inh) - let hab_only = (wiki_hab - wiki_inh).max(0); - for _ in 0..hab_only { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = if is_binary { - format!("{}A{}", sid, letter) - } else { - format!("{}{}", sid, letter) - }; - bodies.push(ProposalBody { - body_id, - body_type: "planet".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: Some("terrestrial".into()), - surface_gravity: Some(0.85), - orbital_period_days: Some(400.0), - rotation_period_hours: Some(26.0), - atmosphere: Some("standard".into()), - biome_summary: Some("temperate".into()), - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "habitable, uninhabited".into(), - }); - orbit += 1; - } - - // Outer rocky/ice planets to fill remaining count - let placed = inner_barren + wiki_inh + hab_only; - let remaining_planets = (total_planets - placed).max(0); - for _ in 0..remaining_planets { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = if is_binary { - format!("{}A{}", sid, letter) - } else { - format!("{}{}", sid, letter) - }; - bodies.push(ProposalBody { - body_id, - body_type: "planet".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: Some("terrestrial".into()), - surface_gravity: Some(0.5), - orbital_period_days: Some(2000.0), - rotation_period_hours: Some(18.0), - atmosphere: Some("thin".into()), - biome_summary: Some("frozen".into()), - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "outer rocky/ice, uninhabited".into(), - }); - orbit += 1; - } - - // Asteroid belt - if db_belt { - bodies.push(ProposalBody { - body_id: format!("{}-belt", sid), - body_type: "asteroid_belt".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: None, - surface_gravity: None, - orbital_period_days: None, - rotation_period_hours: None, - atmosphere: None, - biome_summary: None, - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "asteroid belt".into(), - }); - orbit += 1; - } - - // Gas giant (with up to 2 moons as default) - if db_gas_giant { - let letter = (b'b' + orbit as u8 - 1) as char; - let gg_id = if is_binary { - format!("{}A{}", sid, letter) - } else { - format!("{}{}", sid, letter) - }; - bodies.push(ProposalBody { - body_id: gg_id.clone(), - body_type: "gas_giant".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: Some("gas_giant".into()), - surface_gravity: None, - orbital_period_days: Some(4300.0), - rotation_period_hours: Some(10.0), - atmosphere: Some("dense".into()), - biome_summary: None, - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "gas giant".into(), - }); - // 2 default moons - for m in 1..=2 { - bodies.push(ProposalBody { - body_id: format!("{}-{}", gg_id, m), - body_type: "moon".into(), - orbit_index: m, - parent_body_id: Some(gg_id.clone()), - inhabited: false, - proper_name: None, - population: None, - mass_class: Some("dwarf".into()), - surface_gravity: Some(0.1), - orbital_period_days: Some(3.5 * m as f64), - rotation_period_hours: Some(3.5 * 24.0 * m as f64), - atmosphere: Some("none".into()), - biome_summary: Some("barren".into()), - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: format!("moon {} of gas giant", m), - }); - } - orbit += 1; - } - - // Oort cloud (always) - let oort_id = format!("{}-oort", sid); - bodies.push(ProposalBody { - body_id: oort_id.clone(), - body_type: "oort_cloud".into(), - orbit_index: orbit, - parent_body_id: None, - inhabited: false, - proper_name: None, - population: None, - mass_class: None, - surface_gravity: None, - orbital_period_days: None, - rotation_period_hours: None, - atmosphere: None, - biome_summary: None, - hydrosphere: None, - economic_role: None, - settlement_pattern: None, - industrial_corridor: None, - notes: "oort cloud".into(), - }); - - // Horizon station - if has_horizon { - stations.push(ProposalStation { - station_id: format!("{}-oort-S1", sid), - proper_name: None, - orbits_body_id: oort_id, - station_type: "horizon".into(), - population: None, - economic_role: Some("transit".into()), - docking_class: Some("major".into()), - has_gate_infrastructure: true, - notes: "horizon station — gate infrastructure".into(), - }); - } - - (bodies, stations) -} - -fn cmd_author(conn: &Connection, system_id: &str, outdir: &str, wiki_dir: &str) { - // Get system data from DB - let sys = conn - .query_row( - "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, - s.habitable_planet_count, s.inhabited_planet_count, - s.asteroid_belt, s.gas_giant, - g.horizon_station - FROM star_systems s - LEFT JOIN system_gates g ON s.system_id = g.system_id - WHERE s.system_id = ?1", - params![system_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - )) - }, - ) - .unwrap_or_else(|_| { - eprintln!("error: system '{}' not found", system_id); - process::exit(1); - }); - - let (sid, proper_name, star_type, spectral, db_hab, db_inh, db_belt, db_gg, db_horizon) = sys; - - // Parse wiki for body info - let (raw_line, wiki_hab, wiki_inh) = parse_wiki_bodies_line(wiki_dir, &sid); - - // Use wiki data preferentially, fall back to DB - let hab = if wiki_hab > 0 { - wiki_hab - } else { - db_hab.unwrap_or(0) - }; - let inh = if wiki_inh > 0 { - wiki_inh - } else { - db_inh.unwrap_or(0) - }; - let has_belt = db_belt.unwrap_or(0) != 0; - let has_gg = db_gg.unwrap_or(0) != 0; - let has_horizon = db_horizon.unwrap_or(0) != 0; - - let wiki_data = WikiData { - habitable_count: hab, - inhabited_count: inh, - has_gas_giant: has_gg, - has_asteroid_belt: has_belt, - has_horizon_station: has_horizon, - raw_bodies_line: raw_line, - }; - - let (bodies, stations) = generate_body_matrix( - &sid, - star_type.as_deref(), - spectral.as_deref(), - hab, - inh, - has_gg, - has_belt, - has_horizon, - ); - - let proposal = SystemProposal { - system_id: sid.clone(), - proper_name, - star_type, - spectral_class: spectral, - wiki_data, - bodies, - stations, - }; - - // Write proposal JSON - let sid_compact = sid.replace(' ', ""); - std::fs::create_dir_all(outdir).unwrap(); - let path = std::path::Path::new(outdir).join(format!("{}.json", sid_compact)); - let json = serde_json::to_string_pretty(&proposal).unwrap(); - std::fs::write(&path, &json).unwrap(); - - eprintln!("Proposal written to {}", path.display()); - println!("{}", json); -} - -fn cmd_commit_system(conn: &Connection, path: &str) { - let content = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("error: cannot read {}: {}", path, e); - process::exit(1); - }); - let proposal: SystemProposal = serde_json::from_str(&content).unwrap_or_else(|e| { - eprintln!("error: invalid proposal JSON: {}", e); - process::exit(1); - }); - - // Check for existing bodies - let existing: i64 = conn - .query_row( - "SELECT COUNT(*) FROM bodies WHERE system_id = ?1", - params![proposal.system_id], - |r| r.get(0), - ) - .unwrap(); - - if existing > 0 { - eprintln!( - "error: system '{}' already has {} bodies. Use wipe-system first.", - proposal.system_id, existing - ); - process::exit(1); - } - - let tx = conn.unchecked_transaction().unwrap(); - - for body in &proposal.bodies { - tx.execute( - "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, - proper_name, inhabited, population, mass_class, surface_gravity, - orbital_period_days, rotation_period_hours, atmosphere, - biome_summary, hydrosphere, economic_role, settlement_pattern, - industrial_corridor) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", - params![ - body.body_id, - proposal.system_id, - body.parent_body_id, - body.body_type, - body.orbit_index, - body.proper_name, - body.inhabited as i32, - body.population.unwrap_or(0), - body.mass_class, - body.surface_gravity, - body.orbital_period_days, - body.rotation_period_hours, - body.atmosphere, - body.biome_summary, - body.hydrosphere, - body.economic_role, - body.settlement_pattern, - body.industrial_corridor, - ], - ) - .unwrap_or_else(|e| { - eprintln!("error inserting body '{}': {}", body.body_id, e); - process::exit(1); - }); - } - - for station in &proposal.stations { - tx.execute( - "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, - proper_name, population, economic_role, docking_class, - has_gate_infrastructure) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - station.station_id, - proposal.system_id, - station.orbits_body_id, - station.station_type, - station.proper_name, - station.population.unwrap_or(0), - station.economic_role, - station.docking_class, - station.has_gate_infrastructure as i32, - ], - ) - .unwrap_or_else(|e| { - eprintln!("error inserting station '{}': {}", station.station_id, e); - process::exit(1); - }); - } - - // Update star_systems counts from the actual body data - let habitable_count: i32 = proposal - .bodies - .iter() - .filter(|b| { - matches!( - b.atmosphere.as_deref(), - Some("breathable") | Some("standard") - ) && (b.body_type == "planet" || b.body_type == "moon") - }) - .count() as i32; - - let inhabited_count: i32 = proposal.bodies.iter().filter(|b| b.inhabited).count() as i32 - + proposal - .stations - .iter() - .filter(|s| s.population.unwrap_or(0) > 0 || s.station_type == "horizon") - .count() as i32; - - let has_gas_giant: i32 = proposal.bodies.iter().any(|b| b.body_type == "gas_giant") as i32; - - let has_belt: i32 = proposal - .bodies - .iter() - .any(|b| b.body_type == "asteroid_belt") as i32; - - tx.execute( - "UPDATE star_systems SET habitable_planet_count = ?1, inhabited_planet_count = ?2, - gas_giant = ?3, asteroid_belt = ?4 - WHERE system_id = ?5", - params![ - habitable_count, - inhabited_count, - has_gas_giant, - has_belt, - proposal.system_id, - ], - ) - .unwrap(); - - tx.commit().unwrap(); - - #[derive(Serialize)] - struct CommitResult { - system_id: String, - bodies_created: usize, - stations_created: usize, - habitable_count: i32, - inhabited_count: i32, - } - let result = CommitResult { - system_id: proposal.system_id, - bodies_created: proposal.bodies.len(), - stations_created: proposal.stations.len(), - habitable_count, - inhabited_count, - }; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); -} - -fn cmd_wipe_system(conn: &Connection, system_id: &str) { - let stations_deleted: usize = conn - .execute( - "DELETE FROM stations WHERE system_id = ?1", - params![system_id], - ) - .unwrap(); - let bodies_deleted: usize = conn - .execute( - "DELETE FROM bodies WHERE system_id = ?1", - params![system_id], - ) - .unwrap(); - - #[derive(Serialize)] - struct WipeResult { - system_id: String, - bodies_deleted: usize, - stations_deleted: usize, - } - let result = WipeResult { - system_id: system_id.to_string(), - bodies_deleted, - stations_deleted, - }; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); -} - -fn cmd_list_systems( - conn: &Connection, - sector: Option<&str>, - hop: Option, - finished: bool, - unfinished: bool, -) { - // Build query dynamically based on filters - let mut conditions: Vec = Vec::new(); - let mut param_values: Vec> = Vec::new(); - let mut idx = 1; - - if let Some(s) = sector { - conditions.push(format!("s.geographic_sector = ?{idx}")); - param_values.push(Box::new(s.to_string())); - idx += 1; - } - if let Some(h) = hop { - conditions.push(format!("g.hop_distance_from_gateway = ?{idx}")); - param_values.push(Box::new(h)); - idx += 1; - } - let _ = idx; // suppress unused warning - if finished { - conditions.push("s.system_id IN (SELECT DISTINCT system_id FROM bodies)".to_string()); - } - if unfinished { - conditions.push("s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)".to_string()); - } - - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!(" WHERE {}", conditions.join(" AND ")) - }; - - let sql = format!( - "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, - g.gate_topology, s.geographic_sector, - g.hop_distance_from_gateway, - s.habitable_planet_count, s.inhabited_planet_count, - e.population - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - LEFT JOIN system_economy e ON s.system_id = e.system_id - {where_clause} - ORDER BY g.hop_distance_from_gateway, s.system_id" - ); - - let mut stmt = conn.prepare(&sql).unwrap(); - let params_refs: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|p| p.as_ref()).collect(); - - #[derive(Serialize)] - struct ListSystem { - system_id: String, - proper_name: Option, - star_type: Option, - spectral_class: Option, - gate_topology: Option, - geographic_sector: Option, - hop_distance: Option, - habitable_planet_count: Option, - inhabited_planet_count: Option, - population: Option, - } - - let systems: Vec = stmt - .query_map(params_refs.as_slice(), |row| { - Ok(ListSystem { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - spectral_class: row.get(3)?, - gate_topology: row.get(4)?, - geographic_sector: row.get(5)?, - hop_distance: row.get(6)?, - habitable_planet_count: row.get(7)?, - inhabited_planet_count: row.get(8)?, - population: row.get(9)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - - #[derive(Serialize)] - struct ListResult { - count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - sector: Option, - #[serde(skip_serializing_if = "Option::is_none")] - hop: Option, - systems: Vec, - } - - let result = ListResult { - count: systems.len(), - sector: sector.map(|s| s.to_string()), - hop, - systems, - }; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); -} - -fn cmd_unfinished(conn: &Connection, sector: Option<&str>) { - let sql = if sector.is_some() { - "SELECT s.system_id, s.proper_name, s.star_type - FROM star_systems s - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - AND s.geographic_sector = ?1 - ORDER BY s.system_id" - } else { - "SELECT s.system_id, s.proper_name, s.star_type - FROM star_systems s - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - ORDER BY s.system_id" - }; - - let mut stmt = conn.prepare(sql).unwrap(); - - #[derive(Serialize)] - struct UnfinishedSystem { - system_id: String, - proper_name: Option, - star_type: Option, - } - - let rows: Vec = if let Some(s) = sector { - stmt.query_map(params![s], |row| { - Ok(UnfinishedSystem { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - } else { - stmt.query_map([], |row| { - Ok(UnfinishedSystem { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - }; - - #[derive(Serialize)] - struct UnfinishedResult { - count: usize, - sector: Option, - systems: Vec, - } - let result = UnfinishedResult { - count: rows.len(), - sector: sector.map(|s| s.to_string()), - systems: rows, - }; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); -} - -fn cmd_next(conn: &Connection, hop: Option, sector: Option<&str>) { - // Find the target hop — either specified or the lowest with unfinished systems - let target_hop: i32 = if let Some(h) = hop { - h - } else { - let sql = if sector.is_some() { - "SELECT MIN(g.hop_distance_from_gateway) - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - AND s.geographic_sector = ?1" - } else { - "SELECT MIN(g.hop_distance_from_gateway) - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)" - }; - if let Some(s) = sector { - conn.query_row(sql, params![s], |r| r.get::<_, Option>(0)) - } else { - conn.query_row(sql, [], |r| r.get::<_, Option>(0)) - } - .unwrap() - .unwrap_or(-1) - }; - - if target_hop < 0 { - println!( - r#"{{"hop": null, "count": 0, "systems": [], "message": "all systems have bodies"}}"# - ); - return; - } - - #[derive(Serialize)] - struct NextSystem { - system_id: String, - proper_name: Option, - star_type: Option, - spectral_class: Option, - gate_topology: Option, - geographic_sector: Option, - habitable_planet_count: Option, - inhabited_planet_count: Option, - population: Option, - } - - let sql = if sector.is_some() { - "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, - g.gate_topology, s.geographic_sector, - s.habitable_planet_count, s.inhabited_planet_count, - e.population - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - LEFT JOIN system_economy e ON s.system_id = e.system_id - WHERE g.hop_distance_from_gateway = ?1 - AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - AND s.geographic_sector = ?2 - ORDER BY s.system_id" - } else { - "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, - g.gate_topology, s.geographic_sector, - s.habitable_planet_count, s.inhabited_planet_count, - e.population - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - LEFT JOIN system_economy e ON s.system_id = e.system_id - WHERE g.hop_distance_from_gateway = ?1 - AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - ORDER BY s.system_id" - }; - - let mut stmt = conn.prepare(sql).unwrap(); - - let systems: Vec = if let Some(s) = sector { - stmt.query_map(params![target_hop, s], |row| { - Ok(NextSystem { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - spectral_class: row.get(3)?, - gate_topology: row.get(4)?, - geographic_sector: row.get(5)?, - habitable_planet_count: row.get(6)?, - inhabited_planet_count: row.get(7)?, - population: row.get(8)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - } else { - stmt.query_map(params![target_hop], |row| { - Ok(NextSystem { - system_id: row.get(0)?, - proper_name: row.get(1)?, - star_type: row.get(2)?, - spectral_class: row.get(3)?, - gate_topology: row.get(4)?, - geographic_sector: row.get(5)?, - habitable_planet_count: row.get(6)?, - inhabited_planet_count: row.get(7)?, - population: row.get(8)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - }; - - #[derive(Serialize)] - struct NextResult { - hop: i32, - sector: Option, - count: usize, - systems: Vec, - } - - let result = NextResult { - hop: target_hop, - sector: sector.map(|s| s.to_string()), - count: systems.len(), - systems, - }; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); -} - -fn cmd_sync_wiki(conn: &Connection, system_id: Option<&str>, wiki_dir: &str) { - // Get list of systems to sync - let system_ids: Vec = if let Some(sid) = system_id { - vec![sid.to_string()] - } else { - let mut stmt = conn - .prepare("SELECT DISTINCT system_id FROM bodies ORDER BY system_id") - .unwrap(); - stmt.query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - }; - - let mut synced = 0; - - for sid in &system_ids { - let sid_slug = sid.replace(' ', "-"); - let wiki_path = std::path::Path::new(wiki_dir) - .join(&sid_slug) - .join("index.md"); - if !wiki_path.exists() { - eprintln!("skip: {} — no wiki page at {}", sid, wiki_path.display()); - continue; - } - - // Query bodies - let mut body_stmt = conn - .prepare( - "SELECT body_id, proper_name, body_type, orbit_index, parent_body_id, - inhabited, population, mass_class, atmosphere, surface_gravity, - orbital_period_days, rotation_period_hours, - biome_summary, hydrosphere, economic_role, settlement_pattern, - industrial_corridor - FROM bodies WHERE system_id = ?1 - ORDER BY CASE WHEN parent_body_id IS NULL THEN orbit_index ELSE 1000 + orbit_index END", - ) - .unwrap(); - - struct Body { - id: String, - name: Option, - btype: String, - orbit: i32, - parent: Option, - inhabited: bool, - population: i64, - mass_class: Option, - atmosphere: Option, - gravity: Option, - orbital_days: Option, - rotation_hours: Option, - biome: Option, - hydro: Option, - econ: Option, - settlement: Option, - industrial: Option, - } - - let bodies: Vec = body_stmt - .query_map(params![sid], |row| { - Ok(Body { - id: row.get(0)?, - name: row.get(1)?, - btype: row.get(2)?, - orbit: row.get::<_, Option>(3)?.unwrap_or(0), - parent: row.get(4)?, - inhabited: row.get::<_, i32>(5)? != 0, - population: row.get::<_, Option>(6)?.unwrap_or(0), - mass_class: row.get(7)?, - atmosphere: row.get(8)?, - gravity: row.get(9)?, - orbital_days: row.get(10)?, - rotation_hours: row.get(11)?, - biome: row.get(12)?, - hydro: row.get(13)?, - econ: row.get(14)?, - settlement: row.get(15)?, - industrial: row.get(16)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - - // Query stations - let mut station_stmt = conn - .prepare( - "SELECT station_id, proper_name, orbits_body_id, station_type, - population, economic_role, governance_type, docking_class, - has_gate_infrastructure, district_count - FROM stations WHERE system_id = ?1 - ORDER BY station_id", - ) - .unwrap(); - - struct Station { - id: String, - name: Option, - orbits: Option, - stype: String, - population: i64, - econ: Option, - governance: Option, - docking: Option, - gate: bool, - districts: i32, - } - - let stations: Vec = station_stmt - .query_map(params![sid], |row| { - Ok(Station { - id: row.get(0)?, - name: row.get(1)?, - orbits: row.get(2)?, - stype: row.get(3)?, - population: row.get::<_, Option>(4)?.unwrap_or(0), - econ: row.get(5)?, - governance: row.get(6)?, - docking: row.get(7)?, - gate: row.get::<_, Option>(8)?.unwrap_or(0) != 0, - districts: row.get::<_, Option>(9)?.unwrap_or(1), - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - - if bodies.is_empty() && stations.is_empty() { - continue; - } - - // Build the Celestial Bodies section - let mut section = String::new(); - section.push_str("## Celestial Bodies\n"); - section - .push_str("\n\n"); - - // Bodies table - if !bodies.is_empty() { - section.push_str("| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |\n"); - section.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n"); - - // First pass: top-level bodies (no parent) - for b in &bodies { - if b.parent.is_some() { - continue; - } - let name = b.name.as_deref().unwrap_or("—"); - let pop = if b.population > 0 { - format_population(b.population) - } else { - "—".to_string() - }; - let grav = b - .gravity - .map(|g| format!("{:.2}g", g)) - .unwrap_or_else(|| "—".to_string()); - let year = b - .orbital_days - .map(|d| format!("{:.0}", d)) - .unwrap_or_else(|| "—".to_string()); - let day = b - .rotation_hours - .map(|h| format!("{:.1}", h)) - .unwrap_or_else(|| "—".to_string()); - section.push_str(&format!( - "| {} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", - b.orbit, - b.id, - name, - b.btype, - if b.inhabited { "yes" } else { "no" }, - pop, - b.mass_class.as_deref().unwrap_or("—"), - grav, - year, - day, - b.atmosphere.as_deref().unwrap_or("—"), - b.biome.as_deref().unwrap_or("—"), - b.hydro.as_deref().unwrap_or("—"), - b.econ.as_deref().unwrap_or("—"), - b.settlement.as_deref().unwrap_or("—"), - b.industrial.as_deref().unwrap_or("—"), - )); - - // Child bodies (moons) - for m in &bodies { - if m.parent.as_deref() == Some(&b.id) { - let mname = m.name.as_deref().unwrap_or("—"); - let mpop = if m.population > 0 { - format_population(m.population) - } else { - "—".to_string() - }; - let mgrav = m - .gravity - .map(|g| format!("{:.2}g", g)) - .unwrap_or_else(|| "—".to_string()); - let myear = m - .orbital_days - .map(|d| format!("{:.0}", d)) - .unwrap_or_else(|| "—".to_string()); - let mday = m - .rotation_hours - .map(|h| format!("{:.1}", h)) - .unwrap_or_else(|| "—".to_string()); - section.push_str(&format!( - "| ↳ {}.{} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", - b.orbit, - m.orbit, - m.id, - mname, - m.btype, - if m.inhabited { "yes" } else { "no" }, - mpop, - m.mass_class.as_deref().unwrap_or("—"), - mgrav, - myear, - mday, - m.atmosphere.as_deref().unwrap_or("—"), - m.biome.as_deref().unwrap_or("—"), - m.hydro.as_deref().unwrap_or("—"), - m.econ.as_deref().unwrap_or("—"), - m.settlement.as_deref().unwrap_or("—"), - m.industrial.as_deref().unwrap_or("—"), - )); - } - } - } - section.push('\n'); - } - - // Stations table - if !stations.is_empty() { - section.push_str("### Stations & Facilities\n\n"); - section.push_str("| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |\n"); - section.push_str("|---|---|---|---|---|---|---|---|---|---|\n"); - for s in &stations { - let name = s.name.as_deref().unwrap_or("—"); - let orbits = s.orbits.as_deref().unwrap_or("—"); - let pop = if s.population > 0 { - format_population(s.population) - } else { - "—".to_string() - }; - section.push_str(&format!( - "| `{}` | {} | {} | `{}` | {} | {} | {} | {} | {} | {} |\n", - s.id, - name, - s.stype, - orbits, - pop, - s.econ.as_deref().unwrap_or("—"), - s.governance.as_deref().unwrap_or("—"), - s.docking.as_deref().unwrap_or("—"), - if s.gate { "yes" } else { "no" }, - s.districts, - )); - } - section.push('\n'); - } - - // Read current wiki content - let content = std::fs::read_to_string(&wiki_path).unwrap(); - - // Replace or insert the Celestial Bodies section - let marker_start = "## Celestial Bodies"; - let new_content = if let Some(start_pos) = content.find(marker_start) { - // Find the next ## heading after the section (or end of file) - let after = &content[start_pos + marker_start.len()..]; - let end_offset = after - .find("\n## ") - .map(|p| start_pos + marker_start.len() + p + 1) - .unwrap_or(content.len()); - format!( - "{}{}\n{}", - &content[..start_pos], - section.trim_end(), - &content[end_offset..] - ) - } else { - // Insert before ## Topology if it exists, otherwise before end of file - if let Some(topo_pos) = content.find("\n## Topology") { - let insert_pos = topo_pos + 1; // after the newline - format!( - "{}{}\n\n{}", - &content[..insert_pos], - section.trim_end(), - &content[insert_pos..] - ) - } else { - // No topology section — append at end - format!("{}\n{}", content.trim_end(), section) - } - }; - - std::fs::write(&wiki_path, new_content).unwrap(); - eprintln!("synced: {} → {}", sid, wiki_path.display()); - synced += 1; - } - - #[derive(Serialize)] - struct SyncResult { - systems_synced: usize, - } - println!( - "{}", - serde_json::to_string_pretty(&SyncResult { - systems_synced: synced - }) - .unwrap() - ); -} - -fn format_population(pop: i64) -> String { - if pop >= 1_000_000_000 { - format!("{:.1}B", pop as f64 / 1_000_000_000.0) - } else if pop >= 1_000_000 { - format!("{}M", pop / 1_000_000) - } else if pop >= 1_000 { - format!("{}K", pop / 1_000) - } else { - format!("{}", pop) - } -} - -// cmd_populate removed — replaced by per-system author/commit workflow - -fn cmd_corridor_status(conn: &Connection) { - // Query unfinished systems grouped by geographic_sector and hop_distance_from_gateway. - // "Unfinished" = no rows in bodies for this system_id. - // LEFT JOIN so systems with no gate record are still counted (hop = NULL → "?"). - let mut stmt = conn - .prepare( - "SELECT s.geographic_sector, g.hop_distance_from_gateway, COUNT(*) AS remaining - FROM star_systems s - LEFT JOIN system_gates g ON s.system_id = g.system_id - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - GROUP BY s.geographic_sector, g.hop_distance_from_gateway - ORDER BY s.geographic_sector, g.hop_distance_from_gateway", - ) - .unwrap(); - - #[derive(Serialize)] - struct CorridorRow { - sector: Option, - hop: Option, - remaining: i64, - } - - let rows: Vec = stmt - .query_map([], |row| { - Ok(CorridorRow { - sector: row.get(0)?, - hop: row.get(1)?, - remaining: row.get(2)?, - }) - }) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - - if rows.is_empty() { - #[derive(Serialize)] - struct EmptyResult { - total: i64, - message: &'static str, - corridors: Vec<()>, - } - println!( - "{}", - serde_json::to_string_pretty(&EmptyResult { - total: 0, - message: "All systems have bodies authored.", - corridors: vec![], - }) - .unwrap() - ); - return; - } - - let total: i64 = rows.iter().map(|r| r.remaining).sum(); - - #[derive(Serialize)] - struct CorridorResult { - total: i64, - corridors: Vec, - } - println!( - "{}", - serde_json::to_string_pretty(&CorridorResult { - total, - corridors: rows, - }) - .unwrap() - ); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -fn main() { - let cli = Cli::parse(); - let db_path = resolve_db_path(cli.db); - let conn = open_db(&db_path); - - match &cli.command { - Commands::ShowSystem { system_id } => cmd_show_system(&conn, system_id), - Commands::ShowBody { body_id } => cmd_show_body(&conn, body_id), - Commands::ShowStation { station_id } => cmd_show_station(&conn, station_id), - Commands::ListBodies { - system, - r#type, - inhabited, - unnamed, - } => cmd_list_bodies( - &conn, - system.as_deref(), - r#type.as_deref(), - *inhabited, - *unnamed, - ), - Commands::ListStations { system, r#type } => { - cmd_list_stations(&conn, system.as_deref(), r#type.as_deref()) - } - cmd @ Commands::AddBody { .. } => cmd_add_body(&conn, cmd), - cmd @ Commands::AddStation { .. } => cmd_add_station(&conn, cmd), - Commands::Stats => cmd_stats(&conn), - Commands::Author { - system_id, - outdir, - wiki, - } => cmd_author(&conn, system_id, outdir, wiki), - Commands::CommitSystem { path } => cmd_commit_system(&conn, path), - Commands::WipeSystem { system_id } => cmd_wipe_system(&conn, system_id), - Commands::ListSystems { - sector, - hop, - finished, - unfinished, - } => cmd_list_systems(&conn, sector.as_deref(), *hop, *finished, *unfinished), - Commands::Unfinished { sector } => cmd_unfinished(&conn, sector.as_deref()), - Commands::Next { hop, sector } => cmd_next(&conn, *hop, sector.as_deref()), - Commands::SyncWiki { system_id, wiki } => cmd_sync_wiki(&conn, system_id.as_deref(), wiki), - Commands::CorridorStatus => cmd_corridor_status(&conn), - } -} diff --git a/server/src/bin/atlas/author.rs b/server/src/bin/atlas/author.rs new file mode 100644 index 000000000..8b79115fd --- /dev/null +++ b/server/src/bin/atlas/author.rs @@ -0,0 +1,640 @@ +use std::process; + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Proposal types (owned by the author pipeline) +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone)] +pub struct ProposalBody { + pub body_id: String, + pub proper_name: Option, + pub body_type: String, + pub orbit_index: i32, + pub parent_body_id: Option, + pub inhabited: bool, + pub population: Option, + pub mass_class: Option, + pub surface_gravity: Option, + pub orbital_period_days: Option, + pub rotation_period_hours: Option, + pub atmosphere: Option, + pub biome_summary: Option, + pub hydrosphere: Option, + pub economic_role: Option, + pub settlement_pattern: Option, + pub industrial_corridor: Option, + pub notes: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ProposalStation { + pub station_id: String, + pub proper_name: Option, + pub orbits_body_id: String, + pub station_type: String, + pub population: Option, + pub economic_role: Option, + pub docking_class: Option, + pub has_gate_infrastructure: bool, + pub notes: String, +} + +#[derive(Serialize, Deserialize)] +pub struct SystemProposal { + pub system_id: String, + pub proper_name: Option, + pub star_type: Option, + pub spectral_class: Option, + pub wiki_data: WikiData, + pub bodies: Vec, + pub stations: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct WikiData { + pub habitable_count: i32, + pub inhabited_count: i32, + pub has_gas_giant: bool, + pub has_asteroid_belt: bool, + pub has_horizon_station: bool, + pub raw_bodies_line: Option, +} + +// --------------------------------------------------------------------------- +// Author pipeline +// --------------------------------------------------------------------------- + +pub fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option, i32, i32) { + // Try to find the wiki page for this system + let sid_slug = system_id.replace(' ', "-"); + let wiki_path = std::path::Path::new(wiki_dir) + .join(&sid_slug) + .join("index.md"); + if !wiki_path.exists() { + return (None, 0, 0); + } + let content = std::fs::read_to_string(&wiki_path).unwrap_or_default(); + // Look for "| **Bodies** | X habitable · Y inhabited |" + for line in content.lines() { + if line.contains("**Bodies**") { + let raw = line.to_string(); + let mut hab = 0i32; + let mut inh = 0i32; + // Parse "N habitable" + if let Some(pos) = line.find("habitable") { + let before = &line[..pos]; + let parts: Vec<&str> = before.split_whitespace().collect(); + if let Some(n) = parts.last() { + hab = n.parse().unwrap_or(0); + } + } + // Parse "N inhabited" + if let Some(pos) = line.find("inhabited") { + let before = &line[..pos]; + let parts: Vec<&str> = before.split_whitespace().collect(); + if let Some(n) = parts.last() { + inh = n.parse().unwrap_or(0); + } + } + return (Some(raw), hab, inh); + } + } + (None, 0, 0) +} + +pub fn generate_body_matrix( + system_id: &str, + star_type: Option<&str>, + spectral: Option<&str>, + wiki_hab: i32, + wiki_inh: i32, + db_gas_giant: bool, + db_belt: bool, + has_horizon: bool, +) -> (Vec, Vec) { + let sid = system_id.replace(' ', ""); + let mut bodies = Vec::new(); + let mut stations = Vec::new(); + let mut orbit = 1; + + // Determine planet count from star type if wiki doesn't specify + let spectral_char = spectral.and_then(|s| s.chars().next()).unwrap_or('M'); + + // Base planet count by spectral type. + // Sol has 8. TRAPPIST-1 (M-dwarf) has 7. Minimum 6 for any star. + let total_planets = if wiki_hab > 0 || wiki_inh > 0 { + // Wiki has data — use it as the inhabited/habitable core, pad to realistic count + let known = wiki_hab.max(wiki_inh); + match spectral_char { + 'O' | 'B' | 'A' => (known + 4).max(6), // hot stars — fewer but still 6+ + 'F' => (known + 5).max(8), // bright — wide system, 8+ + 'G' => (known + 5).max(8), // sol-like — 8 is baseline + 'K' => (known + 4).max(7), // cooler — 7+ typical + 'M' => (known + 4).max(6), // compact but TRAPPIST-1 has 7 + _ => (known + 4).max(6), + } + } else { + // No wiki data — generate realistic count + match spectral_char { + 'O' | 'B' | 'A' => 6, + 'F' => 8, + 'G' => 8, + 'K' => 7, + 'M' => 6, + _ => 6, + } + }; + + // Determine if binary — affects naming + let is_binary = star_type == Some("binary"); + + // Place inner barren rocky planets + let inner_barren = if total_planets > wiki_inh + 1 { 1 } else { 0 }; + for _ in 0..inner_barren { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = if is_binary { + format!("{}A{}", sid, letter) // circumbinary assumed for now + } else { + format!("{}{}", sid, letter) + }; + bodies.push(ProposalBody { + body_id, + body_type: "planet".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: Some("terrestrial".into()), + surface_gravity: Some(0.38), + orbital_period_days: Some(88.0), + rotation_period_hours: Some(1408.0), + atmosphere: Some("none".into()), + biome_summary: Some("barren".into()), + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "inner rocky, uninhabited".into(), + }); + orbit += 1; + } + + // Place inhabited planets + for i in 0..wiki_inh { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = if is_binary { + format!("{}A{}", sid, letter) + } else { + format!("{}{}", sid, letter) + }; + bodies.push(ProposalBody { + body_id, + body_type: "planet".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: true, + proper_name: None, + population: None, + mass_class: Some("terrestrial".into()), + surface_gravity: Some(0.9), + orbital_period_days: Some(365.0), + rotation_period_hours: Some(24.0), + atmosphere: Some("standard".into()), + biome_summary: Some("temperate".into()), + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: format!("inhabited planet {}/{}", i + 1, wiki_inh), + }); + orbit += 1; + } + + // Place habitable-but-uninhabited planets (hab > inh) + let hab_only = (wiki_hab - wiki_inh).max(0); + for _ in 0..hab_only { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = if is_binary { + format!("{}A{}", sid, letter) + } else { + format!("{}{}", sid, letter) + }; + bodies.push(ProposalBody { + body_id, + body_type: "planet".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: Some("terrestrial".into()), + surface_gravity: Some(0.85), + orbital_period_days: Some(400.0), + rotation_period_hours: Some(26.0), + atmosphere: Some("standard".into()), + biome_summary: Some("temperate".into()), + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "habitable, uninhabited".into(), + }); + orbit += 1; + } + + // Outer rocky/ice planets to fill remaining count + let placed = inner_barren + wiki_inh + hab_only; + let remaining_planets = (total_planets - placed).max(0); + for _ in 0..remaining_planets { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = if is_binary { + format!("{}A{}", sid, letter) + } else { + format!("{}{}", sid, letter) + }; + bodies.push(ProposalBody { + body_id, + body_type: "planet".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: Some("terrestrial".into()), + surface_gravity: Some(0.5), + orbital_period_days: Some(2000.0), + rotation_period_hours: Some(18.0), + atmosphere: Some("thin".into()), + biome_summary: Some("frozen".into()), + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "outer rocky/ice, uninhabited".into(), + }); + orbit += 1; + } + + // Asteroid belt + if db_belt { + bodies.push(ProposalBody { + body_id: format!("{}-belt", sid), + body_type: "asteroid_belt".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: None, + surface_gravity: None, + orbital_period_days: None, + rotation_period_hours: None, + atmosphere: None, + biome_summary: None, + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "asteroid belt".into(), + }); + orbit += 1; + } + + // Gas giant (with up to 2 moons as default) + if db_gas_giant { + let letter = (b'b' + orbit as u8 - 1) as char; + let gg_id = if is_binary { + format!("{}A{}", sid, letter) + } else { + format!("{}{}", sid, letter) + }; + bodies.push(ProposalBody { + body_id: gg_id.clone(), + body_type: "gas_giant".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: Some("gas_giant".into()), + surface_gravity: None, + orbital_period_days: Some(4300.0), + rotation_period_hours: Some(10.0), + atmosphere: Some("dense".into()), + biome_summary: None, + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "gas giant".into(), + }); + // 2 default moons + for m in 1..=2 { + bodies.push(ProposalBody { + body_id: format!("{}-{}", gg_id, m), + body_type: "moon".into(), + orbit_index: m, + parent_body_id: Some(gg_id.clone()), + inhabited: false, + proper_name: None, + population: None, + mass_class: Some("dwarf".into()), + surface_gravity: Some(0.1), + orbital_period_days: Some(3.5 * m as f64), + rotation_period_hours: Some(3.5 * 24.0 * m as f64), + atmosphere: Some("none".into()), + biome_summary: Some("barren".into()), + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: format!("moon {} of gas giant", m), + }); + } + orbit += 1; + } + + // Oort cloud (always) + let oort_id = format!("{}-oort", sid); + bodies.push(ProposalBody { + body_id: oort_id.clone(), + body_type: "oort_cloud".into(), + orbit_index: orbit, + parent_body_id: None, + inhabited: false, + proper_name: None, + population: None, + mass_class: None, + surface_gravity: None, + orbital_period_days: None, + rotation_period_hours: None, + atmosphere: None, + biome_summary: None, + hydrosphere: None, + economic_role: None, + settlement_pattern: None, + industrial_corridor: None, + notes: "oort cloud".into(), + }); + + // Horizon station + if has_horizon { + stations.push(ProposalStation { + station_id: format!("{}-oort-S1", sid), + proper_name: None, + orbits_body_id: oort_id, + station_type: "horizon".into(), + population: None, + economic_role: Some("transit".into()), + docking_class: Some("major".into()), + has_gate_infrastructure: true, + notes: "horizon station — gate infrastructure".into(), + }); + } + + (bodies, stations) +} + +pub fn cmd_author(conn: &Connection, system_id: &str, outdir: &str, wiki_dir: &str) { + // Get system data from DB + let sys = conn + .query_row( + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + s.habitable_planet_count, s.inhabited_planet_count, + s.asteroid_belt, s.gas_giant, + g.horizon_station + FROM star_systems s + LEFT JOIN system_gates g ON s.system_id = g.system_id + WHERE s.system_id = ?1", + params![system_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + )) + }, + ) + .unwrap_or_else(|_| { + eprintln!("error: system '{}' not found", system_id); + process::exit(1); + }); + + let (sid, proper_name, star_type, spectral, db_hab, db_inh, db_belt, db_gg, db_horizon) = sys; + + // Parse wiki for body info + let (raw_line, wiki_hab, wiki_inh) = parse_wiki_bodies_line(wiki_dir, &sid); + + // Use wiki data preferentially, fall back to DB + let hab = if wiki_hab > 0 { + wiki_hab + } else { + db_hab.unwrap_or(0) + }; + let inh = if wiki_inh > 0 { + wiki_inh + } else { + db_inh.unwrap_or(0) + }; + let has_belt = db_belt.unwrap_or(0) != 0; + let has_gg = db_gg.unwrap_or(0) != 0; + let has_horizon = db_horizon.unwrap_or(0) != 0; + + let wiki_data = WikiData { + habitable_count: hab, + inhabited_count: inh, + has_gas_giant: has_gg, + has_asteroid_belt: has_belt, + has_horizon_station: has_horizon, + raw_bodies_line: raw_line, + }; + + let (bodies, stations) = generate_body_matrix( + &sid, + star_type.as_deref(), + spectral.as_deref(), + hab, + inh, + has_gg, + has_belt, + has_horizon, + ); + + let proposal = SystemProposal { + system_id: sid.clone(), + proper_name, + star_type, + spectral_class: spectral, + wiki_data, + bodies, + stations, + }; + + // Write proposal JSON + let sid_compact = sid.replace(' ', ""); + std::fs::create_dir_all(outdir).unwrap(); + let path = std::path::Path::new(outdir).join(format!("{}.json", sid_compact)); + let json = serde_json::to_string_pretty(&proposal).unwrap(); + std::fs::write(&path, &json).unwrap(); + + eprintln!("Proposal written to {}", path.display()); + println!("{}", json); +} + +pub fn cmd_commit_system(conn: &Connection, path: &str) { + let content = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("error: cannot read {}: {}", path, e); + process::exit(1); + }); + let proposal: SystemProposal = serde_json::from_str(&content).unwrap_or_else(|e| { + eprintln!("error: invalid proposal JSON: {}", e); + process::exit(1); + }); + + // Check for existing bodies + let existing: i64 = conn + .query_row( + "SELECT COUNT(*) FROM bodies WHERE system_id = ?1", + params![proposal.system_id], + |r| r.get(0), + ) + .unwrap(); + + if existing > 0 { + eprintln!( + "error: system '{}' already has {} bodies. Use wipe-system first.", + proposal.system_id, existing + ); + process::exit(1); + } + + let tx = conn.unchecked_transaction().unwrap(); + + for body in &proposal.bodies { + tx.execute( + "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, inhabited, population, mass_class, surface_gravity, + orbital_period_days, rotation_period_hours, atmosphere, + biome_summary, hydrosphere, economic_role, settlement_pattern, + industrial_corridor) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + params![ + body.body_id, + proposal.system_id, + body.parent_body_id, + body.body_type, + body.orbit_index, + body.proper_name, + body.inhabited as i32, + body.population.unwrap_or(0), + body.mass_class, + body.surface_gravity, + body.orbital_period_days, + body.rotation_period_hours, + body.atmosphere, + body.biome_summary, + body.hydrosphere, + body.economic_role, + body.settlement_pattern, + body.industrial_corridor, + ], + ) + .unwrap_or_else(|e| { + eprintln!("error inserting body '{}': {}", body.body_id, e); + process::exit(1); + }); + } + + for station in &proposal.stations { + tx.execute( + "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, docking_class, + has_gate_infrastructure) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + station.station_id, + proposal.system_id, + station.orbits_body_id, + station.station_type, + station.proper_name, + station.population.unwrap_or(0), + station.economic_role, + station.docking_class, + station.has_gate_infrastructure as i32, + ], + ) + .unwrap_or_else(|e| { + eprintln!("error inserting station '{}': {}", station.station_id, e); + process::exit(1); + }); + } + + // Update star_systems counts from the actual body data + let habitable_count: i32 = proposal + .bodies + .iter() + .filter(|b| { + matches!( + b.atmosphere.as_deref(), + Some("breathable") | Some("standard") + ) && (b.body_type == "planet" || b.body_type == "moon") + }) + .count() as i32; + + let inhabited_count: i32 = proposal.bodies.iter().filter(|b| b.inhabited).count() as i32 + + proposal + .stations + .iter() + .filter(|s| s.population.unwrap_or(0) > 0 || s.station_type == "horizon") + .count() as i32; + + let has_gas_giant: i32 = proposal.bodies.iter().any(|b| b.body_type == "gas_giant") as i32; + + let has_belt: i32 = proposal + .bodies + .iter() + .any(|b| b.body_type == "asteroid_belt") as i32; + + tx.execute( + "UPDATE star_systems SET habitable_planet_count = ?1, inhabited_planet_count = ?2, + gas_giant = ?3, asteroid_belt = ?4 + WHERE system_id = ?5", + params![ + habitable_count, + inhabited_count, + has_gas_giant, + has_belt, + proposal.system_id, + ], + ) + .unwrap(); + + tx.commit().unwrap(); + + #[derive(Serialize)] + struct CommitResult { + system_id: String, + bodies_created: usize, + stations_created: usize, + habitable_count: i32, + inhabited_count: i32, + } + let result = CommitResult { + system_id: proposal.system_id, + bodies_created: proposal.bodies.len(), + stations_created: proposal.stations.len(), + habitable_count, + inhabited_count, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} diff --git a/server/src/bin/atlas/common.rs b/server/src/bin/atlas/common.rs new file mode 100644 index 000000000..1b305f7e4 --- /dev/null +++ b/server/src/bin/atlas/common.rs @@ -0,0 +1,244 @@ +use std::path::PathBuf; +use std::process; + +use rusqlite::{params, Connection}; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Shared output types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct BodyRow { + pub body_id: String, + pub system_id: String, + pub parent_body_id: Option, + pub body_type: String, + pub orbit_index: Option, + pub proper_name: Option, + pub mass_class: Option, + pub atmosphere: Option, + pub surface_gravity: Option, + pub biome_summary: Option, + pub hydrosphere: Option, + pub inhabited: bool, + pub population: i64, + pub economic_role: Option, + pub cultural_corridor: Option, + pub industrial_corridor: Option, +} + +#[derive(Serialize)] +pub struct StationRow { + pub station_id: String, + pub system_id: String, + pub orbits_body_id: Option, + pub station_type: String, + pub proper_name: Option, + pub population: i64, + pub economic_role: Option, + pub governance_type: Option, + pub docking_class: Option, + pub has_gate_infrastructure: bool, + pub district_count: i32, +} + +#[derive(Serialize)] +pub struct SystemSummary { + pub system_id: String, + pub proper_name: Option, + pub star_type: Option, + pub geographic_sector: Option, + pub habitable_planet_count: Option, + pub inhabited_planet_count: Option, + pub bodies: Vec, + pub stations: Vec, +} + +// --------------------------------------------------------------------------- +// Database helpers +// --------------------------------------------------------------------------- + +pub fn resolve_db_path(explicit: Option) -> PathBuf { + if let Some(p) = explicit { + return p; + } + // Walk up from CWD looking for server/data/systems.db + let mut dir = std::env::current_dir().expect("Cannot determine CWD"); + loop { + let candidate = dir.join("server").join("data").join("systems.db"); + if candidate.exists() { + return candidate; + } + if !dir.pop() { + break; + } + } + eprintln!("error: cannot find server/data/systems.db — pass --db explicitly"); + process::exit(1); +} + +pub fn open_db(path: &PathBuf) -> Connection { + let conn = Connection::open(path).unwrap_or_else(|e| { + eprintln!("error: cannot open {}: {}", path.display(), e); + process::exit(1); + }); + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .unwrap(); + conn +} + +// --------------------------------------------------------------------------- +// Shared query helpers +// --------------------------------------------------------------------------- + +pub fn query_bodies( + conn: &Connection, + system: Option<&str>, + body_type: Option<&str>, + inhabited_only: bool, + unnamed_only: bool, +) -> Vec { + let mut sql = String::from( + "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, hydrosphere, inhabited, population, + economic_role, cultural_corridor, industrial_corridor + FROM bodies WHERE 1=1", + ); + let mut param_values: Vec> = Vec::new(); + + if let Some(s) = system { + sql.push_str(" AND system_id = ?"); + param_values.push(Box::new(s.to_string())); + } + if let Some(t) = body_type { + sql.push_str(" AND body_type = ?"); + param_values.push(Box::new(t.to_string())); + } + if inhabited_only { + sql.push_str(" AND inhabited = 1"); + } + if unnamed_only { + sql.push_str(" AND proper_name IS NULL"); + } + sql.push_str(" ORDER BY system_id, orbit_index"); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map(params_ref.as_slice(), |row| { + Ok(BodyRow { + body_id: row.get(0)?, + system_id: row.get(1)?, + parent_body_id: row.get(2)?, + body_type: row.get(3)?, + orbit_index: row.get(4)?, + proper_name: row.get(5)?, + mass_class: row.get(6)?, + atmosphere: row.get(7)?, + surface_gravity: row.get(8)?, + biome_summary: row.get(9)?, + hydrosphere: row.get(10)?, + inhabited: row.get::<_, i32>(11)? != 0, + population: row.get::<_, Option>(12)?.unwrap_or(0), + economic_role: row.get(13)?, + cultural_corridor: row.get(14)?, + industrial_corridor: row.get(15)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn query_stations( + conn: &Connection, + system: Option<&str>, + station_type: Option<&str>, +) -> Vec { + let mut sql = String::from( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE 1=1", + ); + let mut param_values: Vec> = Vec::new(); + + if let Some(s) = system { + sql.push_str(" AND system_id = ?"); + param_values.push(Box::new(s.to_string())); + } + if let Some(t) = station_type { + sql.push_str(" AND station_type = ?"); + param_values.push(Box::new(t.to_string())); + } + sql.push_str(" ORDER BY system_id, station_id"); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map(params_ref.as_slice(), |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec { + let mut stmt = conn + .prepare( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE orbits_body_id = ?1 ORDER BY station_id", + ) + .unwrap(); + stmt.query_map(params![body_id], |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// --------------------------------------------------------------------------- +// Shared formatting +// --------------------------------------------------------------------------- + +pub fn format_population(pop: i64) -> String { + if pop >= 1_000_000_000 { + format!("{:.1}B", pop as f64 / 1_000_000_000.0) + } else if pop >= 1_000_000 { + format!("{}M", pop / 1_000_000) + } else if pop >= 1_000 { + format!("{}K", pop / 1_000) + } else { + format!("{}", pop) + } +} diff --git a/server/src/bin/atlas/main.rs b/server/src/bin/atlas/main.rs new file mode 100644 index 000000000..fd35bf9fc --- /dev/null +++ b/server/src/bin/atlas/main.rs @@ -0,0 +1,275 @@ +//! Atlas CLI — query and manage celestial bodies and stations in systems.db. +//! +//! # Usage +//! +//! ```sh +//! # Via wrapper script (recommended): +//! tooling/atlas list-bodies --system "GJ 15A" +//! tooling/atlas list-bodies --type planet --inhabited +//! tooling/atlas show-system "GJ 15A" +//! tooling/atlas show-body "GJ 15Ab" +//! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab" +//! tooling/atlas stats +//! tooling/atlas corridor-status # remaining systems by corridor/hop +//! tooling/atlas populate # bulk classifier pass +//! +//! # Direct: +//! cargo run --bin atlas -- [args] +//! ``` + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +mod author; +mod common; +mod mutate; +mod show; +mod stats; +mod sync_wiki; +mod systems; + +use common::{open_db, resolve_db_path}; + +// --------------------------------------------------------------------------- +// CLI structure +// --------------------------------------------------------------------------- + +#[derive(Parser)] +#[command( + name = "atlas", + about = "Query and manage celestial bodies and stations in systems.db" +)] +struct Cli { + /// Path to the SQLite database (default: auto-detect from worktree) + #[arg(long, global = true)] + db: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Show full system hierarchy (star → bodies → stations) + ShowSystem { + /// System ID (e.g., "GJ 15A") + system_id: String, + }, + /// Show a single body's details + ShowBody { + /// Body ID (e.g., "GJ 15Ab") + body_id: String, + }, + /// Show a single station's details + ShowStation { + /// Station ID (e.g., "GJ 15Ab-S1") + station_id: String, + }, + /// List bodies with optional filters + ListBodies { + #[arg(long)] + system: Option, + #[arg(long, value_name = "TYPE")] + r#type: Option, + #[arg(long)] + inhabited: bool, + #[arg(long)] + unnamed: bool, + }, + /// List stations with optional filters + ListStations { + #[arg(long)] + system: Option, + #[arg(long, value_name = "TYPE")] + r#type: Option, + }, + /// Add a body record + AddBody { + #[arg(long)] + id: String, + #[arg(long)] + system: String, + #[arg(long, value_name = "TYPE")] + r#type: String, + #[arg(long)] + orbit: Option, + #[arg(long)] + name: Option, + #[arg(long)] + parent: Option, + #[arg(long)] + mass_class: Option, + #[arg(long)] + atmosphere: Option, + #[arg(long)] + gravity: Option, + #[arg(long)] + biome: Option, + #[arg(long)] + inhabited: bool, + #[arg(long)] + population: Option, + }, + /// Add a station record + AddStation { + #[arg(long)] + id: String, + #[arg(long)] + system: String, + #[arg(long)] + orbits: Option, + #[arg(long, value_name = "TYPE")] + r#type: String, + #[arg(long)] + name: Option, + #[arg(long)] + population: Option, + #[arg(long)] + docking: Option, + #[arg(long)] + gate: bool, + }, + /// Database statistics + Stats, + /// Generate a body/station proposal for a single system (writes JSON file for review) + Author { + /// System ID (e.g., "GJ 71") + system_id: String, + /// Output directory for proposal JSON (default: docs/atlas/proposals/) + #[arg(long, default_value = "docs/atlas/proposals")] + outdir: String, + /// Path to wiki directory (default: wiki/star-systems/) + #[arg(long, default_value = "wiki/star-systems")] + wiki: String, + }, + /// Commit an approved proposal JSON to the database + CommitSystem { + /// Path to the proposal JSON file + path: String, + }, + /// Wipe all bodies and stations for a single system + WipeSystem { + /// System ID + system_id: String, + }, + /// List systems with optional filters + ListSystems { + /// Filter by geographic sector (e.g., "west_reach", "east_reach") + #[arg(long)] + sector: Option, + /// Filter by hop distance + #[arg(long)] + hop: Option, + /// Only show systems that have bodies authored + #[arg(long)] + finished: bool, + /// Only show systems that have NO bodies yet + #[arg(long)] + unfinished: bool, + }, + /// List systems that have no bodies yet + Unfinished { + /// Filter by geographic sector + #[arg(long)] + sector: Option, + }, + /// List unfinished systems at a specific hop distance (or next available hop) + Next { + /// Hop distance (omit to find the lowest hop with unfinished systems) + hop: Option, + /// Filter by geographic sector + #[arg(long)] + sector: Option, + }, + /// Sync body/station data into wiki page for a system (or all systems) + SyncWiki { + /// System ID (omit for all systems with bodies) + system_id: Option, + /// Path to wiki directory (default: wiki/star-systems/) + #[arg(long, default_value = "wiki/star-systems")] + wiki: String, + }, + /// Show remaining unfinished systems grouped by geographic sector and hop distance + CorridorStatus, +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +fn main() { + let cli = Cli::parse(); + let db_path = resolve_db_path(cli.db); + let conn = open_db(&db_path); + + match &cli.command { + Commands::ShowSystem { system_id } => show::cmd_show_system(&conn, system_id), + Commands::ShowBody { body_id } => show::cmd_show_body(&conn, body_id), + Commands::ShowStation { station_id } => show::cmd_show_station(&conn, station_id), + Commands::ListBodies { + system, + r#type, + inhabited, + unnamed, + } => systems::cmd_list_bodies( + &conn, + system.as_deref(), + r#type.as_deref(), + *inhabited, + *unnamed, + ), + Commands::ListStations { system, r#type } => { + systems::cmd_list_stations(&conn, system.as_deref(), r#type.as_deref()) + } + Commands::AddBody { + id, + system, + r#type, + orbit, + name, + parent, + mass_class, + atmosphere, + gravity, + biome, + inhabited, + population, + } => mutate::cmd_add_body( + &conn, id, system, r#type, *orbit, name, parent, mass_class, atmosphere, *gravity, + biome, *inhabited, *population, + ), + Commands::AddStation { + id, + system, + orbits, + r#type, + name, + population, + docking, + gate, + } => mutate::cmd_add_station( + &conn, id, system, orbits, r#type, name, *population, docking, *gate, + ), + Commands::Stats => stats::cmd_stats(&conn), + Commands::Author { + system_id, + outdir, + wiki, + } => author::cmd_author(&conn, system_id, outdir, wiki), + Commands::CommitSystem { path } => author::cmd_commit_system(&conn, path), + Commands::WipeSystem { system_id } => mutate::cmd_wipe_system(&conn, system_id), + Commands::ListSystems { + sector, + hop, + finished, + unfinished, + } => systems::cmd_list_systems(&conn, sector.as_deref(), *hop, *finished, *unfinished), + Commands::Unfinished { sector } => systems::cmd_unfinished(&conn, sector.as_deref()), + Commands::Next { hop, sector } => systems::cmd_next(&conn, *hop, sector.as_deref()), + Commands::SyncWiki { system_id, wiki } => { + sync_wiki::cmd_sync_wiki(&conn, system_id.as_deref(), wiki) + } + Commands::CorridorStatus => systems::cmd_corridor_status(&conn), + } +} diff --git a/server/src/bin/atlas/mutate.rs b/server/src/bin/atlas/mutate.rs new file mode 100644 index 000000000..080dec933 --- /dev/null +++ b/server/src/bin/atlas/mutate.rs @@ -0,0 +1,109 @@ +use std::process; + +use rusqlite::{params, Connection}; +use serde::Serialize; + +pub fn cmd_add_body( + conn: &Connection, + id: &str, + system: &str, + r#type: &str, + orbit: Option, + name: &Option, + parent: &Option, + mass_class: &Option, + atmosphere: &Option, + gravity: Option, + biome: &Option, + inhabited: bool, + population: Option, +) { + conn.execute( + "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, inhabited, population) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + id, + system, + parent, + r#type, + orbit, + name, + mass_class, + atmosphere, + gravity, + biome, + inhabited as i32, + population.unwrap_or(0), + ], + ) + .unwrap_or_else(|e| { + eprintln!("error: {}", e); + process::exit(1); + }); + + println!(r#"{{"ok": true, "body_id": "{}"}}"#, id); +} + +pub fn cmd_add_station( + conn: &Connection, + id: &str, + system: &str, + orbits: &Option, + r#type: &str, + name: &Option, + population: Option, + docking: &Option, + gate: bool, +) { + conn.execute( + "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, + proper_name, population, docking_class, has_gate_infrastructure) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, + system, + orbits, + r#type, + name, + population.unwrap_or(0), + docking, + gate as i32, + ], + ) + .unwrap_or_else(|e| { + eprintln!("error: {}", e); + process::exit(1); + }); + + println!(r#"{{"ok": true, "station_id": "{}"}}"#, id); +} + +pub fn cmd_wipe_system(conn: &Connection, system_id: &str) { + let stations_deleted: usize = conn + .execute( + "DELETE FROM stations WHERE system_id = ?1", + params![system_id], + ) + .unwrap(); + let bodies_deleted: usize = conn + .execute( + "DELETE FROM bodies WHERE system_id = ?1", + params![system_id], + ) + .unwrap(); + + #[derive(Serialize)] + struct WipeResult { + system_id: String, + bodies_deleted: usize, + stations_deleted: usize, + } + let result = WipeResult { + system_id: system_id.to_string(), + bodies_deleted, + stations_deleted, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} diff --git a/server/src/bin/atlas/show.rs b/server/src/bin/atlas/show.rs new file mode 100644 index 000000000..08fbe1877 --- /dev/null +++ b/server/src/bin/atlas/show.rs @@ -0,0 +1,127 @@ +use std::process; + +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::common::{ + query_bodies, query_stations, query_stations_for_body, BodyRow, StationRow, SystemSummary, +}; + +pub fn cmd_show_system(conn: &Connection, system_id: &str) { + let mut stmt = conn + .prepare( + "SELECT system_id, proper_name, star_type, geographic_sector, + habitable_planet_count, inhabited_planet_count + FROM star_systems WHERE system_id = ?1", + ) + .unwrap(); + + let sys: Option = stmt + .query_row(params![system_id], |row| { + Ok(SystemSummary { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + geographic_sector: row.get(3)?, + habitable_planet_count: row.get(4)?, + inhabited_planet_count: row.get(5)?, + bodies: Vec::new(), + stations: Vec::new(), + }) + }) + .ok(); + + let Some(mut sys) = sys else { + eprintln!("error: system '{}' not found", system_id); + process::exit(1); + }; + + sys.bodies = query_bodies(conn, Some(system_id), None, false, false); + sys.stations = query_stations(conn, Some(system_id), None); + + println!("{}", serde_json::to_string_pretty(&sys).unwrap()); +} + +pub fn cmd_show_body(conn: &Connection, body_id: &str) { + let row = conn + .query_row( + "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, hydrosphere, inhabited, population, + economic_role, cultural_corridor, industrial_corridor + FROM bodies WHERE body_id = ?1", + params![body_id], + |row| { + Ok(BodyRow { + body_id: row.get(0)?, + system_id: row.get(1)?, + parent_body_id: row.get(2)?, + body_type: row.get(3)?, + orbit_index: row.get(4)?, + proper_name: row.get(5)?, + mass_class: row.get(6)?, + atmosphere: row.get(7)?, + surface_gravity: row.get(8)?, + biome_summary: row.get(9)?, + hydrosphere: row.get(10)?, + inhabited: row.get::<_, i32>(11)? != 0, + population: row.get::<_, Option>(12)?.unwrap_or(0), + economic_role: row.get(13)?, + cultural_corridor: row.get(14)?, + industrial_corridor: row.get(15)?, + }) + }, + ) + .unwrap_or_else(|_| { + eprintln!("error: body '{}' not found", body_id); + process::exit(1); + }); + + // Also fetch stations orbiting this body + let stations = query_stations_for_body(conn, body_id); + + #[derive(Serialize)] + struct BodyDetail { + #[serde(flatten)] + body: BodyRow, + stations: Vec, + } + + let detail = BodyDetail { + body: row, + stations, + }; + println!("{}", serde_json::to_string_pretty(&detail).unwrap()); +} + +pub fn cmd_show_station(conn: &Connection, station_id: &str) { + let row = conn + .query_row( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE station_id = ?1", + params![station_id], + |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }, + ) + .unwrap_or_else(|_| { + eprintln!("error: station '{}' not found", station_id); + process::exit(1); + }); + + println!("{}", serde_json::to_string_pretty(&row).unwrap()); +} diff --git a/server/src/bin/atlas/stats.rs b/server/src/bin/atlas/stats.rs new file mode 100644 index 000000000..e92458ef3 --- /dev/null +++ b/server/src/bin/atlas/stats.rs @@ -0,0 +1,90 @@ +use rusqlite::Connection; +use serde::Serialize; + +#[derive(Serialize)] +struct StatsOutput { + systems: i64, + bodies: i64, + bodies_by_type: Vec, + inhabited_bodies: i64, + stations: i64, + stations_by_type: Vec, + systems_with_bodies: i64, + systems_without_bodies: i64, +} + +#[derive(Serialize)] +struct TypeCount { + r#type: String, + count: i64, +} + +pub fn cmd_stats(conn: &Connection) { + let systems: i64 = conn + .query_row("SELECT COUNT(*) FROM star_systems", [], |r| r.get(0)) + .unwrap(); + let bodies: i64 = conn + .query_row("SELECT COUNT(*) FROM bodies", [], |r| r.get(0)) + .unwrap(); + let inhabited_bodies: i64 = conn + .query_row("SELECT COUNT(*) FROM bodies WHERE inhabited = 1", [], |r| { + r.get(0) + }) + .unwrap(); + let stations: i64 = conn + .query_row("SELECT COUNT(*) FROM stations", [], |r| r.get(0)) + .unwrap(); + let systems_with_bodies: i64 = conn + .query_row("SELECT COUNT(DISTINCT system_id) FROM bodies", [], |r| { + r.get(0) + }) + .unwrap(); + + let mut bodies_by_type = Vec::new(); + { + let mut stmt = conn + .prepare("SELECT body_type, COUNT(*) FROM bodies GROUP BY body_type ORDER BY body_type") + .unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(TypeCount { + r#type: row.get(0)?, + count: row.get(1)?, + }) + }) + .unwrap(); + for r in rows.flatten() { + bodies_by_type.push(r); + } + } + + let mut stations_by_type = Vec::new(); + { + let mut stmt = conn + .prepare("SELECT station_type, COUNT(*) FROM stations GROUP BY station_type ORDER BY station_type") + .unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(TypeCount { + r#type: row.get(0)?, + count: row.get(1)?, + }) + }) + .unwrap(); + for r in rows.flatten() { + stations_by_type.push(r); + } + } + + let stats = StatsOutput { + systems, + bodies, + bodies_by_type, + inhabited_bodies, + stations, + stations_by_type, + systems_with_bodies, + systems_without_bodies: systems - systems_with_bodies, + }; + println!("{}", serde_json::to_string_pretty(&stats).unwrap()); +} diff --git a/server/src/bin/atlas/sync_wiki.rs b/server/src/bin/atlas/sync_wiki.rs new file mode 100644 index 000000000..86e1ebed3 --- /dev/null +++ b/server/src/bin/atlas/sync_wiki.rs @@ -0,0 +1,319 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::common::format_population; + +pub fn cmd_sync_wiki(conn: &Connection, system_id: Option<&str>, wiki_dir: &str) { + // Get list of systems to sync + let system_ids: Vec = if let Some(sid) = system_id { + vec![sid.to_string()] + } else { + let mut stmt = conn + .prepare("SELECT DISTINCT system_id FROM bodies ORDER BY system_id") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + let mut synced = 0; + + for sid in &system_ids { + let sid_slug = sid.replace(' ', "-"); + let wiki_path = std::path::Path::new(wiki_dir) + .join(&sid_slug) + .join("index.md"); + if !wiki_path.exists() { + eprintln!("skip: {} — no wiki page at {}", sid, wiki_path.display()); + continue; + } + + // Query bodies + let mut body_stmt = conn + .prepare( + "SELECT body_id, proper_name, body_type, orbit_index, parent_body_id, + inhabited, population, mass_class, atmosphere, surface_gravity, + orbital_period_days, rotation_period_hours, + biome_summary, hydrosphere, economic_role, settlement_pattern, + industrial_corridor + FROM bodies WHERE system_id = ?1 + ORDER BY CASE WHEN parent_body_id IS NULL THEN orbit_index ELSE 1000 + orbit_index END", + ) + .unwrap(); + + struct Body { + id: String, + name: Option, + btype: String, + orbit: i32, + parent: Option, + inhabited: bool, + population: i64, + mass_class: Option, + atmosphere: Option, + gravity: Option, + orbital_days: Option, + rotation_hours: Option, + biome: Option, + hydro: Option, + econ: Option, + settlement: Option, + industrial: Option, + } + + let bodies: Vec = body_stmt + .query_map(params![sid], |row| { + Ok(Body { + id: row.get(0)?, + name: row.get(1)?, + btype: row.get(2)?, + orbit: row.get::<_, Option>(3)?.unwrap_or(0), + parent: row.get(4)?, + inhabited: row.get::<_, i32>(5)? != 0, + population: row.get::<_, Option>(6)?.unwrap_or(0), + mass_class: row.get(7)?, + atmosphere: row.get(8)?, + gravity: row.get(9)?, + orbital_days: row.get(10)?, + rotation_hours: row.get(11)?, + biome: row.get(12)?, + hydro: row.get(13)?, + econ: row.get(14)?, + settlement: row.get(15)?, + industrial: row.get(16)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + // Query stations + let mut station_stmt = conn + .prepare( + "SELECT station_id, proper_name, orbits_body_id, station_type, + population, economic_role, governance_type, docking_class, + has_gate_infrastructure, district_count + FROM stations WHERE system_id = ?1 + ORDER BY station_id", + ) + .unwrap(); + + struct Station { + id: String, + name: Option, + orbits: Option, + stype: String, + population: i64, + econ: Option, + governance: Option, + docking: Option, + gate: bool, + districts: i32, + } + + let stations: Vec = station_stmt + .query_map(params![sid], |row| { + Ok(Station { + id: row.get(0)?, + name: row.get(1)?, + orbits: row.get(2)?, + stype: row.get(3)?, + population: row.get::<_, Option>(4)?.unwrap_or(0), + econ: row.get(5)?, + governance: row.get(6)?, + docking: row.get(7)?, + gate: row.get::<_, Option>(8)?.unwrap_or(0) != 0, + districts: row.get::<_, Option>(9)?.unwrap_or(1), + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + if bodies.is_empty() && stations.is_empty() { + continue; + } + + // Build the Celestial Bodies section + let mut section = String::new(); + section.push_str("## Celestial Bodies\n"); + section + .push_str("\n\n"); + + // Bodies table + if !bodies.is_empty() { + section.push_str("| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |\n"); + section.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n"); + + // First pass: top-level bodies (no parent) + for b in &bodies { + if b.parent.is_some() { + continue; + } + let name = b.name.as_deref().unwrap_or("—"); + let pop = if b.population > 0 { + format_population(b.population) + } else { + "—".to_string() + }; + let grav = b + .gravity + .map(|g| format!("{:.2}g", g)) + .unwrap_or_else(|| "—".to_string()); + let year = b + .orbital_days + .map(|d| format!("{:.0}", d)) + .unwrap_or_else(|| "—".to_string()); + let day = b + .rotation_hours + .map(|h| format!("{:.1}", h)) + .unwrap_or_else(|| "—".to_string()); + section.push_str(&format!( + "| {} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", + b.orbit, + b.id, + name, + b.btype, + if b.inhabited { "yes" } else { "no" }, + pop, + b.mass_class.as_deref().unwrap_or("—"), + grav, + year, + day, + b.atmosphere.as_deref().unwrap_or("—"), + b.biome.as_deref().unwrap_or("—"), + b.hydro.as_deref().unwrap_or("—"), + b.econ.as_deref().unwrap_or("—"), + b.settlement.as_deref().unwrap_or("—"), + b.industrial.as_deref().unwrap_or("—"), + )); + + // Child bodies (moons) + for m in &bodies { + if m.parent.as_deref() == Some(&b.id) { + let mname = m.name.as_deref().unwrap_or("—"); + let mpop = if m.population > 0 { + format_population(m.population) + } else { + "—".to_string() + }; + let mgrav = m + .gravity + .map(|g| format!("{:.2}g", g)) + .unwrap_or_else(|| "—".to_string()); + let myear = m + .orbital_days + .map(|d| format!("{:.0}", d)) + .unwrap_or_else(|| "—".to_string()); + let mday = m + .rotation_hours + .map(|h| format!("{:.1}", h)) + .unwrap_or_else(|| "—".to_string()); + section.push_str(&format!( + "| ↳ {}.{} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n", + b.orbit, + m.orbit, + m.id, + mname, + m.btype, + if m.inhabited { "yes" } else { "no" }, + mpop, + m.mass_class.as_deref().unwrap_or("—"), + mgrav, + myear, + mday, + m.atmosphere.as_deref().unwrap_or("—"), + m.biome.as_deref().unwrap_or("—"), + m.hydro.as_deref().unwrap_or("—"), + m.econ.as_deref().unwrap_or("—"), + m.settlement.as_deref().unwrap_or("—"), + m.industrial.as_deref().unwrap_or("—"), + )); + } + } + } + section.push('\n'); + } + + // Stations table + if !stations.is_empty() { + section.push_str("### Stations & Facilities\n\n"); + section.push_str("| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |\n"); + section.push_str("|---|---|---|---|---|---|---|---|---|---|\n"); + for s in &stations { + let name = s.name.as_deref().unwrap_or("—"); + let orbits = s.orbits.as_deref().unwrap_or("—"); + let pop = if s.population > 0 { + format_population(s.population) + } else { + "—".to_string() + }; + section.push_str(&format!( + "| `{}` | {} | {} | `{}` | {} | {} | {} | {} | {} | {} |\n", + s.id, + name, + s.stype, + orbits, + pop, + s.econ.as_deref().unwrap_or("—"), + s.governance.as_deref().unwrap_or("—"), + s.docking.as_deref().unwrap_or("—"), + if s.gate { "yes" } else { "no" }, + s.districts, + )); + } + section.push('\n'); + } + + // Read current wiki content + let content = std::fs::read_to_string(&wiki_path).unwrap(); + + // Replace or insert the Celestial Bodies section + let marker_start = "## Celestial Bodies"; + let new_content = if let Some(start_pos) = content.find(marker_start) { + // Find the next ## heading after the section (or end of file) + let after = &content[start_pos + marker_start.len()..]; + let end_offset = after + .find("\n## ") + .map(|p| start_pos + marker_start.len() + p + 1) + .unwrap_or(content.len()); + format!( + "{}{}\n{}", + &content[..start_pos], + section.trim_end(), + &content[end_offset..] + ) + } else { + // Insert before ## Topology if it exists, otherwise before end of file + if let Some(topo_pos) = content.find("\n## Topology") { + let insert_pos = topo_pos + 1; // after the newline + format!( + "{}{}\n\n{}", + &content[..insert_pos], + section.trim_end(), + &content[insert_pos..] + ) + } else { + // No topology section — append at end + format!("{}\n{}", content.trim_end(), section) + } + }; + + std::fs::write(&wiki_path, new_content).unwrap(); + eprintln!("synced: {} → {}", sid, wiki_path.display()); + synced += 1; + } + + #[derive(Serialize)] + struct SyncResult { + systems_synced: usize, + } + println!( + "{}", + serde_json::to_string_pretty(&SyncResult { + systems_synced: synced + }) + .unwrap() + ); +} diff --git a/server/src/bin/atlas/systems.rs b/server/src/bin/atlas/systems.rs new file mode 100644 index 000000000..c51957ddf --- /dev/null +++ b/server/src/bin/atlas/systems.rs @@ -0,0 +1,382 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::common::{query_bodies, query_stations}; + +pub fn cmd_list_bodies( + conn: &Connection, + system: Option<&str>, + body_type: Option<&str>, + inhabited: bool, + unnamed: bool, +) { + let bodies = query_bodies(conn, system, body_type, inhabited, unnamed); + println!("{}", serde_json::to_string_pretty(&bodies).unwrap()); +} + +pub fn cmd_list_stations(conn: &Connection, system: Option<&str>, station_type: Option<&str>) { + let stations = query_stations(conn, system, station_type); + println!("{}", serde_json::to_string_pretty(&stations).unwrap()); +} + +pub fn cmd_list_systems( + conn: &Connection, + sector: Option<&str>, + hop: Option, + finished: bool, + unfinished: bool, +) { + // Build query dynamically based on filters + let mut conditions: Vec = Vec::new(); + let mut param_values: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(s) = sector { + conditions.push(format!("s.geographic_sector = ?{idx}")); + param_values.push(Box::new(s.to_string())); + idx += 1; + } + if let Some(h) = hop { + conditions.push(format!("g.hop_distance_from_gateway = ?{idx}")); + param_values.push(Box::new(h)); + idx += 1; + } + let _ = idx; // suppress unused warning + if finished { + conditions.push("s.system_id IN (SELECT DISTINCT system_id FROM bodies)".to_string()); + } + if unfinished { + conditions.push("s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)".to_string()); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + g.hop_distance_from_gateway, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + {where_clause} + ORDER BY g.hop_distance_from_gateway, s.system_id" + ); + + let mut stmt = conn.prepare(&sql).unwrap(); + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + + #[derive(Serialize)] + struct ListSystem { + system_id: String, + proper_name: Option, + star_type: Option, + spectral_class: Option, + gate_topology: Option, + geographic_sector: Option, + hop_distance: Option, + habitable_planet_count: Option, + inhabited_planet_count: Option, + population: Option, + } + + let systems: Vec = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(ListSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + spectral_class: row.get(3)?, + gate_topology: row.get(4)?, + geographic_sector: row.get(5)?, + hop_distance: row.get(6)?, + habitable_planet_count: row.get(7)?, + inhabited_planet_count: row.get(8)?, + population: row.get(9)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + #[derive(Serialize)] + struct ListResult { + count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + sector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hop: Option, + systems: Vec, + } + + let result = ListResult { + count: systems.len(), + sector: sector.map(|s| s.to_string()), + hop, + systems, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} + +pub fn cmd_unfinished(conn: &Connection, sector: Option<&str>) { + let sql = if sector.is_some() { + "SELECT s.system_id, s.proper_name, s.star_type + FROM star_systems s + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + AND s.geographic_sector = ?1 + ORDER BY s.system_id" + } else { + "SELECT s.system_id, s.proper_name, s.star_type + FROM star_systems s + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + ORDER BY s.system_id" + }; + + let mut stmt = conn.prepare(sql).unwrap(); + + #[derive(Serialize)] + struct UnfinishedSystem { + system_id: String, + proper_name: Option, + star_type: Option, + } + + let rows: Vec = if let Some(s) = sector { + stmt.query_map(params![s], |row| { + Ok(UnfinishedSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map([], |row| { + Ok(UnfinishedSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + #[derive(Serialize)] + struct UnfinishedResult { + count: usize, + sector: Option, + systems: Vec, + } + let result = UnfinishedResult { + count: rows.len(), + sector: sector.map(|s| s.to_string()), + systems: rows, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} + +pub fn cmd_next(conn: &Connection, hop: Option, sector: Option<&str>) { + // Find the target hop — either specified or the lowest with unfinished systems + let target_hop: i32 = if let Some(h) = hop { + h + } else { + let sql = if sector.is_some() { + "SELECT MIN(g.hop_distance_from_gateway) + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + AND s.geographic_sector = ?1" + } else { + "SELECT MIN(g.hop_distance_from_gateway) + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)" + }; + if let Some(s) = sector { + conn.query_row(sql, params![s], |r| r.get::<_, Option>(0)) + } else { + conn.query_row(sql, [], |r| r.get::<_, Option>(0)) + } + .unwrap() + .unwrap_or(-1) + }; + + if target_hop < 0 { + println!( + r#"{{"hop": null, "count": 0, "systems": [], "message": "all systems have bodies"}}"# + ); + return; + } + + #[derive(Serialize)] + struct NextSystem { + system_id: String, + proper_name: Option, + star_type: Option, + spectral_class: Option, + gate_topology: Option, + geographic_sector: Option, + habitable_planet_count: Option, + inhabited_planet_count: Option, + population: Option, + } + + let sql = if sector.is_some() { + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + WHERE g.hop_distance_from_gateway = ?1 + AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + AND s.geographic_sector = ?2 + ORDER BY s.system_id" + } else { + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + WHERE g.hop_distance_from_gateway = ?1 + AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + ORDER BY s.system_id" + }; + + let mut stmt = conn.prepare(sql).unwrap(); + + let systems: Vec = if let Some(s) = sector { + stmt.query_map(params![target_hop, s], |row| { + Ok(NextSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + spectral_class: row.get(3)?, + gate_topology: row.get(4)?, + geographic_sector: row.get(5)?, + habitable_planet_count: row.get(6)?, + inhabited_planet_count: row.get(7)?, + population: row.get(8)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } else { + stmt.query_map(params![target_hop], |row| { + Ok(NextSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + spectral_class: row.get(3)?, + gate_topology: row.get(4)?, + geographic_sector: row.get(5)?, + habitable_planet_count: row.get(6)?, + inhabited_planet_count: row.get(7)?, + population: row.get(8)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + #[derive(Serialize)] + struct NextResult { + hop: i32, + sector: Option, + count: usize, + systems: Vec, + } + + let result = NextResult { + hop: target_hop, + sector: sector.map(|s| s.to_string()), + count: systems.len(), + systems, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} + +pub fn cmd_corridor_status(conn: &Connection) { + // Query unfinished systems grouped by geographic_sector and hop_distance_from_gateway. + // "Unfinished" = no rows in bodies for this system_id. + // LEFT JOIN so systems with no gate record are still counted (hop = NULL → "?"). + let mut stmt = conn + .prepare( + "SELECT s.geographic_sector, g.hop_distance_from_gateway, COUNT(*) AS remaining + FROM star_systems s + LEFT JOIN system_gates g ON s.system_id = g.system_id + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + GROUP BY s.geographic_sector, g.hop_distance_from_gateway + ORDER BY s.geographic_sector, g.hop_distance_from_gateway", + ) + .unwrap(); + + #[derive(Serialize)] + struct CorridorRow { + sector: Option, + hop: Option, + remaining: i64, + } + + let rows: Vec = stmt + .query_map([], |row| { + Ok(CorridorRow { + sector: row.get(0)?, + hop: row.get(1)?, + remaining: row.get(2)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + #[derive(Serialize)] + struct EmptyResult { + total: i64, + message: &'static str, + corridors: Vec<()>, + } + println!( + "{}", + serde_json::to_string_pretty(&EmptyResult { + total: 0, + message: "All systems have bodies authored.", + corridors: vec![], + }) + .unwrap() + ); + return; + } + + let total: i64 = rows.iter().map(|r| r.remaining).sum(); + + #[derive(Serialize)] + struct CorridorResult { + total: i64, + corridors: Vec, + } + println!( + "{}", + serde_json::to_string_pretty(&CorridorResult { + total, + corridors: rows, + }) + .unwrap() + ); +}