feat(db): add atlas Rust CLI for celestial bodies + stations

Rust binary (server/src/bin/atlas.rs) with commands: show-system,
show-body, show-station, list-bodies, list-stations, add-body,
add-station, stats, populate. Reads from server/data/systems.db.

Wrapper at tooling/atlas, skill at .claude/skills/atlas/SKILL.md.
Scope note: atlas will grow to cover the full geographic hierarchy
(planetary surfaces, settlements, districts) as the cascade progresses.

Also: removed stale star_systems table from settledreach.db (belongs
in systems.db only) and ran initial populate pass (329 bodies + 301
horizon stations across 301 systems).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:40:37 +01:00
co-authored by Claude Opus 4.6
parent fc3b7485c6
commit ca676e8e66
4 changed files with 1042 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
---
name: atlas
description: >
Query and manage celestial bodies and stations in systems.db. Use when the user
says "atlas", "show system", "list bodies", "list stations", "populate bodies",
"show body", or invokes /atlas. Wraps the Rust atlas CLI binary.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
# Atlas Skill
Query and manage celestial bodies (planets, moons, gas giants, asteroid belts,
oort clouds) and stations in `server/data/systems.db`.
**CLI binary:** `tooling/atlas` (wraps `cargo run --bin atlas`)
**Database:** `server/data/systems.db` (auto-detected from working directory)
**All output is JSON on stdout.**
## Commands
### Show full system hierarchy
```bash
tooling/atlas show-system "GJ 15A"
```
Returns: system info + all bodies + all stations in the system.
### Show a single body
```bash
tooling/atlas show-body "GJ15Ab"
```
Returns: body details + stations orbiting that body.
### Show a single station
```bash
tooling/atlas show-station "GJ15Ab-S1"
```
### List bodies with filters
```bash
tooling/atlas list-bodies
tooling/atlas list-bodies --system "GJ 15A"
tooling/atlas list-bodies --type planet
tooling/atlas list-bodies --type planet --inhabited
tooling/atlas list-bodies --unnamed
```
Types: `planet`, `moon`, `gas_giant`, `asteroid_belt`, `oort_cloud`
### List stations with filters
```bash
tooling/atlas list-stations
tooling/atlas list-stations --system "GJ 15A"
tooling/atlas list-stations --type horizon
```
Types: `horizon`, `commercial`, `military`, `research`, `industrial`, `agricultural`, `transit`
### Add a body
```bash
tooling/atlas add-body \
--id "GJ15Ab" \
--system "GJ 15A" \
--type planet \
--orbit 1 \
--name "Xin Chengdu" \
--mass-class terrestrial \
--atmosphere breathable \
--gravity 0.87 \
--biome temperate \
--inhabited \
--population 1800000
```
### Add a station
```bash
tooling/atlas add-station \
--id "GJ15Ab-S1" \
--system "GJ 15A" \
--orbits "GJ15Ab" \
--type commercial \
--name "Chengdu Orbital" \
--population 420000 \
--docking major
```
### Database statistics
```bash
tooling/atlas stats
```
Returns: system count, body count by type, inhabited count, station count by type.
### Bulk populate from system data (classifier pass)
```bash
tooling/atlas populate --dry-run # preview what would be created
tooling/atlas populate # create body/station records
```
Reads `habitable_planet_count`, `inhabited_planet_count`, `gas_giant`,
`asteroid_belt`, and `horizon_station` from existing system data.
Creates: planets (inhabited first), gas giants, asteroid belts, oort cloud per
system, horizon station per oort cloud. Skips systems that already have bodies.
## Body ID Naming Convention
```
GJ-{n} — star (single)
GJ-{n}A — primary star (binary)
GJ-{n}B — secondary star (binary)
GJ-{n}b/c/d... — planets, innermost first
GJ-{n}Ab/c/d... — planets orbiting primary only
GJ-{n}Bb/c/d... — planets orbiting secondary only
GJ-{n}d-1 — first moon of third planet
GJ-{n}d-S1 — first station orbiting third planet
GJ-{n}-oort — oort cloud region
GJ-{n}-belt — asteroid belt
GJ-{n}-oort-S1 — horizon station in oort cloud
```
## Entity Hierarchy
```
System (star_systems)
└─ Body (bodies) — planet, moon, gas_giant, asteroid_belt, oort_cloud
└─ Station (stations) — horizon, commercial, military, research, etc.
```
Bodies have a self-referential `parent_body_id` for moons → planet relationships.
Stations reference `orbits_body_id` for what they orbit.
## Scope — A True Atlas
This skill will grow to cover the full geographic hierarchy of the Reach:
- **Galactic:** 301 systems, gate topology, sector/corridor data
- **System:** orbital bodies, stations, oort cloud
- **Planetary surface:** continents, oceans, mountain ranges, rivers, biome regions
- **Settlement:** cities, towns, villages, outposts, rail lines, road hierarchy
- **District:** neighborhoods, zones, named areas/provinces
- **Local:** named locations, landmarks, facilities
As the development cascade progresses through Phase 1 (wiki content) → Phase 3
(planetary maps / Atlas of the Reach), the atlas CLI and its schema will extend
to cover each level. The goal is a single queryable geographic database from
galaxy scale to street level — the in-game implant's atlas app reads from this
same data.
Binary file not shown.
+875
View File
@@ -0,0 +1,875 @@
//! 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,
/// Bulk populate bodies from existing system data (classifier pass)
Populate {
/// Dry run — show what would be created without writing
#[arg(long)]
dry_run: bool,
},
}
// ---------------------------------------------------------------------------
// 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)]
struct PopulateAction {
action: String,
id: String,
system_id: String,
body_type: String,
orbit_index: Option<i32>,
}
// ---------------------------------------------------------------------------
// 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 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,
g.horizon_station
FROM star_systems s
LEFT JOIN system_gates g ON s.system_id = g.system_id
ORDER BY s.system_id",
)
.unwrap();
struct SystemInfo {
system_id: String,
habitable: i32,
inhabited: i32,
has_belt: bool,
has_gas_giant: bool,
has_horizon: bool,
}
let systems: Vec<SystemInfo> = stmt
.query_map([], |row| {
Ok(SystemInfo {
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,
})
})
.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,
}
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,
};
println!("{}", serde_json::to_string_pretty(&result).unwrap());
}
// ---------------------------------------------------------------------------
// 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::Populate { dry_run } => cmd_populate(&conn, *dry_run),
}
}
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Atlas CLI wrapper — celestial bodies and stations in systems.db.
#
# Usage:
# tooling/atlas stats
# tooling/atlas show-system "GJ 15A"
# tooling/atlas list-bodies --system "GJ 15A"
# tooling/atlas populate --dry-run
#
# Builds on first run if binary doesn't exist.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BIN="$ROOT_DIR/server/target/debug/atlas"
# Build if needed
if [ ! -f "$BIN" ]; then
echo "Building atlas..." >&2
(cd "$ROOT_DIR/server" && cargo build --bin atlas 2>&1 | tail -3) >&2
fi
exec "$BIN" "$@"