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:
Generated
+1
-1
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user