From fb401e80d21d7ff0bbe22c9df9315699c1b2895b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Mar 2026 17:53:30 +0100 Subject: [PATCH] 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) --- server/src/bin/atlas.rs | 739 +++++++++++++++++++++++++++++----------- 1 file changed, 539 insertions(+), 200 deletions(-) diff --git a/server/src/bin/atlas.rs b/server/src/bin/atlas.rs index a80d7b332..cc184bc6d 100644 --- a/server/src/bin/atlas.rs +++ b/server/src/bin/atlas.rs @@ -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, + orbit_index: i32, + parent_body_id: Option, + inhabited: bool, + mass_class: Option, + atmosphere: Option, + biome_summary: Option, + 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, + star_type: Option, + spectral_class: Option, + wiki_data: WikiData, + bodies: Vec, + stations: Vec, +} + +#[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, } // --------------------------------------------------------------------------- @@ -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, 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, Vec) { + 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>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(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, + star_type: Option, } - let systems: Vec = stmt + let rows: Vec = stmt .query_map([], |row| { - Ok(SystemInfo { + Ok(UnfinishedSystem { system_id: row.get(0)?, - habitable: row.get::<_, Option>(1)?.unwrap_or(0), - inhabited: row.get::<_, Option>(2)?.unwrap_or(0), - has_belt: row.get::<_, Option>(3)?.unwrap_or(0) != 0, - has_gas_giant: row.get::<_, Option>(4)?.unwrap_or(0) != 0, - has_horizon: row.get::<_, Option>(6)?.unwrap_or(0) != 0, + 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 = { - let mut stmt = conn - .prepare("SELECT DISTINCT system_id FROM bodies") - .unwrap(); - stmt.query_map([], |row| row.get::<_, String>(0)) - .unwrap() - .filter_map(|r| r.ok()) - .collect() - }; - - let mut actions: Vec = Vec::new(); - - for sys in &systems { - if existing.contains(&sys.system_id) { - continue; - } - - let sid = &sys.system_id; - // Normalize system ID for body naming (replace spaces with empty) - let sid_compact = sid.replace(' ', ""); - let mut orbit = 1; - - // Inner rocky planets (uninhabited count = habitable - inhabited, minimum 0) - // We place inhabited planets first, then habitable-but-uninhabited - let uninhabited_habitable = (sys.habitable - sys.inhabited).max(0); - - // Inhabited planets - for _ in 0..sys.inhabited { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = format!("{}{}", sid_compact, letter); - actions.push(PopulateAction { - action: if dry_run { "would_create".into() } else { "created".into() }, - id: body_id, - system_id: sid.clone(), - body_type: "planet".into(), - orbit_index: Some(orbit), - }); - orbit += 1; - } - - // Habitable but uninhabited planets - for _ in 0..uninhabited_habitable { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = format!("{}{}", sid_compact, letter); - actions.push(PopulateAction { - action: if dry_run { "would_create".into() } else { "created".into() }, - id: body_id, - system_id: sid.clone(), - body_type: "planet".into(), - orbit_index: Some(orbit), - }); - orbit += 1; - } - - // Asteroid belt - if sys.has_belt { - let body_id = format!("{}-belt", sid_compact); - actions.push(PopulateAction { - action: if dry_run { "would_create".into() } else { "created".into() }, - id: body_id, - system_id: sid.clone(), - body_type: "asteroid_belt".into(), - orbit_index: Some(orbit), - }); - orbit += 1; - } - - // Gas giant - if sys.has_gas_giant { - let letter = (b'b' + orbit as u8 - 1) as char; - let body_id = format!("{}{}", sid_compact, letter); - actions.push(PopulateAction { - action: if dry_run { "would_create".into() } else { "created".into() }, - id: body_id, - system_id: sid.clone(), - body_type: "gas_giant".into(), - orbit_index: Some(orbit), - }); - orbit += 1; - } - - // Oort cloud (always present — every system has one) - { - let body_id = format!("{}-oort", sid_compact); - actions.push(PopulateAction { - action: if dry_run { "would_create".into() } else { "created".into() }, - id: body_id.clone(), - system_id: sid.clone(), - body_type: "oort_cloud".into(), - orbit_index: Some(orbit), - }); - - // Horizon station in the oort cloud - if sys.has_horizon { - actions.push(PopulateAction { - action: if dry_run { "would_create_station".into() } else { "created_station".into() }, - id: format!("{}-oort-S1", sid_compact), - system_id: sid.clone(), - body_type: "horizon".into(), - orbit_index: None, - }); - } - } - } - - // Execute if not dry run - if !dry_run { - let tx = conn.unchecked_transaction().unwrap(); - for action in &actions { - if action.action == "created" { - let inhabited = if action.body_type == "planet" { - // Check if this is one of the inhabited planets (first N by orbit) - let sys = systems.iter().find(|s| s.system_id == action.system_id).unwrap(); - action.orbit_index.unwrap_or(0) <= sys.inhabited - } else { - false - }; - - tx.execute( - "INSERT OR IGNORE INTO bodies (body_id, system_id, body_type, orbit_index, inhabited) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - action.id, - action.system_id, - action.body_type, - action.orbit_index, - inhabited as i32, - ], - ) - .unwrap(); - } else if action.action == "created_station" { - let oort_id = format!( - "{}-oort", - action.system_id.replace(' ', "") - ); - tx.execute( - "INSERT OR IGNORE INTO stations (station_id, system_id, orbits_body_id, - station_type, has_gate_infrastructure) - VALUES (?1, ?2, ?3, ?4, 1)", - params![action.id, action.system_id, oort_id, "horizon"], - ) - .unwrap(); - } - } - tx.commit().unwrap(); - } - #[derive(Serialize)] - struct PopulateResult { - dry_run: bool, - actions: Vec, - total_bodies: usize, - total_stations: usize, - systems_populated: usize, + struct UnfinishedResult { + count: usize, + systems: Vec, } - - let station_count = actions.iter().filter(|a| a.action.contains("station")).count(); - let body_count = actions.len() - station_count; - let system_count = actions - .iter() - .map(|a| &a.system_id) - .collect::>() - .len(); - - let result = PopulateResult { - dry_run, - actions, - total_bodies: body_count, - total_stations: station_count, - systems_populated: system_count, + 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), } }