refactor(simulation): split atlas.rs subcommands into modules

Extract 2119-line monolithic atlas.rs into 8 focused modules under
src/bin/atlas/: main.rs (thin dispatch), common.rs (shared types and
DB helpers), show.rs, mutate.rs, stats.rs, systems.rs, author.rs,
and sync_wiki.rs. Commands enum stays in main.rs; each match arm
delegates to module::cmd_fn(&conn, args). No behavior change.

Closes #776.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 09:37:04 +02:00
co-authored by Claude Opus 4.6
parent b3ae632530
commit fb5c8854e8
10 changed files with 2187 additions and 2120 deletions
+1 -1
View File
@@ -1236,7 +1236,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.29"
version = "0.1.30"
dependencies = [
"bevy_app",
"bevy_ecs",
File diff suppressed because it is too large Load Diff
+640
View File
@@ -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<String>,
pub body_type: String,
pub orbit_index: i32,
pub parent_body_id: Option<String>,
pub inhabited: bool,
pub population: Option<i64>,
pub mass_class: Option<String>,
pub surface_gravity: Option<f64>,
pub orbital_period_days: Option<f64>,
pub rotation_period_hours: Option<f64>,
pub atmosphere: Option<String>,
pub biome_summary: Option<String>,
pub hydrosphere: Option<String>,
pub economic_role: Option<String>,
pub settlement_pattern: Option<String>,
pub industrial_corridor: Option<String>,
pub notes: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ProposalStation {
pub station_id: String,
pub proper_name: Option<String>,
pub orbits_body_id: String,
pub station_type: String,
pub population: Option<i64>,
pub economic_role: Option<String>,
pub docking_class: Option<String>,
pub has_gate_infrastructure: bool,
pub notes: String,
}
#[derive(Serialize, Deserialize)]
pub struct SystemProposal {
pub system_id: String,
pub proper_name: Option<String>,
pub star_type: Option<String>,
pub spectral_class: Option<String>,
pub wiki_data: WikiData,
pub bodies: Vec<ProposalBody>,
pub stations: Vec<ProposalStation>,
}
#[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<String>,
}
// ---------------------------------------------------------------------------
// Author pipeline
// ---------------------------------------------------------------------------
pub fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option<String>, 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<ProposalBody>, Vec<ProposalStation>) {
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<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i32>>(4)?,
row.get::<_, Option<i32>>(5)?,
row.get::<_, Option<i32>>(6)?,
row.get::<_, Option<i32>>(7)?,
row.get::<_, Option<i32>>(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());
}
+244
View File
@@ -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<String>,
pub body_type: String,
pub orbit_index: Option<i32>,
pub proper_name: Option<String>,
pub mass_class: Option<String>,
pub atmosphere: Option<String>,
pub surface_gravity: Option<f64>,
pub biome_summary: Option<String>,
pub hydrosphere: Option<String>,
pub inhabited: bool,
pub population: i64,
pub economic_role: Option<String>,
pub cultural_corridor: Option<String>,
pub industrial_corridor: Option<String>,
}
#[derive(Serialize)]
pub struct StationRow {
pub station_id: String,
pub system_id: String,
pub orbits_body_id: Option<String>,
pub station_type: String,
pub proper_name: Option<String>,
pub population: i64,
pub economic_role: Option<String>,
pub governance_type: Option<String>,
pub docking_class: Option<String>,
pub has_gate_infrastructure: bool,
pub district_count: i32,
}
#[derive(Serialize)]
pub struct SystemSummary {
pub system_id: String,
pub proper_name: Option<String>,
pub star_type: Option<String>,
pub geographic_sector: Option<String>,
pub habitable_planet_count: Option<i32>,
pub inhabited_planet_count: Option<i32>,
pub bodies: Vec<BodyRow>,
pub stations: Vec<StationRow>,
}
// ---------------------------------------------------------------------------
// Database helpers
// ---------------------------------------------------------------------------
pub fn resolve_db_path(explicit: Option<PathBuf>) -> 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<BodyRow> {
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<Box<dyn rusqlite::types::ToSql>> = 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<i64>>(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<StationRow> {
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<Box<dyn rusqlite::types::ToSql>> = 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<i64>>(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<i32>>(9)?.unwrap_or(0) != 0,
district_count: row.get::<_, Option<i32>>(10)?.unwrap_or(1),
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
pub fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec<StationRow> {
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<i64>>(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<i32>>(9)?.unwrap_or(0) != 0,
district_count: row.get::<_, Option<i32>>(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)
}
}
+275
View File
@@ -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 -- <subcommand> [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<PathBuf>,
#[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<String>,
#[arg(long, value_name = "TYPE")]
r#type: Option<String>,
#[arg(long)]
inhabited: bool,
#[arg(long)]
unnamed: bool,
},
/// List stations with optional filters
ListStations {
#[arg(long)]
system: Option<String>,
#[arg(long, value_name = "TYPE")]
r#type: Option<String>,
},
/// 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<i32>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
parent: Option<String>,
#[arg(long)]
mass_class: Option<String>,
#[arg(long)]
atmosphere: Option<String>,
#[arg(long)]
gravity: Option<f64>,
#[arg(long)]
biome: Option<String>,
#[arg(long)]
inhabited: bool,
#[arg(long)]
population: Option<i64>,
},
/// Add a station record
AddStation {
#[arg(long)]
id: String,
#[arg(long)]
system: String,
#[arg(long)]
orbits: Option<String>,
#[arg(long, value_name = "TYPE")]
r#type: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
population: Option<i64>,
#[arg(long)]
docking: Option<String>,
#[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<String>,
/// Filter by hop distance
#[arg(long)]
hop: Option<i32>,
/// 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<String>,
},
/// 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<i32>,
/// Filter by geographic sector
#[arg(long)]
sector: Option<String>,
},
/// 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<String>,
/// 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),
}
}
+109
View File
@@ -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<i32>,
name: &Option<String>,
parent: &Option<String>,
mass_class: &Option<String>,
atmosphere: &Option<String>,
gravity: Option<f64>,
biome: &Option<String>,
inhabited: bool,
population: Option<i64>,
) {
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<String>,
r#type: &str,
name: &Option<String>,
population: Option<i64>,
docking: &Option<String>,
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());
}
+127
View File
@@ -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<SystemSummary> = 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<i64>>(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<StationRow>,
}
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<i64>>(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<i32>>(9)?.unwrap_or(0) != 0,
district_count: row.get::<_, Option<i32>>(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());
}
+90
View File
@@ -0,0 +1,90 @@
use rusqlite::Connection;
use serde::Serialize;
#[derive(Serialize)]
struct StatsOutput {
systems: i64,
bodies: i64,
bodies_by_type: Vec<TypeCount>,
inhabited_bodies: i64,
stations: i64,
stations_by_type: Vec<TypeCount>,
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());
}
+319
View File
@@ -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<String> = 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<String>,
btype: String,
orbit: i32,
parent: Option<String>,
inhabited: bool,
population: i64,
mass_class: Option<String>,
atmosphere: Option<String>,
gravity: Option<f64>,
orbital_days: Option<f64>,
rotation_hours: Option<f64>,
biome: Option<String>,
hydro: Option<String>,
econ: Option<String>,
settlement: Option<String>,
industrial: Option<String>,
}
let bodies: Vec<Body> = 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<i32>>(3)?.unwrap_or(0),
parent: row.get(4)?,
inhabited: row.get::<_, i32>(5)? != 0,
population: row.get::<_, Option<i64>>(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<String>,
orbits: Option<String>,
stype: String,
population: i64,
econ: Option<String>,
governance: Option<String>,
docking: Option<String>,
gate: bool,
districts: i32,
}
let stations: Vec<Station> = 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<i64>>(4)?.unwrap_or(0),
econ: row.get(5)?,
governance: row.get(6)?,
docking: row.get(7)?,
gate: row.get::<_, Option<i32>>(8)?.unwrap_or(0) != 0,
districts: row.get::<_, Option<i32>>(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("<!-- READ-ONLY — generated from systems.db bodies/stations tables -->\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()
);
}
+382
View File
@@ -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<i32>,
finished: bool,
unfinished: bool,
) {
// Build query dynamically based on filters
let mut conditions: Vec<String> = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = 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<String>,
star_type: Option<String>,
spectral_class: Option<String>,
gate_topology: Option<String>,
geographic_sector: Option<String>,
hop_distance: Option<i32>,
habitable_planet_count: Option<i32>,
inhabited_planet_count: Option<i32>,
population: Option<i64>,
}
let systems: Vec<ListSystem> = 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hop: Option<i32>,
systems: Vec<ListSystem>,
}
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<String>,
star_type: Option<String>,
}
let rows: Vec<UnfinishedSystem> = 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<String>,
systems: Vec<UnfinishedSystem>,
}
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<i32>, 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<i32>>(0))
} else {
conn.query_row(sql, [], |r| r.get::<_, Option<i32>>(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<String>,
star_type: Option<String>,
spectral_class: Option<String>,
gate_topology: Option<String>,
geographic_sector: Option<String>,
habitable_planet_count: Option<i32>,
inhabited_planet_count: Option<i32>,
population: Option<i64>,
}
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<NextSystem> = 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<String>,
count: usize,
systems: Vec<NextSystem>,
}
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<String>,
hop: Option<i32>,
remaining: i64,
}
let rows: Vec<CorridorRow> = 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<CorridorRow>,
}
println!(
"{}",
serde_json::to_string_pretty(&CorridorResult {
total,
corridors: rows,
})
.unwrap()
);
}