feat(db): replace atlas bulk populate with per-system author/commit workflow

New commands: author (generates proposal JSON per system from wiki + DB
data), commit-system (writes approved proposal to DB), wipe-system
(clears a system's bodies/stations), unfinished (lists systems without
bodies). Removed the batch populate command.

The author command reads the wiki page, extracts habitable/inhabited
counts, generates a body ID matrix based on star type, and writes a
reviewable JSON proposal. Human reviews, edits, then commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:53:30 +01:00
co-authored by Claude Opus 4.6
parent ca676e8e66
commit fb401e80d2
+539 -200
View File
@@ -124,12 +124,29 @@ enum Commands {
},
/// Database statistics
Stats,
/// Bulk populate bodies from existing system data (classifier pass)
Populate {
/// Dry run — show what would be created without writing
#[arg(long)]
dry_run: bool,
/// 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 that have no bodies yet
Unfinished,
}
// ---------------------------------------------------------------------------
@@ -201,13 +218,47 @@ struct TypeCount {
count: i64,
}
#[derive(Serialize)]
struct PopulateAction {
action: String,
id: String,
system_id: String,
#[derive(Serialize, serde::Deserialize, Clone)]
struct ProposalBody {
body_id: String,
body_type: String,
orbit_index: Option<i32>,
orbit_index: i32,
parent_body_id: Option<String>,
inhabited: bool,
mass_class: Option<String>,
atmosphere: Option<String>,
biome_summary: Option<String>,
notes: String,
}
#[derive(Serialize, serde::Deserialize, Clone)]
struct ProposalStation {
station_id: String,
orbits_body_id: String,
station_type: 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>,
}
// ---------------------------------------------------------------------------
@@ -636,218 +687,503 @@ fn cmd_stats(conn: &Connection) {
println!("{}", serde_json::to_string_pretty(&stats).unwrap());
}
fn cmd_populate(conn: &Connection, dry_run: bool) {
// Read all systems with their physical properties
let mut stmt = conn
.prepare(
"SELECT s.system_id, s.habitable_planet_count, s.inhabited_planet_count,
s.asteroid_belt, s.gas_giant, s.star_type,
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 (realistic defaults)
let total_planets = if wiki_hab > 0 || wiki_inh > 0 {
// Wiki has data — use it as the inhabited/habitable core, add some barren ones
let known = wiki_hab.max(wiki_inh);
match spectral_char {
'O' | 'B' | 'A' => known + 1, // hot stars, fewer planets
'F' => known + 2,
'G' => known + 2, // sol-like
'K' => known + 1,
'M' => known + 1, // red dwarfs, compact systems
_ => known + 1,
}
} else {
// No wiki data — generate reasonable count
match spectral_char {
'O' | 'B' | 'A' => 2,
'F' => 4,
'G' => 4,
'K' => 3,
'M' => 3,
_ => 3,
}
};
// Determine if binary — affects naming
let is_binary = star_type.map_or(false, |t| t == "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,
mass_class: Some("terrestrial".into()),
atmosphere: Some("none".into()),
biome_summary: Some("barren".into()),
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,
mass_class: Some("terrestrial".into()),
atmosphere: Some("breathable".into()),
biome_summary: Some("temperate".into()),
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,
mass_class: Some("terrestrial".into()),
atmosphere: Some("breathable".into()),
biome_summary: Some("temperate".into()),
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,
mass_class: Some("terrestrial".into()),
atmosphere: Some("thin".into()),
biome_summary: Some("frozen".into()),
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,
mass_class: None,
atmosphere: None,
biome_summary: 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,
mass_class: Some("gas_giant".into()),
atmosphere: Some("dense".into()),
biome_summary: 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,
mass_class: Some("dwarf".into()),
atmosphere: Some("none".into()),
biome_summary: Some("barren".into()),
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,
mass_class: None,
atmosphere: None,
biome_summary: None,
notes: "oort cloud".into(),
});
// Horizon station
if has_horizon {
stations.push(ProposalStation {
station_id: format!("{}-oort-S1", sid),
orbits_body_id: oort_id,
station_type: "horizon".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,
inhabited, mass_class, atmosphere, biome_summary)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
body.body_id,
proposal.system_id,
body.parent_body_id,
body.body_type,
body.orbit_index,
body.inhabited as i32,
body.mass_class,
body.atmosphere,
body.biome_summary,
],
)
.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,
has_gate_infrastructure)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
station.station_id,
proposal.system_id,
station.orbits_body_id,
station.station_type,
station.has_gate_infrastructure as i32,
],
)
.unwrap_or_else(|e| {
eprintln!("error inserting station '{}': {}", station.station_id, e);
process::exit(1);
});
}
tx.commit().unwrap();
#[derive(Serialize)]
struct CommitResult {
system_id: String,
bodies_created: usize,
stations_created: usize,
}
let result = CommitResult {
system_id: proposal.system_id,
bodies_created: proposal.bodies.len(),
stations_created: proposal.stations.len(),
};
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_unfinished(conn: &Connection) {
let mut stmt = conn
.prepare(
"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",
)
.unwrap();
struct SystemInfo {
#[derive(Serialize)]
struct UnfinishedSystem {
system_id: String,
habitable: i32,
inhabited: i32,
has_belt: bool,
has_gas_giant: bool,
has_horizon: bool,
proper_name: Option<String>,
star_type: Option<String>,
}
let systems: Vec<SystemInfo> = stmt
let rows: Vec<UnfinishedSystem> = stmt
.query_map([], |row| {
Ok(SystemInfo {
Ok(UnfinishedSystem {
system_id: row.get(0)?,
habitable: row.get::<_, Option<i32>>(1)?.unwrap_or(0),
inhabited: row.get::<_, Option<i32>>(2)?.unwrap_or(0),
has_belt: row.get::<_, Option<i32>>(3)?.unwrap_or(0) != 0,
has_gas_giant: row.get::<_, Option<i32>>(4)?.unwrap_or(0) != 0,
has_horizon: row.get::<_, Option<i32>>(6)?.unwrap_or(0) != 0,
proper_name: row.get(1)?,
star_type: row.get(2)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
// Check which systems already have bodies
let existing: std::collections::HashSet<String> = {
let mut stmt = conn
.prepare("SELECT DISTINCT system_id FROM bodies")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
let mut actions: Vec<PopulateAction> = Vec::new();
for sys in &systems {
if existing.contains(&sys.system_id) {
continue;
}
let sid = &sys.system_id;
// Normalize system ID for body naming (replace spaces with empty)
let sid_compact = sid.replace(' ', "");
let mut orbit = 1;
// Inner rocky planets (uninhabited count = habitable - inhabited, minimum 0)
// We place inhabited planets first, then habitable-but-uninhabited
let uninhabited_habitable = (sys.habitable - sys.inhabited).max(0);
// Inhabited planets
for _ in 0..sys.inhabited {
let letter = (b'b' + orbit as u8 - 1) as char;
let body_id = format!("{}{}", sid_compact, letter);
actions.push(PopulateAction {
action: if dry_run { "would_create".into() } else { "created".into() },
id: body_id,
system_id: sid.clone(),
body_type: "planet".into(),
orbit_index: Some(orbit),
});
orbit += 1;
}
// Habitable but uninhabited planets
for _ in 0..uninhabited_habitable {
let letter = (b'b' + orbit as u8 - 1) as char;
let body_id = format!("{}{}", sid_compact, letter);
actions.push(PopulateAction {
action: if dry_run { "would_create".into() } else { "created".into() },
id: body_id,
system_id: sid.clone(),
body_type: "planet".into(),
orbit_index: Some(orbit),
});
orbit += 1;
}
// Asteroid belt
if sys.has_belt {
let body_id = format!("{}-belt", sid_compact);
actions.push(PopulateAction {
action: if dry_run { "would_create".into() } else { "created".into() },
id: body_id,
system_id: sid.clone(),
body_type: "asteroid_belt".into(),
orbit_index: Some(orbit),
});
orbit += 1;
}
// Gas giant
if sys.has_gas_giant {
let letter = (b'b' + orbit as u8 - 1) as char;
let body_id = format!("{}{}", sid_compact, letter);
actions.push(PopulateAction {
action: if dry_run { "would_create".into() } else { "created".into() },
id: body_id,
system_id: sid.clone(),
body_type: "gas_giant".into(),
orbit_index: Some(orbit),
});
orbit += 1;
}
// Oort cloud (always present — every system has one)
{
let body_id = format!("{}-oort", sid_compact);
actions.push(PopulateAction {
action: if dry_run { "would_create".into() } else { "created".into() },
id: body_id.clone(),
system_id: sid.clone(),
body_type: "oort_cloud".into(),
orbit_index: Some(orbit),
});
// Horizon station in the oort cloud
if sys.has_horizon {
actions.push(PopulateAction {
action: if dry_run { "would_create_station".into() } else { "created_station".into() },
id: format!("{}-oort-S1", sid_compact),
system_id: sid.clone(),
body_type: "horizon".into(),
orbit_index: None,
});
}
}
}
// Execute if not dry run
if !dry_run {
let tx = conn.unchecked_transaction().unwrap();
for action in &actions {
if action.action == "created" {
let inhabited = if action.body_type == "planet" {
// Check if this is one of the inhabited planets (first N by orbit)
let sys = systems.iter().find(|s| s.system_id == action.system_id).unwrap();
action.orbit_index.unwrap_or(0) <= sys.inhabited
} else {
false
};
tx.execute(
"INSERT OR IGNORE INTO bodies (body_id, system_id, body_type, orbit_index, inhabited)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
action.id,
action.system_id,
action.body_type,
action.orbit_index,
inhabited as i32,
],
)
.unwrap();
} else if action.action == "created_station" {
let oort_id = format!(
"{}-oort",
action.system_id.replace(' ', "")
);
tx.execute(
"INSERT OR IGNORE INTO stations (station_id, system_id, orbits_body_id,
station_type, has_gate_infrastructure)
VALUES (?1, ?2, ?3, ?4, 1)",
params![action.id, action.system_id, oort_id, "horizon"],
)
.unwrap();
}
}
tx.commit().unwrap();
}
#[derive(Serialize)]
struct PopulateResult {
dry_run: bool,
actions: Vec<PopulateAction>,
total_bodies: usize,
total_stations: usize,
systems_populated: usize,
struct UnfinishedResult {
count: usize,
systems: Vec<UnfinishedSystem>,
}
let station_count = actions.iter().filter(|a| a.action.contains("station")).count();
let body_count = actions.len() - station_count;
let system_count = actions
.iter()
.map(|a| &a.system_id)
.collect::<std::collections::HashSet<_>>()
.len();
let result = PopulateResult {
dry_run,
actions,
total_bodies: body_count,
total_stations: station_count,
systems_populated: system_count,
let result = UnfinishedResult {
count: rows.len(),
systems: rows,
};
println!("{}", serde_json::to_string_pretty(&result).unwrap());
}
// cmd_populate removed — replaced by per-system author/commit workflow
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -870,6 +1206,9 @@ fn main() {
cmd @ Commands::AddBody { .. } => cmd_add_body(&conn, cmd),
cmd @ Commands::AddStation { .. } => cmd_add_station(&conn, cmd),
Commands::Stats => cmd_stats(&conn),
Commands::Populate { dry_run } => cmd_populate(&conn, *dry_run),
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::Unfinished => cmd_unfinished(&conn),
}
}