Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2044 lines
67 KiB
Rust
2044 lines
67 KiB
Rust
//! Atlas CLI — query and manage celestial bodies and stations in systems.db.
|
|
//!
|
|
//! # Usage
|
|
//!
|
|
//! ```sh
|
|
//! # Via wrapper script (recommended):
|
|
//! tooling/atlas list-bodies --system "GJ 15A"
|
|
//! tooling/atlas list-bodies --type planet --inhabited
|
|
//! tooling/atlas show-system "GJ 15A"
|
|
//! tooling/atlas show-body "GJ 15Ab"
|
|
//! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab"
|
|
//! tooling/atlas stats
|
|
//! tooling/atlas populate # bulk classifier pass
|
|
//!
|
|
//! # Direct:
|
|
//! cargo run --bin atlas -- <subcommand> [args]
|
|
//! ```
|
|
|
|
use std::path::PathBuf;
|
|
use std::process;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use rusqlite::{params, Connection};
|
|
use serde::Serialize;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI structure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "atlas",
|
|
about = "Query and manage celestial bodies and stations in systems.db"
|
|
)]
|
|
struct Cli {
|
|
/// Path to the SQLite database (default: auto-detect from worktree)
|
|
#[arg(long, global = true)]
|
|
db: Option<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,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Serialize)]
|
|
struct BodyRow {
|
|
body_id: String,
|
|
system_id: String,
|
|
parent_body_id: Option<String>,
|
|
body_type: String,
|
|
orbit_index: Option<i32>,
|
|
proper_name: Option<String>,
|
|
mass_class: Option<String>,
|
|
atmosphere: Option<String>,
|
|
surface_gravity: Option<f64>,
|
|
biome_summary: Option<String>,
|
|
hydrosphere: Option<String>,
|
|
inhabited: bool,
|
|
population: i64,
|
|
economic_role: Option<String>,
|
|
cultural_corridor: Option<String>,
|
|
industrial_corridor: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct StationRow {
|
|
station_id: String,
|
|
system_id: String,
|
|
orbits_body_id: Option<String>,
|
|
station_type: String,
|
|
proper_name: Option<String>,
|
|
population: i64,
|
|
economic_role: Option<String>,
|
|
governance_type: Option<String>,
|
|
docking_class: Option<String>,
|
|
has_gate_infrastructure: bool,
|
|
district_count: i32,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SystemSummary {
|
|
system_id: String,
|
|
proper_name: Option<String>,
|
|
star_type: Option<String>,
|
|
geographic_sector: Option<String>,
|
|
habitable_planet_count: Option<i32>,
|
|
inhabited_planet_count: Option<i32>,
|
|
bodies: Vec<BodyRow>,
|
|
stations: Vec<StationRow>,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Serialize, serde::Deserialize, Clone)]
|
|
struct ProposalBody {
|
|
body_id: String,
|
|
proper_name: Option<String>,
|
|
body_type: String,
|
|
orbit_index: i32,
|
|
parent_body_id: Option<String>,
|
|
inhabited: bool,
|
|
population: Option<i64>,
|
|
mass_class: Option<String>,
|
|
surface_gravity: Option<f64>,
|
|
orbital_period_days: Option<f64>,
|
|
rotation_period_hours: Option<f64>,
|
|
atmosphere: Option<String>,
|
|
biome_summary: Option<String>,
|
|
hydrosphere: Option<String>,
|
|
economic_role: Option<String>,
|
|
settlement_pattern: Option<String>,
|
|
industrial_corridor: Option<String>,
|
|
notes: String,
|
|
}
|
|
|
|
#[derive(Serialize, serde::Deserialize, Clone)]
|
|
struct ProposalStation {
|
|
station_id: String,
|
|
proper_name: Option<String>,
|
|
orbits_body_id: String,
|
|
station_type: String,
|
|
population: Option<i64>,
|
|
economic_role: Option<String>,
|
|
docking_class: Option<String>,
|
|
has_gate_infrastructure: bool,
|
|
notes: String,
|
|
}
|
|
|
|
#[derive(Serialize, serde::Deserialize)]
|
|
struct SystemProposal {
|
|
system_id: String,
|
|
proper_name: Option<String>,
|
|
star_type: Option<String>,
|
|
spectral_class: Option<String>,
|
|
wiki_data: WikiData,
|
|
bodies: Vec<ProposalBody>,
|
|
stations: Vec<ProposalStation>,
|
|
}
|
|
|
|
#[derive(Serialize, serde::Deserialize)]
|
|
struct WikiData {
|
|
habitable_count: i32,
|
|
inhabited_count: i32,
|
|
has_gas_giant: bool,
|
|
has_asteroid_belt: bool,
|
|
has_horizon_station: bool,
|
|
raw_bodies_line: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Database helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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);
|
|
}
|
|
|
|
fn open_db(path: &PathBuf) -> Connection {
|
|
let conn = Connection::open(path).unwrap_or_else(|e| {
|
|
eprintln!("error: cannot open {}: {}", path.display(), e);
|
|
process::exit(1);
|
|
});
|
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
|
.unwrap();
|
|
conn
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_show_system(conn: &Connection, system_id: &str) {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT system_id, proper_name, star_type, geographic_sector,
|
|
habitable_planet_count, inhabited_planet_count
|
|
FROM star_systems WHERE system_id = ?1",
|
|
)
|
|
.unwrap();
|
|
|
|
let sys: Option<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());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
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();
|
|
let rows = stmt
|
|
.query_map(params_ref.as_slice(), |row| {
|
|
Ok(BodyRow {
|
|
body_id: row.get(0)?,
|
|
system_id: row.get(1)?,
|
|
parent_body_id: row.get(2)?,
|
|
body_type: row.get(3)?,
|
|
orbit_index: row.get(4)?,
|
|
proper_name: row.get(5)?,
|
|
mass_class: row.get(6)?,
|
|
atmosphere: row.get(7)?,
|
|
surface_gravity: row.get(8)?,
|
|
biome_summary: row.get(9)?,
|
|
hydrosphere: row.get(10)?,
|
|
inhabited: row.get::<_, i32>(11)? != 0,
|
|
population: row.get::<_, Option<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();
|
|
rows
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
fn cmd_list_bodies(
|
|
conn: &Connection,
|
|
system: Option<&str>,
|
|
body_type: Option<&str>,
|
|
inhabited: bool,
|
|
unnamed: bool,
|
|
) {
|
|
let bodies = query_bodies(conn, system, body_type, inhabited, unnamed);
|
|
println!("{}", serde_json::to_string_pretty(&bodies).unwrap());
|
|
}
|
|
|
|
fn cmd_list_stations(conn: &Connection, system: Option<&str>, station_type: Option<&str>) {
|
|
let stations = query_stations(conn, system, station_type);
|
|
println!("{}", serde_json::to_string_pretty(&stations).unwrap());
|
|
}
|
|
|
|
fn cmd_add_body(conn: &Connection, args: &Commands) {
|
|
let Commands::AddBody {
|
|
id,
|
|
system,
|
|
r#type,
|
|
orbit,
|
|
name,
|
|
parent,
|
|
mass_class,
|
|
atmosphere,
|
|
gravity,
|
|
biome,
|
|
inhabited,
|
|
population,
|
|
} = args
|
|
else {
|
|
unreachable!()
|
|
};
|
|
|
|
conn.execute(
|
|
"INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index,
|
|
proper_name, mass_class, atmosphere, surface_gravity,
|
|
biome_summary, inhabited, population)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
params![
|
|
id,
|
|
system,
|
|
parent,
|
|
r#type,
|
|
orbit,
|
|
name,
|
|
mass_class,
|
|
atmosphere,
|
|
gravity,
|
|
biome,
|
|
*inhabited as i32,
|
|
population.unwrap_or(0),
|
|
],
|
|
)
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("error: {}", e);
|
|
process::exit(1);
|
|
});
|
|
|
|
println!(r#"{{"ok": true, "body_id": "{}"}}"#, id);
|
|
}
|
|
|
|
fn cmd_add_station(conn: &Connection, args: &Commands) {
|
|
let Commands::AddStation {
|
|
id,
|
|
system,
|
|
orbits,
|
|
r#type,
|
|
name,
|
|
population,
|
|
docking,
|
|
gate,
|
|
} = args
|
|
else {
|
|
unreachable!()
|
|
};
|
|
|
|
conn.execute(
|
|
"INSERT INTO stations (station_id, system_id, orbits_body_id, station_type,
|
|
proper_name, population, docking_class, has_gate_infrastructure)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![
|
|
id,
|
|
system,
|
|
orbits,
|
|
r#type,
|
|
name,
|
|
population.unwrap_or(0),
|
|
docking,
|
|
*gate as i32,
|
|
],
|
|
)
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("error: {}", e);
|
|
process::exit(1);
|
|
});
|
|
|
|
println!(r#"{{"ok": true, "station_id": "{}"}}"#, id);
|
|
}
|
|
|
|
fn cmd_stats(conn: &Connection) {
|
|
let systems: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM star_systems", [], |r| r.get(0))
|
|
.unwrap();
|
|
let bodies: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM bodies", [], |r| r.get(0))
|
|
.unwrap();
|
|
let inhabited_bodies: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM bodies WHERE inhabited = 1", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
let stations: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM stations", [], |r| r.get(0))
|
|
.unwrap();
|
|
let systems_with_bodies: i64 = conn
|
|
.query_row("SELECT COUNT(DISTINCT system_id) FROM bodies", [], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
|
|
let mut bodies_by_type = Vec::new();
|
|
{
|
|
let mut stmt = conn
|
|
.prepare("SELECT body_type, COUNT(*) FROM bodies GROUP BY body_type ORDER BY body_type")
|
|
.unwrap();
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(TypeCount {
|
|
r#type: row.get(0)?,
|
|
count: row.get(1)?,
|
|
})
|
|
})
|
|
.unwrap();
|
|
for r in rows.flatten() {
|
|
bodies_by_type.push(r);
|
|
}
|
|
}
|
|
|
|
let mut stations_by_type = Vec::new();
|
|
{
|
|
let mut stmt = conn
|
|
.prepare("SELECT station_type, COUNT(*) FROM stations GROUP BY station_type ORDER BY station_type")
|
|
.unwrap();
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(TypeCount {
|
|
r#type: row.get(0)?,
|
|
count: row.get(1)?,
|
|
})
|
|
})
|
|
.unwrap();
|
|
for r in rows.flatten() {
|
|
stations_by_type.push(r);
|
|
}
|
|
}
|
|
|
|
let stats = StatsOutput {
|
|
systems,
|
|
bodies,
|
|
bodies_by_type,
|
|
inhabited_bodies,
|
|
stations,
|
|
stations_by_type,
|
|
systems_with_bodies,
|
|
systems_without_bodies: systems - systems_with_bodies,
|
|
};
|
|
println!("{}", serde_json::to_string_pretty(&stats).unwrap());
|
|
}
|
|
|
|
fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option<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)
|
|
}
|
|
|
|
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("breathable".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("breathable".into()),
|
|
biome_summary: Some("temperate".into()),
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: "habitable, uninhabited".into(),
|
|
});
|
|
orbit += 1;
|
|
}
|
|
|
|
// Outer rocky/ice planets to fill remaining count
|
|
let placed = inner_barren + wiki_inh + hab_only;
|
|
let remaining_planets = (total_planets - placed).max(0);
|
|
for _ in 0..remaining_planets {
|
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
|
let body_id = if is_binary {
|
|
format!("{}A{}", sid, letter)
|
|
} else {
|
|
format!("{}{}", sid, letter)
|
|
};
|
|
bodies.push(ProposalBody {
|
|
body_id,
|
|
body_type: "planet".into(),
|
|
orbit_index: orbit,
|
|
parent_body_id: None,
|
|
inhabited: false,
|
|
proper_name: None,
|
|
population: None,
|
|
mass_class: Some("terrestrial".into()),
|
|
surface_gravity: Some(0.5),
|
|
orbital_period_days: Some(2000.0),
|
|
rotation_period_hours: Some(18.0),
|
|
atmosphere: Some("thin".into()),
|
|
biome_summary: Some("frozen".into()),
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: "outer rocky/ice, uninhabited".into(),
|
|
});
|
|
orbit += 1;
|
|
}
|
|
|
|
// Asteroid belt
|
|
if db_belt {
|
|
bodies.push(ProposalBody {
|
|
body_id: format!("{}-belt", sid),
|
|
body_type: "asteroid_belt".into(),
|
|
orbit_index: orbit,
|
|
parent_body_id: None,
|
|
inhabited: false,
|
|
proper_name: None,
|
|
population: None,
|
|
mass_class: None,
|
|
surface_gravity: None,
|
|
orbital_period_days: None,
|
|
rotation_period_hours: None,
|
|
atmosphere: None,
|
|
biome_summary: None,
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: "asteroid belt".into(),
|
|
});
|
|
orbit += 1;
|
|
}
|
|
|
|
// Gas giant (with up to 2 moons as default)
|
|
if db_gas_giant {
|
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
|
let gg_id = if is_binary {
|
|
format!("{}A{}", sid, letter)
|
|
} else {
|
|
format!("{}{}", sid, letter)
|
|
};
|
|
bodies.push(ProposalBody {
|
|
body_id: gg_id.clone(),
|
|
body_type: "gas_giant".into(),
|
|
orbit_index: orbit,
|
|
parent_body_id: None,
|
|
inhabited: false,
|
|
proper_name: None,
|
|
population: None,
|
|
mass_class: Some("gas_giant".into()),
|
|
surface_gravity: None,
|
|
orbital_period_days: Some(4300.0),
|
|
rotation_period_hours: Some(10.0),
|
|
atmosphere: Some("dense".into()),
|
|
biome_summary: None,
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: "gas giant".into(),
|
|
});
|
|
// 2 default moons
|
|
for m in 1..=2 {
|
|
bodies.push(ProposalBody {
|
|
body_id: format!("{}-{}", gg_id, m),
|
|
body_type: "moon".into(),
|
|
orbit_index: m,
|
|
parent_body_id: Some(gg_id.clone()),
|
|
inhabited: false,
|
|
proper_name: None,
|
|
population: None,
|
|
mass_class: Some("dwarf".into()),
|
|
surface_gravity: Some(0.1),
|
|
orbital_period_days: Some(3.5 * m as f64),
|
|
rotation_period_hours: Some(3.5 * 24.0 * m as f64),
|
|
atmosphere: Some("none".into()),
|
|
biome_summary: Some("barren".into()),
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: format!("moon {} of gas giant", m),
|
|
});
|
|
}
|
|
orbit += 1;
|
|
}
|
|
|
|
// Oort cloud (always)
|
|
let oort_id = format!("{}-oort", sid);
|
|
bodies.push(ProposalBody {
|
|
body_id: oort_id.clone(),
|
|
body_type: "oort_cloud".into(),
|
|
orbit_index: orbit,
|
|
parent_body_id: None,
|
|
inhabited: false,
|
|
proper_name: None,
|
|
population: None,
|
|
mass_class: None,
|
|
surface_gravity: None,
|
|
orbital_period_days: None,
|
|
rotation_period_hours: None,
|
|
atmosphere: None,
|
|
biome_summary: None,
|
|
hydrosphere: None,
|
|
economic_role: None,
|
|
settlement_pattern: None,
|
|
industrial_corridor: None,
|
|
notes: "oort cloud".into(),
|
|
});
|
|
|
|
// Horizon station
|
|
if has_horizon {
|
|
stations.push(ProposalStation {
|
|
station_id: format!("{}-oort-S1", sid),
|
|
proper_name: None,
|
|
orbits_body_id: oort_id,
|
|
station_type: "horizon".into(),
|
|
population: None,
|
|
economic_role: Some("transit".into()),
|
|
docking_class: Some("major".into()),
|
|
has_gate_infrastructure: true,
|
|
notes: "horizon station — gate infrastructure".into(),
|
|
});
|
|
}
|
|
|
|
(bodies, stations)
|
|
}
|
|
|
|
fn cmd_author(conn: &Connection, system_id: &str, outdir: &str, wiki_dir: &str) {
|
|
// Get system data from DB
|
|
let sys = conn
|
|
.query_row(
|
|
"SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class,
|
|
s.habitable_planet_count, s.inhabited_planet_count,
|
|
s.asteroid_belt, s.gas_giant,
|
|
g.horizon_station
|
|
FROM star_systems s
|
|
LEFT JOIN system_gates g ON s.system_id = g.system_id
|
|
WHERE s.system_id = ?1",
|
|
params![system_id],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, Option<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);
|
|
}
|
|
|
|
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| {
|
|
b.atmosphere.as_deref() == Some("breathable")
|
|
&& (b.body_type == "planet" || b.body_type == "moon")
|
|
})
|
|
.count() as i32;
|
|
|
|
let inhabited_count: i32 = proposal.bodies.iter().filter(|b| b.inhabited).count() as i32
|
|
+ proposal
|
|
.stations
|
|
.iter()
|
|
.filter(|s| s.population.unwrap_or(0) > 0 || s.station_type == "horizon")
|
|
.count() as i32;
|
|
|
|
let has_gas_giant: i32 = proposal.bodies.iter().any(|b| b.body_type == "gas_giant") as i32;
|
|
|
|
let has_belt: i32 = proposal
|
|
.bodies
|
|
.iter()
|
|
.any(|b| b.body_type == "asteroid_belt") as i32;
|
|
|
|
tx.execute(
|
|
"UPDATE star_systems SET habitable_planet_count = ?1, inhabited_planet_count = ?2,
|
|
gas_giant = ?3, asteroid_belt = ?4
|
|
WHERE system_id = ?5",
|
|
params![
|
|
habitable_count,
|
|
inhabited_count,
|
|
has_gas_giant,
|
|
has_belt,
|
|
proposal.system_id,
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
tx.commit().unwrap();
|
|
|
|
#[derive(Serialize)]
|
|
struct CommitResult {
|
|
system_id: String,
|
|
bodies_created: usize,
|
|
stations_created: usize,
|
|
habitable_count: i32,
|
|
inhabited_count: i32,
|
|
}
|
|
let result = CommitResult {
|
|
system_id: proposal.system_id,
|
|
bodies_created: proposal.bodies.len(),
|
|
stations_created: proposal.stations.len(),
|
|
habitable_count,
|
|
inhabited_count,
|
|
};
|
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
|
}
|
|
|
|
fn cmd_wipe_system(conn: &Connection, system_id: &str) {
|
|
let stations_deleted: usize = conn
|
|
.execute(
|
|
"DELETE FROM stations WHERE system_id = ?1",
|
|
params![system_id],
|
|
)
|
|
.unwrap();
|
|
let bodies_deleted: usize = conn
|
|
.execute(
|
|
"DELETE FROM bodies WHERE system_id = ?1",
|
|
params![system_id],
|
|
)
|
|
.unwrap();
|
|
|
|
#[derive(Serialize)]
|
|
struct WipeResult {
|
|
system_id: String,
|
|
bodies_deleted: usize,
|
|
stations_deleted: usize,
|
|
}
|
|
let result = WipeResult {
|
|
system_id: system_id.to_string(),
|
|
bodies_deleted,
|
|
stations_deleted,
|
|
};
|
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
|
}
|
|
|
|
fn cmd_list_systems(
|
|
conn: &Connection,
|
|
sector: Option<&str>,
|
|
hop: Option<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());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
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()
|
|
);
|
|
}
|
|
|
|
fn format_population(pop: i64) -> String {
|
|
if pop >= 1_000_000_000 {
|
|
format!("{:.1}B", pop as f64 / 1_000_000_000.0)
|
|
} else if pop >= 1_000_000 {
|
|
format!("{}M", pop / 1_000_000)
|
|
} else if pop >= 1_000 {
|
|
format!("{}K", pop / 1_000)
|
|
} else {
|
|
format!("{}", pop)
|
|
}
|
|
}
|
|
|
|
// cmd_populate removed — replaced by per-system author/commit workflow
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn main() {
|
|
let cli = Cli::parse();
|
|
let db_path = resolve_db_path(cli.db);
|
|
let conn = open_db(&db_path);
|
|
|
|
match &cli.command {
|
|
Commands::ShowSystem { system_id } => cmd_show_system(&conn, system_id),
|
|
Commands::ShowBody { body_id } => cmd_show_body(&conn, body_id),
|
|
Commands::ShowStation { station_id } => cmd_show_station(&conn, station_id),
|
|
Commands::ListBodies {
|
|
system,
|
|
r#type,
|
|
inhabited,
|
|
unnamed,
|
|
} => cmd_list_bodies(
|
|
&conn,
|
|
system.as_deref(),
|
|
r#type.as_deref(),
|
|
*inhabited,
|
|
*unnamed,
|
|
),
|
|
Commands::ListStations { system, r#type } => {
|
|
cmd_list_stations(&conn, system.as_deref(), r#type.as_deref())
|
|
}
|
|
cmd @ Commands::AddBody { .. } => cmd_add_body(&conn, cmd),
|
|
cmd @ Commands::AddStation { .. } => cmd_add_station(&conn, cmd),
|
|
Commands::Stats => cmd_stats(&conn),
|
|
Commands::Author {
|
|
system_id,
|
|
outdir,
|
|
wiki,
|
|
} => cmd_author(&conn, system_id, outdir, wiki),
|
|
Commands::CommitSystem { path } => cmd_commit_system(&conn, path),
|
|
Commands::WipeSystem { system_id } => cmd_wipe_system(&conn, system_id),
|
|
Commands::ListSystems {
|
|
sector,
|
|
hop,
|
|
finished,
|
|
unfinished,
|
|
} => cmd_list_systems(&conn, sector.as_deref(), *hop, *finished, *unfinished),
|
|
Commands::Unfinished { sector } => cmd_unfinished(&conn, sector.as_deref()),
|
|
Commands::Next { hop, sector } => cmd_next(&conn, *hop, sector.as_deref()),
|
|
Commands::SyncWiki { system_id, wiki } => cmd_sync_wiki(&conn, system_id.as_deref(), wiki),
|
|
}
|
|
}
|