diff --git a/.claude/skills/atlas/SKILL.md b/.claude/skills/atlas/SKILL.md new file mode 100644 index 000000000..fdf0b58de --- /dev/null +++ b/.claude/skills/atlas/SKILL.md @@ -0,0 +1,143 @@ +--- +name: atlas +description: > + Query and manage celestial bodies and stations in systems.db. Use when the user + says "atlas", "show system", "list bodies", "list stations", "populate bodies", + "show body", or invokes /atlas. Wraps the Rust atlas CLI binary. +user-invocable: true +allowed-tools: Bash, Read, Grep, Glob +--- + +# Atlas Skill + +Query and manage celestial bodies (planets, moons, gas giants, asteroid belts, +oort clouds) and stations in `server/data/systems.db`. + +**CLI binary:** `tooling/atlas` (wraps `cargo run --bin atlas`) +**Database:** `server/data/systems.db` (auto-detected from working directory) +**All output is JSON on stdout.** + +## Commands + +### Show full system hierarchy +```bash +tooling/atlas show-system "GJ 15A" +``` +Returns: system info + all bodies + all stations in the system. + +### Show a single body +```bash +tooling/atlas show-body "GJ15Ab" +``` +Returns: body details + stations orbiting that body. + +### Show a single station +```bash +tooling/atlas show-station "GJ15Ab-S1" +``` + +### List bodies with filters +```bash +tooling/atlas list-bodies +tooling/atlas list-bodies --system "GJ 15A" +tooling/atlas list-bodies --type planet +tooling/atlas list-bodies --type planet --inhabited +tooling/atlas list-bodies --unnamed +``` +Types: `planet`, `moon`, `gas_giant`, `asteroid_belt`, `oort_cloud` + +### List stations with filters +```bash +tooling/atlas list-stations +tooling/atlas list-stations --system "GJ 15A" +tooling/atlas list-stations --type horizon +``` +Types: `horizon`, `commercial`, `military`, `research`, `industrial`, `agricultural`, `transit` + +### Add a body +```bash +tooling/atlas add-body \ + --id "GJ15Ab" \ + --system "GJ 15A" \ + --type planet \ + --orbit 1 \ + --name "Xin Chengdu" \ + --mass-class terrestrial \ + --atmosphere breathable \ + --gravity 0.87 \ + --biome temperate \ + --inhabited \ + --population 1800000 +``` + +### Add a station +```bash +tooling/atlas add-station \ + --id "GJ15Ab-S1" \ + --system "GJ 15A" \ + --orbits "GJ15Ab" \ + --type commercial \ + --name "Chengdu Orbital" \ + --population 420000 \ + --docking major +``` + +### Database statistics +```bash +tooling/atlas stats +``` +Returns: system count, body count by type, inhabited count, station count by type. + +### Bulk populate from system data (classifier pass) +```bash +tooling/atlas populate --dry-run # preview what would be created +tooling/atlas populate # create body/station records +``` +Reads `habitable_planet_count`, `inhabited_planet_count`, `gas_giant`, +`asteroid_belt`, and `horizon_station` from existing system data. +Creates: planets (inhabited first), gas giants, asteroid belts, oort cloud per +system, horizon station per oort cloud. Skips systems that already have bodies. + +## Body ID Naming Convention + +``` +GJ-{n} — star (single) +GJ-{n}A — primary star (binary) +GJ-{n}B — secondary star (binary) +GJ-{n}b/c/d... — planets, innermost first +GJ-{n}Ab/c/d... — planets orbiting primary only +GJ-{n}Bb/c/d... — planets orbiting secondary only +GJ-{n}d-1 — first moon of third planet +GJ-{n}d-S1 — first station orbiting third planet +GJ-{n}-oort — oort cloud region +GJ-{n}-belt — asteroid belt +GJ-{n}-oort-S1 — horizon station in oort cloud +``` + +## Entity Hierarchy + +``` +System (star_systems) + └─ Body (bodies) — planet, moon, gas_giant, asteroid_belt, oort_cloud + └─ Station (stations) — horizon, commercial, military, research, etc. +``` + +Bodies have a self-referential `parent_body_id` for moons → planet relationships. +Stations reference `orbits_body_id` for what they orbit. + +## Scope — A True Atlas + +This skill will grow to cover the full geographic hierarchy of the Reach: + +- **Galactic:** 301 systems, gate topology, sector/corridor data +- **System:** orbital bodies, stations, oort cloud +- **Planetary surface:** continents, oceans, mountain ranges, rivers, biome regions +- **Settlement:** cities, towns, villages, outposts, rail lines, road hierarchy +- **District:** neighborhoods, zones, named areas/provinces +- **Local:** named locations, landmarks, facilities + +As the development cascade progresses through Phase 1 (wiki content) → Phase 3 +(planetary maps / Atlas of the Reach), the atlas CLI and its schema will extend +to cover each level. The goal is a single queryable geographic database from +galaxy scale to street level — the in-game implant's atlas app reads from this +same data. diff --git a/docs/backups/settledreach.db.backup b/docs/backups/settledreach.db.backup index 49b0f2778..f996fba14 100644 Binary files a/docs/backups/settledreach.db.backup and b/docs/backups/settledreach.db.backup differ diff --git a/server/src/bin/atlas.rs b/server/src/bin/atlas.rs new file mode 100644 index 000000000..a80d7b332 --- /dev/null +++ b/server/src/bin/atlas.rs @@ -0,0 +1,875 @@ +//! 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 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, + /// Bulk populate bodies from existing system data (classifier pass) + Populate { + /// Dry run — show what would be created without writing + #[arg(long)] + dry_run: bool, + }, +} + +// --------------------------------------------------------------------------- +// 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)] +struct PopulateAction { + action: String, + id: String, + system_id: String, + body_type: String, + orbit_index: 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 cmd_populate(conn: &Connection, dry_run: bool) { + // Read all systems with their physical properties + let mut stmt = conn + .prepare( + "SELECT s.system_id, s.habitable_planet_count, s.inhabited_planet_count, + s.asteroid_belt, s.gas_giant, s.star_type, + g.horizon_station + FROM star_systems s + LEFT JOIN system_gates g ON s.system_id = g.system_id + ORDER BY s.system_id", + ) + .unwrap(); + + struct SystemInfo { + system_id: String, + habitable: i32, + inhabited: i32, + has_belt: bool, + has_gas_giant: bool, + has_horizon: bool, + } + + let systems: Vec = stmt + .query_map([], |row| { + Ok(SystemInfo { + system_id: row.get(0)?, + habitable: row.get::<_, Option>(1)?.unwrap_or(0), + inhabited: row.get::<_, Option>(2)?.unwrap_or(0), + has_belt: row.get::<_, Option>(3)?.unwrap_or(0) != 0, + has_gas_giant: row.get::<_, Option>(4)?.unwrap_or(0) != 0, + has_horizon: row.get::<_, Option>(6)?.unwrap_or(0) != 0, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + // Check which systems already have bodies + let existing: std::collections::HashSet = { + let mut stmt = conn + .prepare("SELECT DISTINCT system_id FROM bodies") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + let mut actions: Vec = Vec::new(); + + for sys in &systems { + if existing.contains(&sys.system_id) { + continue; + } + + let sid = &sys.system_id; + // Normalize system ID for body naming (replace spaces with empty) + let sid_compact = sid.replace(' ', ""); + let mut orbit = 1; + + // Inner rocky planets (uninhabited count = habitable - inhabited, minimum 0) + // We place inhabited planets first, then habitable-but-uninhabited + let uninhabited_habitable = (sys.habitable - sys.inhabited).max(0); + + // Inhabited planets + for _ in 0..sys.inhabited { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "planet".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Habitable but uninhabited planets + for _ in 0..uninhabited_habitable { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "planet".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Asteroid belt + if sys.has_belt { + let body_id = format!("{}-belt", sid_compact); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "asteroid_belt".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Gas giant + if sys.has_gas_giant { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "gas_giant".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Oort cloud (always present — every system has one) + { + let body_id = format!("{}-oort", sid_compact); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id.clone(), + system_id: sid.clone(), + body_type: "oort_cloud".into(), + orbit_index: Some(orbit), + }); + + // Horizon station in the oort cloud + if sys.has_horizon { + actions.push(PopulateAction { + action: if dry_run { "would_create_station".into() } else { "created_station".into() }, + id: format!("{}-oort-S1", sid_compact), + system_id: sid.clone(), + body_type: "horizon".into(), + orbit_index: None, + }); + } + } + } + + // Execute if not dry run + if !dry_run { + let tx = conn.unchecked_transaction().unwrap(); + for action in &actions { + if action.action == "created" { + let inhabited = if action.body_type == "planet" { + // Check if this is one of the inhabited planets (first N by orbit) + let sys = systems.iter().find(|s| s.system_id == action.system_id).unwrap(); + action.orbit_index.unwrap_or(0) <= sys.inhabited + } else { + false + }; + + tx.execute( + "INSERT OR IGNORE INTO bodies (body_id, system_id, body_type, orbit_index, inhabited) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + action.id, + action.system_id, + action.body_type, + action.orbit_index, + inhabited as i32, + ], + ) + .unwrap(); + } else if action.action == "created_station" { + let oort_id = format!( + "{}-oort", + action.system_id.replace(' ', "") + ); + tx.execute( + "INSERT OR IGNORE INTO stations (station_id, system_id, orbits_body_id, + station_type, has_gate_infrastructure) + VALUES (?1, ?2, ?3, ?4, 1)", + params![action.id, action.system_id, oort_id, "horizon"], + ) + .unwrap(); + } + } + tx.commit().unwrap(); + } + + #[derive(Serialize)] + struct PopulateResult { + dry_run: bool, + actions: Vec, + total_bodies: usize, + total_stations: usize, + systems_populated: usize, + } + + let station_count = actions.iter().filter(|a| a.action.contains("station")).count(); + let body_count = actions.len() - station_count; + let system_count = actions + .iter() + .map(|a| &a.system_id) + .collect::>() + .len(); + + let result = PopulateResult { + dry_run, + actions, + total_bodies: body_count, + total_stations: station_count, + systems_populated: system_count, + }; + println!("{}", serde_json::to_string_pretty(&result).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::Populate { dry_run } => cmd_populate(&conn, *dry_run), + } +} diff --git a/tooling/atlas b/tooling/atlas new file mode 100755 index 000000000..621944072 --- /dev/null +++ b/tooling/atlas @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Atlas CLI wrapper — celestial bodies and stations in systems.db. +# +# Usage: +# tooling/atlas stats +# tooling/atlas show-system "GJ 15A" +# tooling/atlas list-bodies --system "GJ 15A" +# tooling/atlas populate --dry-run +# +# Builds on first run if binary doesn't exist. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BIN="$ROOT_DIR/server/target/debug/atlas" + +# Build if needed +if [ ! -f "$BIN" ]; then + echo "Building atlas..." >&2 + (cd "$ROOT_DIR/server" && cargo build --bin atlas 2>&1 | tail -3) >&2 +fi + +exec "$BIN" "$@"