From 27343ea0038f0c4874182b1df0e54d33e621d4da Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 30 Mar 2026 12:26:36 +0200 Subject: [PATCH] feat(atlas): add list-systems command and --sector filter to unfinished/next New `list-systems` subcommand with --sector, --hop, --finished, --unfinished filters. Also adds --sector flag to existing `unfinished` and `next` commands for corridor-scoped queries during atlas authoring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/atlas/SKILL.md | 26 ++++ server/src/bin/atlas.rs | 274 +++++++++++++++++++++++++++++----- 2 files changed, 262 insertions(+), 38 deletions(-) diff --git a/.claude/skills/atlas/SKILL.md b/.claude/skills/atlas/SKILL.md index 8a68ae21c..e1c84293a 100644 --- a/.claude/skills/atlas/SKILL.md +++ b/.claude/skills/atlas/SKILL.md @@ -37,6 +37,32 @@ Returns: body details + stations orbiting that body. tooling/atlas show-station "GJ15Ab-S1" ``` +### List systems with filters +```bash +tooling/atlas list-systems +tooling/atlas list-systems --sector west_reach +tooling/atlas list-systems --sector west_reach --hop 7 +tooling/atlas list-systems --sector west_reach --unfinished +tooling/atlas list-systems --sector east_reach --finished +``` +Sectors: `north_reach`, `south_reach`, `east_reach`, `west_reach`, `deep_frontier`, `core` +Returns: system_id, proper_name, star_type, spectral_class, gate_topology, +geographic_sector, hop_distance, population. Sorted by hop then system_id. + +### Unfinished systems (with optional sector filter) +```bash +tooling/atlas unfinished +tooling/atlas unfinished --sector west_reach +``` + +### Next hop (with optional sector filter) +```bash +tooling/atlas next +tooling/atlas next 7 +tooling/atlas next --sector west_reach +tooling/atlas next 8 --sector west_reach +``` + ### List bodies with filters ```bash tooling/atlas list-bodies diff --git a/server/src/bin/atlas.rs b/server/src/bin/atlas.rs index a0d5fd7b9..8391de083 100644 --- a/server/src/bin/atlas.rs +++ b/server/src/bin/atlas.rs @@ -145,12 +145,34 @@ enum Commands { /// System ID system_id: String, }, + /// List systems with optional filters + ListSystems { + /// Filter by geographic sector (e.g., "west_reach", "east_reach") + #[arg(long)] + sector: Option, + /// Filter by hop distance + #[arg(long)] + hop: Option, + /// Only show systems that have bodies authored + #[arg(long)] + finished: bool, + /// Only show systems that have NO bodies yet + #[arg(long)] + unfinished: bool, + }, /// List systems that have no bodies yet - Unfinished, + Unfinished { + /// Filter by geographic sector + #[arg(long)] + sector: Option, + }, /// List unfinished systems at a specific hop distance (or next available hop) Next { /// Hop distance (omit to find the lowest hop with unfinished systems) hop: Option, + /// Filter by geographic sector + #[arg(long)] + sector: Option, }, /// Sync body/station data into wiki page for a system (or all systems) SyncWiki { @@ -1349,15 +1371,130 @@ fn cmd_wipe_system(conn: &Connection, system_id: &str) { 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(); +fn cmd_list_systems( + conn: &Connection, + sector: Option<&str>, + hop: Option, + finished: bool, + unfinished: bool, +) { + // Build query dynamically based on filters + let mut conditions: Vec = Vec::new(); + let mut param_values: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(s) = sector { + conditions.push(format!("s.geographic_sector = ?{idx}")); + param_values.push(Box::new(s.to_string())); + idx += 1; + } + if let Some(h) = hop { + conditions.push(format!("g.hop_distance_from_gateway = ?{idx}")); + param_values.push(Box::new(h)); + idx += 1; + } + let _ = idx; // suppress unused warning + if finished { + conditions.push( + "s.system_id IN (SELECT DISTINCT system_id FROM bodies)".to_string(), + ); + } + if unfinished { + conditions.push( + "s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)".to_string(), + ); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + g.hop_distance_from_gateway, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + {where_clause} + ORDER BY g.hop_distance_from_gateway, s.system_id" + ); + + let mut stmt = conn.prepare(&sql).unwrap(); + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|p| p.as_ref()).collect(); + + #[derive(Serialize)] + struct ListSystem { + system_id: String, + proper_name: Option, + star_type: Option, + spectral_class: Option, + gate_topology: Option, + geographic_sector: Option, + hop_distance: Option, + habitable_planet_count: Option, + inhabited_planet_count: Option, + population: Option, + } + + let systems: Vec = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(ListSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + spectral_class: row.get(3)?, + gate_topology: row.get(4)?, + geographic_sector: row.get(5)?, + hop_distance: row.get(6)?, + habitable_planet_count: row.get(7)?, + inhabited_planet_count: row.get(8)?, + population: row.get(9)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + #[derive(Serialize)] + struct ListResult { + count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + sector: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hop: Option, + systems: Vec, + } + + let result = ListResult { + count: systems.len(), + sector: sector.map(|s| s.to_string()), + hop, + systems, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} + +fn cmd_unfinished(conn: &Connection, sector: Option<&str>) { + let sql = if sector.is_some() { + "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) + AND s.geographic_sector = ?1 + ORDER BY s.system_id" + } else { + "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" + }; + + let mut stmt = conn.prepare(sql).unwrap(); #[derive(Serialize)] struct UnfinishedSystem { @@ -1366,8 +1503,8 @@ fn cmd_unfinished(conn: &Connection) { star_type: Option, } - let rows: Vec = stmt - .query_map([], |row| { + let rows: Vec = if let Some(s) = sector { + stmt.query_map(params![s], |row| { Ok(UnfinishedSystem { system_id: row.get(0)?, proper_name: row.get(1)?, @@ -1376,33 +1513,56 @@ fn cmd_unfinished(conn: &Connection) { }) .unwrap() .filter_map(|r| r.ok()) - .collect(); + .collect() + } else { + stmt.query_map([], |row| { + Ok(UnfinishedSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; #[derive(Serialize)] struct UnfinishedResult { count: usize, + sector: Option, systems: Vec, } let result = UnfinishedResult { count: rows.len(), + sector: sector.map(|s| s.to_string()), systems: rows, }; println!("{}", serde_json::to_string_pretty(&result).unwrap()); } -fn cmd_next(conn: &Connection, hop: Option) { +fn cmd_next(conn: &Connection, hop: Option, sector: Option<&str>) { // Find the target hop — either specified or the lowest with unfinished systems let target_hop: i32 = if let Some(h) = hop { h } else { - conn.query_row( + let sql = if sector.is_some() { "SELECT MIN(g.hop_distance_from_gateway) FROM star_systems s JOIN system_gates g ON s.system_id = g.system_id - WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)", - [], - |r| r.get::<_, Option>(0), - ) + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + AND s.geographic_sector = ?1" + } else { + "SELECT MIN(g.hop_distance_from_gateway) + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + WHERE s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies)" + }; + if let Some(s) = sector { + conn.query_row(sql, params![s], |r| r.get::<_, Option>(0)) + } else { + conn.query_row(sql, [], |r| r.get::<_, Option>(0)) + } .unwrap() .unwrap_or(-1) }; @@ -1427,23 +1587,35 @@ fn cmd_next(conn: &Connection, hop: Option) { population: Option, } - let mut stmt = conn - .prepare( - "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, - g.gate_topology, s.geographic_sector, - s.habitable_planet_count, s.inhabited_planet_count, - e.population - FROM star_systems s - JOIN system_gates g ON s.system_id = g.system_id - LEFT JOIN system_economy e ON s.system_id = e.system_id - WHERE g.hop_distance_from_gateway = ?1 - AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) - ORDER BY s.system_id", - ) - .unwrap(); + let sql = if sector.is_some() { + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + WHERE g.hop_distance_from_gateway = ?1 + AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + AND s.geographic_sector = ?2 + ORDER BY s.system_id" + } else { + "SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class, + g.gate_topology, s.geographic_sector, + s.habitable_planet_count, s.inhabited_planet_count, + e.population + FROM star_systems s + JOIN system_gates g ON s.system_id = g.system_id + LEFT JOIN system_economy e ON s.system_id = e.system_id + WHERE g.hop_distance_from_gateway = ?1 + AND s.system_id NOT IN (SELECT DISTINCT system_id FROM bodies) + ORDER BY s.system_id" + }; - let systems: Vec = stmt - .query_map(params![target_hop], |row| { + let mut stmt = conn.prepare(sql).unwrap(); + + let systems: Vec = if let Some(s) = sector { + stmt.query_map(params![target_hop, s], |row| { Ok(NextSystem { system_id: row.get(0)?, proper_name: row.get(1)?, @@ -1458,17 +1630,37 @@ fn cmd_next(conn: &Connection, hop: Option) { }) .unwrap() .filter_map(|r| r.ok()) - .collect(); + .collect() + } else { + stmt.query_map(params![target_hop], |row| { + Ok(NextSystem { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + spectral_class: row.get(3)?, + gate_topology: row.get(4)?, + geographic_sector: row.get(5)?, + habitable_planet_count: row.get(6)?, + inhabited_planet_count: row.get(7)?, + population: row.get(8)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; #[derive(Serialize)] struct NextResult { hop: i32, + sector: Option, count: usize, systems: Vec, } let result = NextResult { hop: target_hop, + sector: sector.map(|s| s.to_string()), count: systems.len(), systems, }; @@ -1842,8 +2034,14 @@ fn main() { } => 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), - Commands::Next { hop } => cmd_next(&conn, *hop), + Commands::ListSystems { + sector, + hop, + finished, + unfinished, + } => cmd_list_systems(&conn, sector.as_deref(), *hop, *finished, *unfinished), + Commands::Unfinished { sector } => cmd_unfinished(&conn, sector.as_deref()), + Commands::Next { hop, sector } => cmd_next(&conn, *hop, sector.as_deref()), Commands::SyncWiki { system_id, wiki } => cmd_sync_wiki(&conn, system_id.as_deref(), wiki), } }