//! Atlas data-delivery proxy (T-949) — two thin, independent endpoints //! alongside the per-body layer-stream proxy ([`crate::atlas::layer_proxy`]): //! //! - **Star map** ([`StarMapRequest`]/[`StarMapResponse`]): a session-scoped, //! non-per-body proxy over the static `client/data/star_map_data.json` //! dataset (produced by `tooling/generate-star-map-data.py`). Reads the file //! fresh on every request — no caching, no staleness handling — because the //! client only asks once per session when the Atlas star-map view opens. //! The parsed JSON is passed through as an opaque `serde_json::Value` rather //! than a hand-mirrored Rust struct: the server has no reason to understand //! `_meta`/`nodes`/`edges`, so this stays a genuinely thin proxy that never //! needs a code change when the generator's JSON shape evolves. //! //! - **City names** ([`CityNamesRequest`]/[`CityNamesResponse`]): a per-body //! names-only settlement list read straight from `atlas_city_names` //! (replaces the client's legacy `markers.json` names-only read, D-223, for //! every body except Sol). Available immediately — it does not wait on the //! generation cascade the way `AtlasLayerResponse.settlements` does. //! //! **Sol exclusion (D-236):** Sol (system `GJ-0`) is permanently out of the //! generation cascade. `handle_city_names_request` checks //! [`CityContextReader::is_sol_body`] first and returns //! [`CityNamesStatus::SolExcluded`] with an empty city list — the client keeps //! its legacy authored `markers.json` read for Sol; this proxy never runs Sol //! through any cascade or DB-derived path. //! //! **Inbound demux (D-225 extension):** the existing array-vs-map trick //! (`Vec` vs. `AtlasLayerRequest`) is preserved byte-for-byte — //! neither request type gained fields. The two new map shapes each carry a //! **mandatory boolean discriminator field** (`star_map` / `city_names`) //! instead of relying on "which optional field is missing" trial-order //! fragility: `CityNamesRequest{city_names: bool, body_id: String}` and //! `AtlasLayerRequest{body_id: String, up_to: CascadeLayer}` both key on //! `body_id`, and serde's derived `Deserialize` silently ignores unknown //! fields by default — so a payload carrying every field either shape wants //! would ambiguously satisfy both if disambiguation relied on "does this //! parse at all". Requiring a field the *other* shapes don't have at all //! (missing required field ⇒ hard deserialize failure, not silent ignore) //! keeps every shape mutually exclusive without adding `#[serde(deny_unknown_fields)]` //! to `AtlasLayerRequest` (which would risk breaking any already-deployed //! client encoder that harmlessly sends extra fields). See //! `crate::bridge::decode_inbound` for the trial order + full rationale. use std::path::{Path, PathBuf}; use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::city_context_reader::CityContextReader; // --------------------------------------------------------------------------- // Star map proxy // --------------------------------------------------------------------------- /// A client request for the star-map dataset (T-949a). Non-per-body — the /// map contains every system in the Reach. /// /// `star_map` is the Inbound discriminator (see module doc): always `true`. /// Its presence, not its value, is what disambiguates this map shape. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StarMapRequest { pub star_map: bool, } /// Status of a [`StarMapResponse`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum StarMapStatus { /// The dataset was read and parsed (`data` is populated). Ready, /// IO or JSON-parse failure (message for the client log). The client /// should keep using whatever it already has (or its own bundled copy) /// rather than treat this as fatal. Error(String), } /// A star-map response: the parsed `star_map_data.json` contents verbatim, or /// an error status (T-949a). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StarMapResponse { pub status: StarMapStatus, /// Verbatim parsed JSON (`_meta`, `nodes`, `edges` — see /// `tooling/generate-star-map-data.py`), re-serialized as MessagePack. /// `None` unless `status == Ready`. pub data: Option, } /// Resolved path to `client/data/star_map_data.json` (T-949a). A thin resource /// holding just the path — the file is re-read fresh on every request (no /// caching, per the ticket: this is a one-shot-per-session read from the /// client, so staleness handling would be pure complexity for no benefit). #[derive(Resource, Debug, Clone)] pub struct StarMapDataPath(pub PathBuf); /// Serve one star-map request (T-949a): read + parse the file fresh. pub fn handle_star_map_request(_req: &StarMapRequest, path: &Path) -> StarMapResponse { match std::fs::read_to_string(path) { Ok(text) => match serde_json::from_str::(&text) { Ok(data) => StarMapResponse { status: StarMapStatus::Ready, data: Some(data), }, Err(e) => StarMapResponse { status: StarMapStatus::Error(format!("star_map_data.json parse error: {e}")), data: None, }, }, Err(e) => StarMapResponse { status: StarMapStatus::Error(format!( "star_map_data.json read error ({}): {e}", path.display() )), data: None, }, } } // --------------------------------------------------------------------------- // City names proxy // --------------------------------------------------------------------------- /// A client request for a body's authored settlement names (T-949b). /// /// `city_names` is the Inbound discriminator (see module doc): always `true`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CityNamesRequest { pub city_names: bool, pub body_id: String, } /// Status of a [`CityNamesResponse`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum CityNamesStatus { /// Names are ready (`cities` is populated; legitimately empty for a body /// with no authored settlements). Ready, /// D-236: `body_id` is a Sol body. Sol is permanently out of the /// generation cascade and every DB-derived Atlas path — the client must /// keep its legacy authored `markers.json` read for Sol. `cities` is empty. SolExcluded, /// DB/IO failure reading `atlas_city_names` (message for the client log). Error(String), } /// One settlement name entry (T-949b) — see /// [`crate::atlas::city_context_reader::CityNameRow`] for the reader-side row /// this is built from. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CityNameEntry { pub city_id: u64, pub name: String, pub is_capital: bool, } /// A city-names response: the body's authored settlements, or a non-ready /// status (T-949b). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CityNamesResponse { pub body_id: String, pub status: CityNamesStatus, pub cities: Vec, } /// Serve one city-names request (T-949b): D-236 Sol check first, then the /// names-only `atlas_city_names` read. `city_reader` absent (no DB opened at /// startup) is reported as `Error`, matching `layer_proxy`'s "no body source /// resolver" convention. pub fn handle_city_names_request( req: &CityNamesRequest, city_reader: Option<&CityContextReader>, ) -> CityNamesResponse { let Some(reader) = city_reader else { return CityNamesResponse { body_id: req.body_id.clone(), status: CityNamesStatus::Error("city context reader unavailable".to_string()), cities: Vec::new(), }; }; match reader.is_sol_body(&req.body_id) { Ok(true) => { return CityNamesResponse { body_id: req.body_id.clone(), status: CityNamesStatus::SolExcluded, cities: Vec::new(), }; } Ok(false) => {} Err(e) => { return CityNamesResponse { body_id: req.body_id.clone(), status: CityNamesStatus::Error(e.to_string()), cities: Vec::new(), }; } } match reader.read_body_city_names(&req.body_id) { Ok(rows) => CityNamesResponse { body_id: req.body_id.clone(), status: CityNamesStatus::Ready, cities: rows .into_iter() .map(|r| CityNameEntry { city_id: r.city_id, name: r.name, is_capital: r.is_capital, }) .collect(), }, Err(e) => CityNamesResponse { body_id: req.body_id.clone(), status: CityNamesStatus::Error(e.to_string()), cities: Vec::new(), }, } } // --------------------------------------------------------------------------- // Feature names proxy (T-1169) // --------------------------------------------------------------------------- /// A client request for a body's reserved geographic feature names (T-1169) — /// mirrors [`CityNamesRequest`] exactly (D-236 pattern), its own message type /// per the same Inbound-demux discriminator discipline the module doc /// describes: outside the [`crate::atlas::layer_proxy::AtlasLayerResponse`] /// ceilings, since a name pool is unrelated to the dense per-cell wire /// arrays those ceilings budget for. /// /// `feature_names` is the Inbound discriminator (see module doc): always `true`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureNamesRequest { pub feature_names: bool, pub body_id: String, } /// Status of a [`FeatureNamesResponse`] — mirrors [`CityNamesStatus`] exactly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FeatureNamesStatus { /// Names are ready (`features` is populated; legitimately empty for a /// body with no reserved feature names). Ready, /// D-236: `body_id` is a Sol body. Sol is permanently out of the /// generation cascade and every DB-derived Atlas path — the client must /// keep its legacy authored `markers.json` read for Sol. `features` is empty. SolExcluded, /// DB/IO failure reading `atlas_feature_names` (message for the client log). Error(String), } /// One reserved feature-name entry (T-1169) — see /// [`crate::atlas::city_context_reader::FeatureNameRow`] for the reader-side /// row this is built from. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FeatureNameEntry { pub feature_id: u64, pub name: String, /// `"river"` | `"mountain"` (T-1169 scope — see /// [`crate::atlas::city_context_reader::FeatureNameRow::feature_type`]). pub feature_type: String, } /// A feature-names response: the body's reserved geographic feature names, or /// a non-ready status (T-1169). Mirrors [`CityNamesResponse`] exactly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureNamesResponse { pub body_id: String, pub status: FeatureNamesStatus, pub features: Vec, } /// Serve one feature-names request (T-1169): D-236 Sol check first (same /// reader method [`handle_city_names_request`] uses), then the names-only /// `atlas_feature_names` read. `city_reader` absent (no DB opened at startup) /// is reported as `Error`, matching `handle_city_names_request`'s convention. /// /// This is a POOL read, not a position-assignment lookup — it does not read /// `layer1::attach_feature_names`'s cascade output (which attaches these /// names to computed river-mouth/alpine-peak positions at generation time, /// not at DB-read time). The client pairs this name list with position data /// it already has from the cascade layer response. pub fn handle_feature_names_request( req: &FeatureNamesRequest, city_reader: Option<&CityContextReader>, ) -> FeatureNamesResponse { let Some(reader) = city_reader else { return FeatureNamesResponse { body_id: req.body_id.clone(), status: FeatureNamesStatus::Error("city context reader unavailable".to_string()), features: Vec::new(), }; }; match reader.is_sol_body(&req.body_id) { Ok(true) => { return FeatureNamesResponse { body_id: req.body_id.clone(), status: FeatureNamesStatus::SolExcluded, features: Vec::new(), }; } Ok(false) => {} Err(e) => { return FeatureNamesResponse { body_id: req.body_id.clone(), status: FeatureNamesStatus::Error(e.to_string()), features: Vec::new(), }; } } match reader.read_body_feature_names(&req.body_id) { Ok(rows) => FeatureNamesResponse { body_id: req.body_id.clone(), status: FeatureNamesStatus::Ready, features: rows .into_iter() .map(|r| FeatureNameEntry { feature_id: r.feature_id, name: r.name, feature_type: r.feature_type, }) .collect(), }, Err(e) => FeatureNamesResponse { body_id: req.body_id.clone(), status: FeatureNamesStatus::Error(e.to_string()), features: Vec::new(), }, } } #[cfg(test)] mod tests { use super::*; use rusqlite::Connection; use std::sync::atomic::{AtomicU32, Ordering}; static SEQ: AtomicU32 = AtomicU32::new(0); // ─── StarMapRequest/Response ───────────────────────────────────────────── #[test] fn star_map_request_round_trips_msgpack() { let req = StarMapRequest { star_map: true }; let bytes = rmp_serde::to_vec_named(&req).expect("encode"); let decoded: StarMapRequest = rmp_serde::from_slice(&bytes).expect("decode"); assert!(decoded.star_map); } #[test] fn handle_star_map_request_reads_and_parses_file() { let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_starmap_{}_{n}.json", std::process::id())); std::fs::write( &path, r#"{"_meta": {"v": 1}, "nodes": [1, 2], "edges": []}"#, ) .expect("write fixture json"); let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path); assert_eq!(resp.status, StarMapStatus::Ready); let data = resp.data.as_ref().expect("data present on Ready"); assert_eq!(data["nodes"][0], 1); // Round trip the whole response through MessagePack too (the actual // wire path). let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); let decoded: StarMapResponse = rmp_serde::from_slice(&bytes).expect("decode"); assert_eq!(decoded.status, StarMapStatus::Ready); assert_eq!(decoded.data.unwrap()["nodes"][1], 2); let _ = std::fs::remove_file(&path); } #[test] fn handle_star_map_request_missing_file_is_error_not_panic() { let path = PathBuf::from("/nonexistent/sr-test/star_map_data.json"); let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path); assert!(matches!(resp.status, StarMapStatus::Error(_))); assert!(resp.data.is_none()); } #[test] fn handle_star_map_request_malformed_json_is_error() { let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_starmap_bad_{}_{n}.json", std::process::id())); std::fs::write(&path, "{ not valid json").expect("write fixture json"); let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path); assert!(matches!(resp.status, StarMapStatus::Error(_))); assert!(resp.data.is_none()); let _ = std::fs::remove_file(&path); } // ─── CityNamesRequest/Response ─────────────────────────────────────────── #[test] fn city_names_request_round_trips_msgpack() { let req = CityNamesRequest { city_names: true, body_id: "GJ1c".into(), }; let bytes = rmp_serde::to_vec_named(&req).expect("encode"); let decoded: CityNamesRequest = rmp_serde::from_slice(&bytes).expect("decode"); assert!(decoded.city_names); assert_eq!(decoded.body_id, "GJ1c"); } #[test] fn city_names_response_round_trips_msgpack() { let resp = CityNamesResponse { body_id: "GJ1c".into(), status: CityNamesStatus::Ready, cities: vec![CityNameEntry { city_id: 1, name: "Port Aldren".into(), is_capital: true, }], }; let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); let decoded: CityNamesResponse = rmp_serde::from_slice(&bytes).expect("decode"); assert_eq!(decoded.status, CityNamesStatus::Ready); assert_eq!(decoded.cities[0].name, "Port Aldren"); assert!(decoded.cities[0].is_capital); } /// Minimal db mirroring what `is_sol_body` + `read_body_city_names` + /// `read_body_feature_names` need: `bodies`, `system_history`, /// `atlas_city_names`, `atlas_feature_names`. fn make_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf { let n = SEQ.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("sr_adp_{}_{n}.db", std::process::id())); let _ = std::fs::remove_file(&path); let conn = Connection::open(&path).expect("create db"); conn.execute_batch( "CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL); CREATE TABLE system_history ( system_id TEXT PRIMARY KEY, settlement_wave TEXT ); CREATE TABLE atlas_city_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, body_id TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'city' ); CREATE TABLE atlas_feature_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, body_id TEXT NOT NULL, name TEXT NOT NULL, feature_type TEXT NOT NULL );", ) .expect("create tables"); conn.execute( "INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)", rusqlite::params![body_id, system_id], ) .expect("insert body"); if let Some(wave) = settlement_wave { conn.execute( "INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)", rusqlite::params![system_id, wave], ) .expect("insert system_history"); } drop(conn); path } fn insert_city(db: &Path, body_id: &str, name: &str, kind: &str) { let conn = Connection::open(db).expect("reopen"); conn.execute( "INSERT INTO atlas_city_names (body_id, name, kind) VALUES (?1, ?2, ?3)", rusqlite::params![body_id, name, kind], ) .expect("insert city"); } fn insert_feature(db: &Path, body_id: &str, name: &str, feature_type: &str) { let conn = Connection::open(db).expect("reopen"); conn.execute( "INSERT INTO atlas_feature_names (body_id, name, feature_type) VALUES (?1, ?2, ?3)", rusqlite::params![body_id, name, feature_type], ) .expect("insert feature"); } #[test] fn handle_city_names_request_no_reader_is_error() { let resp = handle_city_names_request( &CityNamesRequest { city_names: true, body_id: "GJ1c".into(), }, None, ); assert!(matches!(resp.status, CityNamesStatus::Error(_))); assert!(resp.cities.is_empty()); } #[test] fn handle_city_names_request_sol_body_is_excluded() { // D-236: system_id = GJ-0 → SolExcluded, no DB row read for cities. let db = make_db("Earth", "GJ-0", None); insert_city(&db, "Earth", "London", "capital"); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_city_names_request( &CityNamesRequest { city_names: true, body_id: "Earth".into(), }, Some(&reader), ); assert_eq!(resp.status, CityNamesStatus::SolExcluded); assert!( resp.cities.is_empty(), "Sol-excluded response must carry no cities even though the row exists" ); } #[test] fn handle_city_names_request_sol_body_via_settlement_wave_is_excluded() { // D-236's second signal: settlement_wave = 'origin' excludes even a // non-GJ-0 system_id. let db = make_db("Weirdbody", "GJ-999", Some("origin")); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_city_names_request( &CityNamesRequest { city_names: true, body_id: "Weirdbody".into(), }, Some(&reader), ); assert_eq!(resp.status, CityNamesStatus::SolExcluded); } #[test] fn handle_city_names_request_ordinary_body_returns_names() { let db = make_db("GJ1c", "GJ-1", Some("first_wave")); insert_city(&db, "GJ1c", "Port Aldren", "capital"); insert_city(&db, "GJ1c", "Millbrook", "city"); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_city_names_request( &CityNamesRequest { city_names: true, body_id: "GJ1c".into(), }, Some(&reader), ); assert_eq!(resp.status, CityNamesStatus::Ready); assert_eq!(resp.cities.len(), 2); assert_eq!(resp.cities[0].name, "Port Aldren"); assert!(resp.cities[0].is_capital); assert_eq!(resp.cities[1].name, "Millbrook"); assert!(!resp.cities[1].is_capital); } #[test] fn handle_city_names_request_unknown_body_is_ready_with_empty_list() { // Matches read_body_settlements' existing convention: unknown body → // empty list under Ready, not a distinct NotFound status. let db = make_db("GJ1c", "GJ-1", Some("first_wave")); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_city_names_request( &CityNamesRequest { city_names: true, body_id: "ghost".into(), }, Some(&reader), ); assert_eq!(resp.status, CityNamesStatus::Ready); assert!(resp.cities.is_empty()); } // ─── FeatureNamesRequest/Response (T-1169) ─────────────────────────────── #[test] fn feature_names_request_round_trips_msgpack() { let req = FeatureNamesRequest { feature_names: true, body_id: "GJ1c".into(), }; let bytes = rmp_serde::to_vec_named(&req).expect("encode"); let decoded: FeatureNamesRequest = rmp_serde::from_slice(&bytes).expect("decode"); assert!(decoded.feature_names); assert_eq!(decoded.body_id, "GJ1c"); } #[test] fn feature_names_response_round_trips_msgpack() { let resp = FeatureNamesResponse { body_id: "GJ1c".into(), status: FeatureNamesStatus::Ready, features: vec![FeatureNameEntry { feature_id: 1, name: "Serra Verde".into(), feature_type: "mountain".into(), }], }; let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); let decoded: FeatureNamesResponse = rmp_serde::from_slice(&bytes).expect("decode"); assert_eq!(decoded.status, FeatureNamesStatus::Ready); assert_eq!(decoded.features[0].name, "Serra Verde"); assert_eq!(decoded.features[0].feature_type, "mountain"); } #[test] fn handle_feature_names_request_no_reader_is_error() { let resp = handle_feature_names_request( &FeatureNamesRequest { feature_names: true, body_id: "GJ1c".into(), }, None, ); assert!(matches!(resp.status, FeatureNamesStatus::Error(_))); assert!(resp.features.is_empty()); } #[test] fn handle_feature_names_request_sol_body_is_excluded() { // D-236: system_id = GJ-0 → SolExcluded, no DB row read for features. let db = make_db("Earth", "GJ-0", None); insert_feature(&db, "Earth", "Thames", "river"); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_feature_names_request( &FeatureNamesRequest { feature_names: true, body_id: "Earth".into(), }, Some(&reader), ); assert_eq!(resp.status, FeatureNamesStatus::SolExcluded); assert!( resp.features.is_empty(), "Sol-excluded response must carry no features even though the row exists" ); } #[test] fn handle_feature_names_request_sol_body_via_settlement_wave_is_excluded() { // D-236's second signal: settlement_wave = 'origin' excludes even a // non-GJ-0 system_id. let db = make_db("Weirdbody", "GJ-999", Some("origin")); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_feature_names_request( &FeatureNamesRequest { feature_names: true, body_id: "Weirdbody".into(), }, Some(&reader), ); assert_eq!(resp.status, FeatureNamesStatus::SolExcluded); } #[test] fn handle_feature_names_request_ordinary_body_returns_names() { let db = make_db("GJ1c", "GJ-1", Some("first_wave")); insert_feature(&db, "GJ1c", "Wiesenbach", "mountain"); insert_feature(&db, "GJ1c", "Kaltfluss", "river"); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_feature_names_request( &FeatureNamesRequest { feature_names: true, body_id: "GJ1c".into(), }, Some(&reader), ); assert_eq!(resp.status, FeatureNamesStatus::Ready); assert_eq!(resp.features.len(), 2); assert_eq!(resp.features[0].name, "Wiesenbach"); assert_eq!(resp.features[0].feature_type, "mountain"); assert_eq!(resp.features[1].name, "Kaltfluss"); assert_eq!(resp.features[1].feature_type, "river"); } #[test] fn handle_feature_names_request_unknown_body_is_ready_with_empty_list() { // Matches handle_city_names_request's existing convention: unknown // body → empty list under Ready, not a distinct NotFound status. let db = make_db("GJ1c", "GJ-1", Some("first_wave")); let reader = CityContextReader::open(&db).expect("open"); let resp = handle_feature_names_request( &FeatureNamesRequest { feature_names: true, body_id: "ghost".into(), }, Some(&reader), ); assert_eq!(resp.status, FeatureNamesStatus::Ready); assert!(resp.features.is_empty()); } }