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:
+539
-200
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user