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:
@@ -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.
@@ -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
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user