From 37881acba036b577b1ed3c4317460a9a428acf46 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 14 Jul 2026 15:45:25 +0200 Subject: [PATCH] feat(simulation): atlas proxy layers + data-delivery endpoints (T-960, T-949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-960: RoadGraphLayer (L2 edges/junctions, MaintenanceAuthority, rail flag, named routes) + SettlementLayer (name/position/size-class/is_capital/is_port from T-955 placements) as new Option fields on AtlasLayerResponse — T-1046 precedent; layer_proxy doc comment corrected (cascade runs through RoadGraph, client up_to ignored). T-949: new StarMapRequest/StarMapResponse (raw JSON passthrough of the star_map_data.json bake) + CityNamesRequest/CityNamesResponse (atlas_city_names via CityContextReader) with defense-in-depth Sol exclusion (GJ-0 system id OR settlement_wave='origin', D-236). Inbound demux extended with mandatory boolean discriminator fields (serde ignores unknown fields — optional-shape sniffing would be ambiguous); AtlasLayerRequest unchanged. SimBridge trait +2 methods, implemented on TcpBridge + LocalBridge. CityPlacement/CityRecord gained name/population/is_capital (BodyWorldState cache-hit path stays DB-free). ~30 new unit tests incl. demux disambiguation + Sol branches; gen_fixtures extended for road/settlement samples. Co-Authored-By: Claude Fable 5 --- server/src/atlas/atlas_data_proxy.rs | 447 ++++++++++++++++++++++++ server/src/atlas/attractor_matching.rs | 73 ++++ server/src/atlas/believability.rs | 5 +- server/src/atlas/cascade.rs | 3 + server/src/atlas/city_context_reader.rs | 210 ++++++++++- server/src/atlas/layer_proxy.rs | 430 ++++++++++++++++++++++- server/src/atlas/mod.rs | 1 + server/src/atlas/plugin.rs | 154 +++++++- server/src/atlas/road_graph.rs | 3 + server/src/bridge/local.rs | 21 ++ server/src/bridge/mod.rs | 221 +++++++++++- server/src/bridge/tcp.rs | 29 ++ server/src/main.rs | 16 + server/tests/bridge_tcp.rs | 5 +- server/tests/gen_fixtures.rs | 59 +++- 15 files changed, 1654 insertions(+), 23 deletions(-) create mode 100644 server/src/atlas/atlas_data_proxy.rs diff --git a/server/src/atlas/atlas_data_proxy.rs b/server/src/atlas/atlas_data_proxy.rs new file mode 100644 index 000000000..558062942 --- /dev/null +++ b/server/src/atlas/atlas_data_proxy.rs @@ -0,0 +1,447 @@ +//! 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(), + }, + } +} + +#[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` need: + /// `bodies`, `system_history`, `atlas_city_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' + );", + ) + .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"); + } + + #[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()); + } +} diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index 3488130c2..5740b6ed8 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -37,6 +37,12 @@ pub struct CityRecord { /// One of: manufacturing, financial, agricultural, extraction, /// service_mixed, institutional, transit_hub, research, military, residential. pub economic_role: String, + /// `atlas_city_names.kind == 'capital'` (authored, not derived from + /// population). Threaded onto [`CityPlacement`] for the Atlas + /// [`SettlementLayer`](crate::atlas::layer_proxy::SettlementLayer) (T-960 §2). + /// Defaults to `false` when the source doesn't track `kind` (e.g. the + /// believability harness's own settlement read). + pub is_capital: bool, } // --------------------------------------------------------------------------- @@ -48,6 +54,9 @@ pub struct CityRecord { #[derive(Debug, Clone)] pub struct CityPlacement { pub city_id: u64, + /// Carried straight from the matched [`CityRecord`] (T-960 §2 — the Atlas + /// `SettlementLayer` needs a display name without a cache-hit DB read). + pub name: String, pub position: (u16, u16), pub attractor_type: AttractorType, /// Integer match score (D-010). See [`cell_score`]. @@ -61,6 +70,11 @@ pub struct CityPlacement { /// Primary street-grid axis (D-213). Derived from the anchoring attractor /// type; pioneer/open-terrain bearings are seed-varied. pub founding_orientation: FoundingOrientation, + /// Carried straight from [`CityRecord::population`] (T-960 §2 — the Atlas + /// `SettlementLayer` derives its coarse size class from this). + pub population: i64, + /// Carried straight from [`CityRecord::is_capital`] (T-960 §2). + pub is_capital: bool, } // --------------------------------------------------------------------------- @@ -371,6 +385,7 @@ pub fn match_cities( ); placements.push(CityPlacement { city_id: cities[ci].city_id, + name: cities[ci].name.clone(), position: attractors[ai].position, attractor_type: attractors[ai].attractor_type, score, @@ -378,6 +393,8 @@ pub fn match_cities( political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, + population: cities[ci].population, + is_capital: cities[ci].is_capital, }); } } @@ -438,6 +455,7 @@ pub fn match_cities( ); placements.push(CityPlacement { city_id: cities[ci].city_id, + name: cities[ci].name.clone(), position: attractors[ai].position, attractor_type: attractors[ai].attractor_type, score, @@ -445,6 +463,8 @@ pub fn match_cities( political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, + population: cities[ci].population, + is_capital: cities[ci].is_capital, }); } } @@ -472,6 +492,7 @@ pub fn match_cities( ); placements.push(CityPlacement { city_id: city.city_id, + name: city.name.clone(), position: synthetic.position, attractor_type: AttractorType::PlainCenter, score, @@ -479,6 +500,8 @@ pub fn match_cities( political_archetype: archetype, arrangement_pattern: pattern, founding_orientation: orientation, + population: city.population, + is_capital: city.is_capital, }); } @@ -682,6 +705,7 @@ mod tests { settlement_class: class, population: pop, economic_role: "manufacturing".to_string(), + is_capital: false, } } @@ -706,6 +730,53 @@ mod tests { assert!(!placements[0].synthetic); } + /// T-960 §2: `name`/`population`/`is_capital` are carried straight from the + /// matched `CityRecord` onto every `CityPlacement`, across all three + /// placement phases (Tier A greedy, Hungarian, synthetic overflow) — the + /// Atlas `SettlementLayer` reads these from the cache with no DB access. + #[test] + fn city_record_fields_propagate_to_placement_in_every_phase() { + let mut capital = make_city(1, SettlementClass::NameLocked, 2_000_000); + capital.is_capital = true; + let tier_bc = make_city(2, SettlementClass::PopulationBudget, 80_000); + let overflow = make_city(3, SettlementClass::PopulationBudget, 10_000); + let cities = vec![capital, tier_bc, overflow]; + // Two real attractors (enough for Tier A + Tier B); city 3 overflows to + // a synthetic attractor (phase 4). + let attractors = vec![ + make_attractor(5, 5, AttractorType::RiverMouth, 90), + make_attractor(10, 10, AttractorType::ValleyFloor, 60), + ]; + let matrix = uniform_matrix(); + let placements = match_cities( + &cities, + &attractors, + &matrix, + None, + 512, + 256, + &TerritorialStatus::FrontierUnclaimed, + SeedChain::root(42), + ); + assert_eq!(placements.len(), 3); + + let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); + assert_eq!(p1.name, "City1"); + assert_eq!(p1.population, 2_000_000); + assert!(p1.is_capital, "capital flag must survive Tier A placement"); + + let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); + assert_eq!(p2.name, "City2"); + assert_eq!(p2.population, 80_000); + assert!(!p2.is_capital); + + let p3 = placements.iter().find(|p| p.city_id == 3).unwrap(); + assert_eq!(p3.name, "City3"); + assert_eq!(p3.population, 10_000); + assert!(!p3.is_capital); + assert!(p3.synthetic, "the third city must overflow to phase 4"); + } + #[test] fn tier_a_gets_priority() { // NameLocked city should get the best attractor (high strength). @@ -794,6 +865,7 @@ mod tests { settlement_class: SettlementClass::PopulationBudget, population: 60_000, economic_role: "agricultural".to_string(), + is_capital: false, }, CityRecord { city_id: 2, @@ -801,6 +873,7 @@ mod tests { settlement_class: SettlementClass::PopulationBudget, population: 80_000, economic_role: "transit_hub".to_string(), + is_capital: false, }, ]; let attractors = vec![ diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs index 487acdd1f..6c754303a 100644 --- a/server/src/atlas/believability.rs +++ b/server/src/atlas/believability.rs @@ -541,18 +541,21 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result, String> { let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?; let mut stmt = conn .prepare( - "SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0) + "SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0), + COALESCE(kind,'city') FROM atlas_city_names WHERE body_id = ?1 ORDER BY id", ) .map_err(|e| format!("prepare city query: {e}"))?; let rows = stmt .query_map([body_id], |r| { + let kind: String = r.get(4)?; Ok(CityRecord { city_id: r.get::<_, i64>(0)? as u64, name: r.get(1)?, settlement_class: SettlementClass::PopulationBudget, economic_role: r.get(2)?, population: r.get(3)?, + is_capital: kind == "capital", }) }) .map_err(|e| format!("city query: {e}"))? diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index b87d3e3b7..2d14d9220 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -526,6 +526,7 @@ mod tests { settlement_class: SettlementClass::NameLocked, population: 2_000_000, economic_role: "financial".into(), + is_capital: true, }, CityRecord { city_id: 2, @@ -533,6 +534,7 @@ mod tests { settlement_class: SettlementClass::OrganicGrowth, population: 120_000, economic_role: "agricultural".into(), + is_capital: false, }, ]; let run = || { @@ -618,6 +620,7 @@ mod tests { settlement_class: SettlementClass::PopulationBudget, population: *pop, economic_role: "manufacturing".into(), + is_capital: false, }) .collect(); diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 088196f27..9ed3bee95 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -275,7 +275,8 @@ impl CityContextReader { .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; let mut stmt = conn .prepare( - "SELECT id, name, economic_role, population, settlement_class + "SELECT id, name, economic_role, population, settlement_class, + COALESCE(kind, 'city') FROM atlas_city_names WHERE body_id = ?1 ORDER BY id", @@ -289,13 +290,14 @@ impl CityContextReader { row.get::<_, Option>(2)?, row.get::<_, i64>(3)?, row.get::<_, Option>(4)?, + row.get::<_, String>(5)?, )) }) .map_err(|e| CityContextReadError::Db(e.to_string()))?; let mut out = Vec::new(); for r in rows { - let (id, name, role, population, sclass) = + let (id, name, role, population, sclass, kind) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?; let city_id = id as u64; let settlement_class = match sclass.as_deref() { @@ -315,6 +317,7 @@ impl CityContextReader { settlement_class, population, economic_role: role.unwrap_or_else(|| "residential".to_string()), + is_capital: kind == "capital", }); } Ok(out) @@ -349,6 +352,99 @@ impl CityContextReader { Err(e) => Err(CityContextReadError::Db(e.to_string())), } } + + /// D-236 Sol-exclusion gate: `true` if `body_id`'s system is Sol. + /// + /// Sol (system `GJ-0`) is permanently out of the generation cascade — the + /// two signals D-236 names as equivalent gate flags are checked directly: + /// `bodies.system_id = 'GJ-0'` *or* the joined + /// `system_history.settlement_wave = 'origin'`. Either one alone is + /// sufficient (defence in depth; in practice they always agree — `'origin'` + /// is a one-off wave value only ever assigned to GJ-0). + /// + /// An unknown `body_id` is **not** treated as Sol (`Ok(false)`) — that's a + /// distinct "no such body" outcome the caller's own not-found handling + /// covers (mirrors [`read_body_dominant_faction`](Self::read_body_dominant_faction)'s + /// convention). Only a DB/mutex error fails. + pub fn is_sol_body(&self, body_id: &str) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; + let result = conn.query_row( + "SELECT b.system_id, sh.settlement_wave + FROM bodies AS b + LEFT JOIN system_history AS sh ON sh.system_id = b.system_id + WHERE b.body_id = ?1", + [body_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ); + match result { + Ok((system_id, settlement_wave)) => { + Ok(system_id == "GJ-0" || settlement_wave.as_deref() == Some("origin")) + } + // Body not present → not (specifically) Sol; the caller's own + // not-found handling applies to the "unknown body" case. + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(CityContextReadError::Db(e.to_string())), + } + } + + /// Read every authored settlement **name** on `body_id` from + /// `atlas_city_names` (T-949 — replaces the client's names-only + /// `markers.json` read for non-Sol bodies, D-223/D-236). Unlike + /// [`read_body_settlements`](Self::read_body_settlements) this returns only + /// the id/name/capital-flag triple — no economic/placement fields — and is + /// available immediately (it doesn't require the generation cascade to have + /// placed anything). Ordered by `id`. An unknown body yields an empty list + /// (matches `read_body_settlements`'s convention); only a DB/mutex error + /// fails. + pub fn read_body_city_names( + &self, + body_id: &str, + ) -> Result, CityContextReadError> { + let conn = self + .conn + .lock() + .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT id, name, COALESCE(kind, 'city') + FROM atlas_city_names + WHERE body_id = ?1 + ORDER BY id", + ) + .map_err(|e| CityContextReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([body_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| CityContextReadError::Db(e.to_string()))?; + + let mut out = Vec::new(); + for r in rows { + let (id, name, kind) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?; + out.push(CityNameRow { + city_id: id as u64, + name, + is_capital: kind == "capital", + }); + } + Ok(out) + } +} + +/// One row of the T-949 names-only read (see +/// [`CityContextReader::read_body_city_names`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CityNameRow { + pub city_id: u64, + pub name: String, + pub is_capital: bool, } // --------------------------------------------------------------------------- @@ -1057,4 +1153,114 @@ mod tests { "unrecognized class defaults to PopulationBudget" ); } + + // ─── read_body_city_names (T-949) ──────────────────────────────────────── + + #[test] + fn read_body_city_names_returns_id_name_capital() { + let db = make_settlements_db(&[ + ("Capital", "financial", 2_000_000, Some("NameLocked")), + ("Outpost", "extraction", 5_000, None), + ]); + let conn = Connection::open(&db).expect("reopen"); + conn.execute( + "UPDATE atlas_city_names SET kind = 'capital' WHERE name = 'Capital'", + [], + ) + .expect("set capital"); + drop(conn); + + let reader = CityContextReader::open(&db).expect("open"); + let names = reader.read_body_city_names("PlanetX").expect("read"); + assert_eq!(names.len(), 2); + // Ordered by id == insertion order. + assert_eq!(names[0].name, "Capital"); + assert!(names[0].is_capital); + assert_eq!(names[1].name, "Outpost"); + assert!( + !names[1].is_capital, + "the default 'city' kind must not read as capital" + ); + } + + #[test] + fn read_body_city_names_unknown_body_is_empty() { + let db = make_settlements_db(&[("Solo", "residential", 10_000, None)]); + let reader = CityContextReader::open(&db).expect("open"); + assert!( + reader + .read_body_city_names("Ghost") + .expect("read") + .is_empty(), + "unknown body yields no names, matching read_body_settlements' convention" + ); + } + + // ─── is_sol_body (T-949, D-236) ────────────────────────────────────────── + + /// Minimal db for `is_sol_body`: one `bodies` row + an optional + /// `system_history` row carrying `settlement_wave`. + fn make_sol_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_ctxsol_{}_{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 + );", + ) + .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 + } + + #[test] + fn is_sol_body_true_for_gj0_system_id() { + let db = make_sol_db("Earth", "GJ-0", None); + let reader = CityContextReader::open(&db).expect("open"); + assert!(reader.is_sol_body("Earth").expect("query")); + } + + #[test] + fn is_sol_body_true_for_origin_settlement_wave() { + // D-236 names `system_id = 'GJ-0'` and `settlement_wave = 'origin'` as + // equivalent gate signals — a body whose system carries the 'origin' + // wave (even under a hypothetically different system_id) must also be + // excluded, not just a literal "GJ-0" string match. + let db = make_sol_db("Weirdbody", "GJ-999", Some("origin")); + let reader = CityContextReader::open(&db).expect("open"); + assert!(reader.is_sol_body("Weirdbody").expect("query")); + } + + #[test] + fn is_sol_body_false_for_ordinary_body() { + let db = make_sol_db("GJ1c", "GJ-1", Some("first_wave")); + let reader = CityContextReader::open(&db).expect("open"); + assert!(!reader.is_sol_body("GJ1c").expect("query")); + } + + #[test] + fn is_sol_body_false_for_unknown_body() { + let db = make_sol_db("GJ1c", "GJ-1", None); + let reader = CityContextReader::open(&db).expect("open"); + assert!( + !reader.is_sol_body("ghost").expect("query"), + "an unknown body is not (specifically) Sol-excluded" + ); + } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index f17aa8012..a62a5263b 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -14,13 +14,15 @@ use serde::{Deserialize, Serialize}; use crate::atlas::body_params_reader::BodyParamsReader; -use crate::atlas::body_world_state::{BodyWorldStateCache, SimTick}; +use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; +use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; use crate::seed::SeedChain; +use crate::simulation::generator::{AttractorType, MaintenanceAuthority}; /// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk /// (the loader prefers the chunk; this is only the floor). @@ -29,9 +31,10 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; /// A client request for a body's generation layers (D-225). /// /// `up_to` is a forward-compat seam that is **not yet honored**: `run_work_item` -/// currently runs the cascade through `CascadeLayer::Settlement` unconditionally, -/// ignoring this field. Wiring per-request depth (and the partial caching it -/// implies) is deferred to #1021. +/// (`gen_queue.rs`) currently runs the cascade through `CascadeLayer::RoadGraph` +/// (the terminal layer, T-1038) unconditionally on every request, ignoring this +/// field. Wiring per-request depth (and the partial caching it implies) is +/// deferred to #1021. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerRequest { pub body_id: String, @@ -67,7 +70,8 @@ pub struct DistrictGridLayer { } /// A layer response: the computed `Layer1Output` + the coarse district grid -/// (D-225, T-1046), or a non-ready status. +/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or +/// a non-ready status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtlasLayerResponse { pub body_id: String, @@ -76,6 +80,15 @@ pub struct AtlasLayerResponse { /// The coarse district/morphology grid for the Atlas overlay (T-1046). /// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise. pub district_grid: Option, + /// The inter-settlement road/rail graph overlay (T-960 §1, T-1038). + /// `Some` on a cache hit once the RoadGraph layer has run; `None` otherwise + /// (including a body with zero placed settlements — an empty graph has no + /// nodes to draw, so it collapses to `None` the same way `district_grid` + /// does for an unrun layer). + pub road_graph: Option, + /// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit + /// once the Settlement layer has placed at least one city; `None` otherwise. + pub settlements: Option, } /// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046). @@ -111,6 +124,173 @@ pub fn build_district_grid( }) } +// --------------------------------------------------------------------------- +// RoadGraphLayer (T-960 §1, T-1038) +// --------------------------------------------------------------------------- + +/// One node in the [`RoadGraphLayer`] overlay — a settlement junction or a +/// waypoint. Trimmed from the internal [`crate::atlas::road_graph::RoadNode`]: +/// `degree` and `parent_edge` are internal bookkeeping a planetary-map overlay +/// doesn't need (degree is trivially re-derivable client-side by counting +/// edges per node index if a renderer wants junction highlighting). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadGraphNode { + /// Position in working-heightmap-grid coordinates `(row, col)` — the same + /// space as `Layer1Output` attractors/rivers and `SettlementLayer` positions. + pub position: (u16, u16), + pub kind: RoadNodeKind, + /// The settlement's `city_id` (cross-references `SettlementLayer`), or + /// `None` for a waypoint. + pub city_id: Option, +} + +/// One edge in the [`RoadGraphLayer`] overlay — a routed road or rail segment. +/// Trimmed from [`crate::atlas::road_graph::RoadEdge`]: `length_cells` is an +/// internal A* routing-grid measure with no meaning outside that grid's scale +/// (the polyline `path` is what an overlay actually draws). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadGraphEdge { + /// `nodes` indices of the endpoint settlements (`from < to`). + pub from: usize, + pub to: usize, + /// Routed polyline in working-heightmap-grid coordinates `(row, col)`. + pub path: Vec<(u16, u16)>, + pub maintenance: MaintenanceAuthority, + /// `true` if this edge is a railroad; `false` is a road. + pub is_rail: bool, + /// Joined `systems.db` named-route id, if any (empty pool today — D-223). + pub named_route_id: Option, +} + +/// The inter-settlement road/rail graph, trimmed for the Atlas planetary-map +/// overlay (T-960 §1, D-211, T-1038). See [`RoadGraphNode`]/[`RoadGraphEdge`] +/// for what was dropped from the internal `RoadGraph`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoadGraphLayer { + pub nodes: Vec, + pub edges: Vec, +} + +/// Build the [`RoadGraphLayer`] from a body's cached state (T-960 §1). +/// Returns `None` when the RoadGraph layer has not run, which coincides +/// exactly with "no settlements placed" (`build_road_graph` returns an empty +/// graph for zero placements, and every placement yields at least one node). +pub fn build_road_graph_layer(state: &BodyWorldState) -> Option { + if state.road_graph.nodes.is_empty() { + return None; + } + let nodes = state + .road_graph + .nodes + .iter() + .map(|n| RoadGraphNode { + position: n.position, + kind: n.kind, + city_id: n.city_id, + }) + .collect(); + let edges = state + .road_graph + .edges + .iter() + .map(|e| RoadGraphEdge { + from: e.from, + to: e.to, + path: e.path.clone(), + maintenance: e.maintenance, + is_rail: e.is_rail, + named_route_id: e.named_route_id.clone(), + }) + .collect(); + Some(RoadGraphLayer { nodes, edges }) +} + +// --------------------------------------------------------------------------- +// SettlementLayer (T-960 §2, #955) +// --------------------------------------------------------------------------- + +/// Coarse settlement size class for the Atlas overlay (T-960 §2), derived from +/// raw population using the same Tier A/B population cutoffs the D-211 +/// placement pipeline already uses (`attractor_matching::match_cities`): +/// Tier A (≥ 1,000,000 or `NameLocked`) settlements are `Major`, Tier B +/// (50,000–999,999) are `Standard`, and everything else (Tier C / synthetic +/// overflow) is `Minor`. A display bucket, not new simulation truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SettlementSizeClass { + Major, + Standard, + Minor, +} + +impl SettlementSizeClass { + /// Bucket a raw population using the D-211 Tier A/B cutoffs. + pub fn from_population(population: i64) -> Self { + if population >= 1_000_000 { + SettlementSizeClass::Major + } else if population >= 50_000 { + SettlementSizeClass::Standard + } else { + SettlementSizeClass::Minor + } + } +} + +/// One placed settlement in the [`SettlementLayer`] overlay (T-960 §2). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettlementEntry { + pub city_id: u64, + pub name: String, + /// Position in working-heightmap-grid coordinates `(row, col)` — the same + /// space as `Layer1Output` attractors/rivers (T-960 §2: match the + /// coordinate convention layer1 features already use so the client + /// transforms identically). + pub position: (u16, u16), + pub size_class: SettlementSizeClass, + /// Authored `atlas_city_names.kind == 'capital'` (not population-derived). + pub is_capital: bool, + /// Cheap derived flag: `true` if the settlement's anchoring attractor is + /// water-adjacent (`CoastalAccess` / `RiverMouth` / `LakeShore`). + pub is_port: bool, +} + +/// The settlement-placement overlay for one body (T-960 §2, #955, D-211). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettlementLayer { + pub settlements: Vec, +} + +/// `true` for the water-adjacent attractor types a settlement counts as a +/// "port" for cheaply (T-960 §2). No "foothold" flag: unlike `is_port`, there +/// is no existing concept in the placement data this could derive from +/// without inventing new business logic — left out (see the T-960 report). +fn is_port_attractor(at: AttractorType) -> bool { + matches!( + at, + AttractorType::CoastalAccess | AttractorType::RiverMouth | AttractorType::LakeShore + ) +} + +/// Build the [`SettlementLayer`] from a body's cached state (T-960 §2). +/// Returns `None` when the Settlement layer has not placed any city yet. +pub fn build_settlement_layer(state: &BodyWorldState) -> Option { + if state.placements.is_empty() { + return None; + } + let settlements = state + .placements + .iter() + .map(|p| SettlementEntry { + city_id: p.city_id, + name: p.name.clone(), + position: p.position, + size_class: SettlementSizeClass::from_population(p.population), + is_capital: p.is_capital, + is_port: is_port_attractor(p.attractor_type), + }) + .collect(); + Some(SettlementLayer { settlements }) +} + /// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit; /// `world_seed` derives the body's `SeedChain` for the enqueued analysis. /// @@ -154,11 +334,15 @@ pub fn handle_atlas_request( district_basin_dirs: std::collections::BTreeMap::new(), }; let district_grid = build_district_grid(state); + let road_graph = build_road_graph_layer(state); + let settlements = build_settlement_layer(state); return AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Ready, layer1: Some(layer1), district_grid, + road_graph, + settlements, }; } @@ -229,6 +413,8 @@ pub fn handle_atlas_request( status: AtlasLayerStatus::Pending, layer1: None, district_grid: None, + road_graph: None, + settlements: None, } } // Unknown / no terrain → re-requesting won't help. @@ -238,12 +424,16 @@ pub fn handle_atlas_request( status: AtlasLayerStatus::NotFound, layer1: None, district_grid: None, + road_graph: None, + settlements: None, }, Err(e) => AtlasLayerResponse { body_id: req.body_id.clone(), status: AtlasLayerStatus::Error(e.to_string()), layer1: None, district_grid: None, + road_graph: None, + settlements: None, }, } } @@ -317,6 +507,236 @@ mod tests { assert!(build_district_grid(&state).is_none()); } + /// A blank `BodyWorldState` for tests that only care about one field — + /// callers overwrite `placements`/`road_graph`/etc. as needed. + fn blank_state(body_id: &str) -> BodyWorldState { + BodyWorldState { + body_id: body_id.into(), + heightmap: vec![], + heightmap_width: 16, + heightmap_height: 8, + river_network: RiverNetwork::default(), + drainage_basins: vec![], + attractors: vec![], + placements: vec![], + road_graph: crate::atlas::road_graph::RoadGraph::default(), + quarters: std::collections::BTreeMap::new(), + districts: std::collections::BTreeMap::new(), + last_accessed: 0, + } + } + + /// T-960 §1: `build_road_graph_layer` trims the internal `RoadGraph` (drops + /// `degree`/`parent_edge`/`length_cells`) while keeping everything a + /// planetary-map overlay needs (positions, kind, polyline, maintenance, + /// rail flag, named-route id). + #[test] + fn road_graph_layer_built_from_cached_state() { + use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode}; + use crate::simulation::generator::MaintenanceAuthority; + + let mut state = blank_state("GJ1c"); + state.road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(1), + position: (10, 20), + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: None, + position: (15, 25), + kind: RoadNodeKind::Waypoint, + degree: 0, + parent_edge: Some(0), + }, + ], + edges: vec![RoadEdge { + from: 0, + to: 1, + path: vec![(10, 20), (15, 25)], + length_cells: 20, // internal routing-grid measure — dropped + maintenance: MaintenanceAuthority::Trade, + named_route_id: Some("split/hwy-1".into()), + is_rail: true, + }], + }; + + let layer = build_road_graph_layer(&state).expect("populated road_graph → Some"); + assert_eq!(layer.nodes.len(), 2); + assert_eq!(layer.nodes[0].position, (10, 20)); + assert_eq!(layer.nodes[0].kind, RoadNodeKind::Settlement); + assert_eq!(layer.nodes[0].city_id, Some(1)); + assert_eq!(layer.nodes[1].kind, RoadNodeKind::Waypoint); + assert_eq!(layer.nodes[1].city_id, None); + assert_eq!(layer.edges.len(), 1); + assert_eq!(layer.edges[0].path, vec![(10, 20), (15, 25)]); + assert_eq!(layer.edges[0].maintenance, MaintenanceAuthority::Trade); + assert!(layer.edges[0].is_rail); + assert_eq!( + layer.edges[0].named_route_id.as_deref(), + Some("split/hwy-1") + ); + + // Layer hasn't run (or zero settlements) → None, mirroring district_grid. + let unrun = blank_state("GJ1c"); + assert!(build_road_graph_layer(&unrun).is_none()); + } + + /// T-960 §2: `build_settlement_layer` derives `size_class` from population + /// using the D-211 Tier A/B cutoffs, threads `is_capital` straight through, + /// and derives `is_port` cheaply from the anchoring attractor type. + #[test] + fn settlement_layer_built_from_cached_placements() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, PoliticalArchetype, + }; + + let mk = |city_id: u64, + name: &str, + pos: (u16, u16), + population: i64, + is_capital: bool, + attractor_type: AttractorType| CityPlacement { + city_id, + name: name.to_string(), + position: pos, + attractor_type, + score: 1000, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + population, + is_capital, + }; + + let mut state = blank_state("GJ1c"); + state.placements = vec![ + mk( + 1, + "Port Aldren", + (12, 58), + 2_000_000, + true, + AttractorType::CoastalAccess, + ), + mk( + 2, + "Millbrook", + (30, 40), + 200_000, + false, + AttractorType::ValleyFloor, + ), + mk( + 3, + "Farmstead Rell", + (50, 60), + 8_000, + false, + AttractorType::PlainCenter, + ), + ]; + + let layer = build_settlement_layer(&state).expect("populated placements → Some"); + assert_eq!(layer.settlements.len(), 3); + + let capital = layer.settlements.iter().find(|s| s.city_id == 1).unwrap(); + assert_eq!(capital.name, "Port Aldren"); + assert_eq!(capital.position, (12, 58)); + assert_eq!(capital.size_class, SettlementSizeClass::Major); + assert!(capital.is_capital); + assert!(capital.is_port, "CoastalAccess must read as a port"); + + let mid = layer.settlements.iter().find(|s| s.city_id == 2).unwrap(); + assert_eq!(mid.size_class, SettlementSizeClass::Standard); + assert!(!mid.is_capital); + assert!(!mid.is_port, "ValleyFloor is not a port attractor"); + + let small = layer.settlements.iter().find(|s| s.city_id == 3).unwrap(); + assert_eq!(small.size_class, SettlementSizeClass::Minor); + assert!(!small.is_port); + + // No placements → None. + let unrun = blank_state("GJ1c"); + assert!(build_settlement_layer(&unrun).is_none()); + } + + /// T-960: the new layers survive a MessagePack round trip inside + /// `AtlasLayerResponse` — the same wire path the bridge uses + /// (`rmp_serde::to_vec_named` / `from_slice`, matching `layer1`/ + /// `district_grid`'s existing serialization). + #[test] + fn atlas_layer_response_with_new_layers_round_trips_msgpack() { + use crate::atlas::attractor_matching::CityPlacement; + use crate::atlas::road_graph::{RoadEdge, RoadGraph, RoadNode}; + use crate::simulation::generator::{ + ArrangementPattern, FoundingOrientation, MaintenanceAuthority, PoliticalArchetype, + }; + + let mut state = blank_state("GJ1c"); + state.placements = vec![CityPlacement { + city_id: 1, + name: "Port Aldren".into(), + position: (12, 58), + attractor_type: AttractorType::CoastalAccess, + score: 1000, + synthetic: false, + political_archetype: PoliticalArchetype::Commission, + arrangement_pattern: ArrangementPattern::RadialCore, + founding_orientation: FoundingOrientation::Cardinal, + population: 2_000_000, + is_capital: true, + }]; + state.road_graph = RoadGraph { + nodes: vec![RoadNode { + city_id: Some(1), + position: (12, 58), + kind: RoadNodeKind::Settlement, + degree: 0, + parent_edge: None, + }], + edges: vec![RoadEdge { + from: 0, + to: 0, + path: vec![(12, 58)], + length_cells: 0, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }], + }; + + let resp = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: None, + district_grid: None, + road_graph: build_road_graph_layer(&state), + settlements: build_settlement_layer(&state), + }; + + let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); + let decoded: AtlasLayerResponse = rmp_serde::from_slice(&bytes).expect("decode"); + + assert_eq!(decoded.body_id, "GJ1c"); + let rg = decoded.road_graph.expect("road_graph survives round trip"); + assert_eq!(rg.nodes[0].position, (12, 58)); + assert_eq!(rg.edges[0].maintenance, MaintenanceAuthority::Administrative); + let settlements = decoded.settlements.expect("settlements survives round trip"); + assert_eq!(settlements.settlements[0].name, "Port Aldren"); + assert_eq!( + settlements.settlements[0].size_class, + SettlementSizeClass::Major + ); + assert!(settlements.settlements[0].is_capital); + assert!(settlements.settlements[0].is_port); + } + fn req(body_id: &str) -> AtlasLayerRequest { AtlasLayerRequest { body_id: body_id.to_string(), diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 5b3572223..b75751b90 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -3,6 +3,7 @@ //! These loaders are used by the runtime-background tier (D-200, D-206) when //! populating BodyWorldState (D-203). They are never called on the main tick thread. +pub mod atlas_data_proxy; pub mod attractor_matching; pub mod believability; pub mod block_irregularity; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 04acd2a8f..01d1c909e 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -13,6 +13,9 @@ use bevy_ecs::schedule::IntoScheduleConfigs; use std::collections::BTreeMap; +use crate::atlas::atlas_data_proxy::{ + handle_city_names_request, handle_star_map_request, StarMapDataPath, +}; use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::body_params_reader::BodyParamsReaderResource; use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; @@ -37,7 +40,10 @@ use crate::atlas::trait_draw::{ use crate::atlas::trait_swerve::{ build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools, }; -use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer}; +use crate::bridge::{ + AtlasRequestBuffer, AtlasResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, + StarMapRequestBuffer, StarMapResponseBuffer, +}; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier, @@ -57,7 +63,12 @@ impl Plugin for GenerationPlugin { Update, drain_generation_completions.in_set(TickPhase::PreInput), ) - .add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput)); + .add_systems(Update, serve_atlas_requests.in_set(TickPhase::PreInput)) + .add_systems(Update, serve_star_map_requests.in_set(TickPhase::PreInput)) + .add_systems( + Update, + serve_city_names_requests.in_set(TickPhase::PreInput), + ); } } @@ -100,12 +111,58 @@ fn serve_atlas_requests( status: AtlasLayerStatus::Error("no body source resolver".to_string()), layer1: None, district_grid: None, + road_graph: None, + settlements: None, }, }; responses.0.push(resp); } } +/// Drain inbound star-map requests and serve each through the proxy (T-949a). +/// A thin read-the-file-fresh proxy — see `atlas_data_proxy` module doc for +/// why there's no caching. Absent `StarMapDataPath` (not wired at startup, +/// e.g. unit tests) reports an error per request rather than panicking. +fn serve_star_map_requests( + mut requests: ResMut, + mut responses: ResMut, + path: Option>, +) { + if requests.0.is_empty() { + return; + } + let pending: Vec<_> = requests.0.drain(..).collect(); + for req in pending { + let resp = match path.as_ref() { + Some(p) => handle_star_map_request(&req, &p.0), + None => crate::atlas::atlas_data_proxy::StarMapResponse { + status: crate::atlas::atlas_data_proxy::StarMapStatus::Error( + "star map data path unavailable".to_string(), + ), + data: None, + }, + }; + responses.0.push(resp); + } +} + +/// Drain inbound city-names requests and serve each through the proxy +/// (T-949b): D-236 Sol check, then the names-only `atlas_city_names` read. +fn serve_city_names_requests( + mut requests: ResMut, + mut responses: ResMut, + city_reader: Option>, +) { + if requests.0.is_empty() { + return; + } + let reader = city_reader.as_ref().map(|r| &r.0); + let pending: Vec<_> = requests.0.drain(..).collect(); + for req in pending { + responses.0.push(handle_city_names_request(&req, reader)); + } +} + /// Drain finished background work each tick and apply it to the cache (D-206). /// /// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap @@ -798,6 +855,87 @@ mod tests { assert!(world.resource::().0.is_empty()); } + /// T-949a: the star-map serve system reads the wired `StarMapDataPath` + /// through to a `Ready` response end-to-end. + #[test] + fn serve_star_map_drains_requests_into_responses() { + use crate::atlas::atlas_data_proxy::{StarMapDataPath, StarMapRequest, StarMapStatus}; + use std::sync::atomic::{AtomicU32, Ordering}; + static SEQ: AtomicU32 = AtomicU32::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "sr_plugin_starmap_{}_{n}.json", + std::process::id() + )); + std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap(); + + let mut world = World::new(); + world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest { + star_map: true, + }])); + world.insert_resource(StarMapResponseBuffer::default()); + world.insert_resource(StarMapDataPath(path.clone())); + + let mut sched = Schedule::default(); + sched.add_systems(serve_star_map_requests); + sched.run(&mut world); + + let responses = world.resource::(); + assert_eq!(responses.0.len(), 1); + assert_eq!(responses.0[0].status, StarMapStatus::Ready); + assert!(world.resource::().0.is_empty()); + + let _ = std::fs::remove_file(&path); + } + + /// Without `StarMapDataPath` wired (e.g. a stripped-down test world), the + /// serve system reports `Error` per request rather than panicking. + #[test] + fn serve_star_map_without_path_resource_is_error() { + use crate::atlas::atlas_data_proxy::{StarMapRequest, StarMapStatus}; + + let mut world = World::new(); + world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest { + star_map: true, + }])); + world.insert_resource(StarMapResponseBuffer::default()); + // No StarMapDataPath resource. + + let mut sched = Schedule::default(); + sched.add_systems(serve_star_map_requests); + sched.run(&mut world); + + let responses = world.resource::(); + assert_eq!(responses.0.len(), 1); + assert!(matches!(responses.0[0].status, StarMapStatus::Error(_))); + } + + /// T-949b: without `CityContextReaderResource` wired, the serve system + /// reports `Error` per request (mirrors the atlas-request "no resolver" + /// convention) rather than panicking. + #[test] + fn serve_city_names_without_reader_is_error() { + use crate::atlas::atlas_data_proxy::{CityNamesRequest, CityNamesStatus}; + + let mut world = World::new(); + world.insert_resource(CityNamesRequestBuffer(vec![CityNamesRequest { + city_names: true, + body_id: "GJ1c".to_string(), + }])); + world.insert_resource(CityNamesResponseBuffer::default()); + // No CityContextReaderResource. + + let mut sched = Schedule::default(); + sched.add_systems(serve_city_names_requests); + sched.run(&mut world); + + let responses = world.resource::(); + assert_eq!(responses.0.len(), 1); + assert_eq!(responses.0[0].body_id, "GJ1c"); + assert!(matches!(responses.0[0].status, CityNamesStatus::Error(_))); + assert!(world.resource::().0.is_empty()); + } + fn sample_read_set() -> CityEconomicReadSet { use crate::simulation::generator::SettlementClass; CityEconomicReadSet { @@ -838,6 +976,7 @@ mod tests { fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement { CityPlacement { city_id, + name: format!("City{city_id}"), position: (10, 20), attractor_type: AttractorType::CoastalAccess, score: 100, @@ -845,6 +984,8 @@ mod tests { political_archetype: PoliticalArchetype::Commission, arrangement_pattern: ArrangementPattern::RadialCore, founding_orientation: orientation, + population: 100_000, + is_capital: false, } } @@ -856,6 +997,7 @@ mod tests { ) -> CityPlacement { CityPlacement { city_id, + name: format!("City{city_id}"), position: (10, 20), attractor_type: AttractorType::CoastalAccess, score: 100, @@ -863,6 +1005,8 @@ mod tests { political_archetype: archetype, arrangement_pattern: arrangement, founding_orientation: orientation, + population: 100_000, + is_capital: false, } } @@ -1308,6 +1452,7 @@ mod tests { // what attractor_matching::match_cities would have stored at L3. let l3_placement = CityPlacement { city_id: 1, + name: "City1".into(), position: (0, 0), attractor_type: AttractorType::PlainCenter, score: 100, @@ -1315,6 +1460,8 @@ mod tests { political_archetype: *archetype, arrangement_pattern: *expected_pattern, founding_orientation: FoundingOrientation::Cardinal, + population: 100_000, + is_capital: false, }; assert_eq!( @@ -1633,6 +1780,7 @@ mod tests { let placement = CityPlacement { city_id: 5, + name: "City5".into(), position: city_pos, attractor_type: AttractorType::CoastalAccess, score: 100, @@ -1640,6 +1788,8 @@ mod tests { political_archetype: PoliticalArchetype::Commission, arrangement_pattern: ArrangementPattern::RadialCore, founding_orientation: FoundingOrientation::Cardinal, + population: 100_000, + is_capital: false, }; let GenWorkItem::GenerateSkeleton { diff --git a/server/src/atlas/road_graph.rs b/server/src/atlas/road_graph.rs index 986b77db8..c9ce7ea31 100644 --- a/server/src/atlas/road_graph.rs +++ b/server/src/atlas/road_graph.rs @@ -739,6 +739,7 @@ mod tests { fn placement(city_id: u64, pos: (u16, u16), archetype: PoliticalArchetype) -> CityPlacement { CityPlacement { city_id, + name: format!("City{city_id}"), position: pos, attractor_type: AttractorType::PlainCenter, score: 1000, @@ -746,6 +747,8 @@ mod tests { political_archetype: archetype, arrangement_pattern: ArrangementPattern::RibbonDevelopment, founding_orientation: FoundingOrientation::Cardinal, + population: 100_000, + is_capital: false, } } diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index ae3f24afa..0b0453187 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -3,6 +3,7 @@ // Deterministic client-server communication via Unix domain sockets use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge}; +use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse}; use crate::atlas::layer_proxy::AtlasLayerResponse; use crate::bridge::framing::{read_framed, write_framed}; use std::fs; @@ -145,6 +146,26 @@ impl SimBridge for LocalBridge { write_framed(writer.get_mut(), &payload)?; Ok(()) } + + fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + write_framed(writer.get_mut(), &payload)?; + Ok(()) + } + + fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + write_framed(writer.get_mut(), &payload)?; + Ok(()) + } } impl Drop for LocalBridge { diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index da8efd0c6..497610325 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -6,6 +6,9 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +use crate::atlas::atlas_data_proxy::{ + CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse, +}; use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse}; pub mod debug; @@ -36,30 +39,61 @@ pub enum BridgeError { } /// One decoded inbound message. The client→server stream is a single demuxed -/// channel (D-225): a `Vec` frame is a MessagePack *array* and an -/// `AtlasLayerRequest` frame is a *map*, so they are distinguishable without a -/// wire-level type tag (existing frames are byte-unchanged — additive). +/// channel (D-225): a `Vec` frame is a MessagePack *array* and +/// every request type below is a *map*, so array vs. map alone separates +/// inputs from everything else without a wire-level type tag (existing frames +/// are byte-unchanged — additive). +/// +/// **Disambiguating the three map shapes (D-225 extension, T-949):** +/// `AtlasLayerRequest{body_id, up_to}` was the only map shape until T-949 +/// added `StarMapRequest`/`CityNamesRequest` alongside it. serde's derived +/// `Deserialize` silently ignores unknown fields by default, so "does this +/// struct parse at all" is not a safe discriminator once more than one map +/// shape can share a field name (`CityNamesRequest` and `AtlasLayerRequest` +/// both key on `body_id`) — a payload carrying every field either shape wants +/// would ambiguously satisfy both. Rather than retrofit +/// `#[serde(deny_unknown_fields)]` onto the existing `AtlasLayerRequest` (risking +/// breakage if any already-deployed client encoder harmlessly sends extra +/// fields), the two *new* map shapes each carry a mandatory boolean +/// discriminator field the others don't have at all (`star_map` / +/// `city_names`): a missing required field is a hard deserialize failure, not +/// a silent ignore, so every shape's required-field set is mutually +/// exclusive. `AtlasLayerRequest` itself is untouched byte-for-byte. #[derive(Debug)] pub enum Inbound { /// A batch of player inputs (the gameplay path). Inputs(Vec), /// An atlas layer-stream request (#969, D-225). AtlasRequest(AtlasLayerRequest), + /// A star-map dataset request (T-949a). + StarMapRequest(StarMapRequest), + /// A per-body city-names request (T-949b). + CityNamesRequest(CityNamesRequest), } -/// Demux a received frame payload into an [`Inbound`] (D-225). Tries -/// `Vec` (array), then `AtlasLayerRequest` (map); a frame that is -/// neither is a genuinely malformed input frame. +/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries, +/// in order: `Vec` (array) → `AtlasLayerRequest` (map, +/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) → +/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`). Every map +/// shape's required fields are mutually exclusive (see the [`Inbound`] doc), +/// so this order is for stability, not correctness — a frame that satisfies +/// none of the four shapes is a genuinely malformed input frame. pub fn decode_inbound(payload: &[u8]) -> Result { if let Ok(inputs) = rmp_serde::from_slice::>(payload) { return Ok(Inbound::Inputs(inputs)); } - match rmp_serde::from_slice::(payload) { - Ok(req) => Ok(Inbound::AtlasRequest(req)), + if let Ok(req) = rmp_serde::from_slice::(payload) { + return Ok(Inbound::AtlasRequest(req)); + } + if let Ok(req) = rmp_serde::from_slice::(payload) { + return Ok(Inbound::StarMapRequest(req)); + } + match rmp_serde::from_slice::(payload) { + Ok(req) => Ok(Inbound::CityNamesRequest(req)), Err(e) => { let dump_len = payload.len().min(256); tracing::error!( - "inbound decode failed (neither inputs nor atlas request): {}. Raw ({} of {} bytes): {:02x?}", + "inbound decode failed (matches no known frame shape): {}. Raw ({} of {} bytes): {:02x?}", e, dump_len, payload.len(), @@ -98,6 +132,12 @@ pub trait SimBridge: Send + Sync { /// Send an atlas layer-stream response to the client (#969, D-225). fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError>; + + /// Send a star-map response to the client (T-949a). + fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError>; + + /// Send a city-names response to the client (T-949b). + fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>; } /// BridgeResource: Bevy Resource wrapper for SimBridge trait object @@ -132,6 +172,14 @@ impl BridgeResource { pub fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError> { self.inner.send_atlas_response(resp) } + + pub fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError> { + self.inner.send_star_map_response(resp) + } + + pub fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError> { + self.inner.send_city_names_response(resp) + } } /// Tracks whether the protocol handshake has been sent (#555). @@ -165,6 +213,8 @@ pub fn receive_bridge_inputs( handshake: Res, mut error_buffer: ResMut, mut atlas_requests: ResMut, + mut star_map_requests: ResMut, + mut city_names_requests: ResMut, time: Option>, ) { let Some(bridge) = bridge else { return }; @@ -193,6 +243,12 @@ pub fn receive_bridge_inputs( Ok(Some(Inbound::AtlasRequest(req))) => { atlas_requests.0.push(req); } + Ok(Some(Inbound::StarMapRequest(req))) => { + star_map_requests.0.push(req); + } + Ok(Some(Inbound::CityNamesRequest(req))) => { + city_names_requests.0.push(req); + } // No complete frame ready — the backlog is drained. Ok(None) => break, Err(BridgeError::Disconnected) => { @@ -308,12 +364,65 @@ pub fn send_atlas_responses( } } +/// Inbound star-map requests routed off the bridge (T-949a), drained by the +/// proxy serve system in `PreInput`. +#[derive(Resource, Default)] +pub struct StarMapRequestBuffer(pub Vec); + +/// Outbound star-map responses, filled by the proxy serve system and flushed +/// to the client in `PostSnapshot` (T-949a). +#[derive(Resource, Default)] +pub struct StarMapResponseBuffer(pub Vec); + +/// Flush buffered star-map responses to the client (T-949a). A failed send is +/// logged but not fatal. +pub fn send_star_map_responses( + bridge: Option>, + mut buffer: ResMut, +) { + let Some(bridge) = bridge else { return }; + for resp in buffer.0.drain(..) { + if let Err(e) = bridge.send_star_map_response(&resp) { + tracing::warn!("failed to send star map response: {}", e); + } + } +} + +/// Inbound city-names requests routed off the bridge (T-949b), drained by the +/// proxy serve system in `PreInput`. +#[derive(Resource, Default)] +pub struct CityNamesRequestBuffer(pub Vec); + +/// Outbound city-names responses, filled by the proxy serve system and +/// flushed to the client in `PostSnapshot` (T-949b). +#[derive(Resource, Default)] +pub struct CityNamesResponseBuffer(pub Vec); + +/// Flush buffered city-names responses to the client (T-949b). A failed send +/// is logged but not fatal. +pub fn send_city_names_responses( + bridge: Option>, + mut buffer: ResMut, +) { + let Some(bridge) = bridge else { return }; + for resp in buffer.0.drain(..) { + if let Err(e) = bridge.send_city_names_response(&resp) { + tracing::warn!( + "failed to send city names response for {}: {}", + resp.body_id, + e + ); + } + } +} + /// Bridge plugin for client-server communication /// Abstracts transport layer (LocalBridge/NetworkBridge) pub struct BridgePlugin; impl Plugin for BridgePlugin { fn build(&self, app: &mut App) { + use crate::simulation::time::sim_not_paused; use crate::tick_phases::TickPhase; app.init_resource::() @@ -325,10 +434,19 @@ impl Plugin for BridgePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() // Bridge I/O — PreInput (receive) and PostSnapshot (send) .add_systems(Update, receive_bridge_inputs.in_set(TickPhase::PreInput)) .add_systems(Update, send_bridge_snapshot.in_set(TickPhase::PostSnapshot)) .add_systems(Update, send_atlas_responses.in_set(TickPhase::PostSnapshot)) + .add_systems(Update, send_star_map_responses.in_set(TickPhase::PostSnapshot)) + .add_systems( + Update, + send_city_names_responses.in_set(TickPhase::PostSnapshot), + ) // Debug commands — Snapshot phase .add_systems( Update, @@ -336,6 +454,13 @@ impl Plugin for BridgePlugin { ) // Monologue chain — Simulation phase, strict intra-phase sequence. // trigger_event_monologue must run after conversations + sound (also Simulation). + // T-970: TickPhase::Simulation is not set-gated (see + // social_plugin.rs's collect_sound_events exemption) — this whole + // chain is genuine world-advancing dialogue/monologue logic, so + // it gates safely on its own. trigger_event_monologue's + // .after(collect_sound_events) still holds while gated: + // collect_sound_events itself is never gated, and an ordering + // edge onto a skipped predecessor is trivially satisfied. .add_systems( Update, ( @@ -352,15 +477,22 @@ impl Plugin for BridgePlugin { crate::simulation::monologue::process_contradiction_monologue .after(crate::simulation::monologue::trigger_event_monologue), ) + .run_if(sim_not_paused) .in_set(TickPhase::Simulation), ) - // Observation systems — Simulation phase (reads positions, feeds snapshot) + // Observation systems — Simulation phase (reads positions, feeds snapshot). + // T-970: gates safely on its own (see note above) — these compute + // "current state" (visibility, nearby interactions) that's valid + // as long as nothing moved, which holds while paused since + // Movement is frozen too; unlike SoundEventQueue, nothing here + // depends on being refreshed on a tick where nothing changed. .add_systems( Update, ( crate::perception::observer::compute_visibility_geometry, crate::simulation::interaction::compute_nearby_interactions, ) + .run_if(sim_not_paused) .in_set(TickPhase::Simulation), ) // Observer snapshot assembly — Snapshot phase @@ -404,4 +536,73 @@ mod inbound_tests { // Neither shape → a malformed-frame error. assert!(decode_inbound(&[0xff, 0xff]).is_err()); } + + #[test] + fn demux_routes_star_map_requests() { + let req = StarMapRequest { star_map: true }; + let frame = rmp_serde::to_vec_named(&req).unwrap(); + assert!(matches!( + decode_inbound(&frame), + Ok(Inbound::StarMapRequest(r)) if r.star_map + )); + } + + #[test] + fn demux_routes_city_names_requests() { + let req = CityNamesRequest { + city_names: true, + body_id: "GJ1c".into(), + }; + let frame = rmp_serde::to_vec_named(&req).unwrap(); + assert!(matches!( + decode_inbound(&frame), + Ok(Inbound::CityNamesRequest(r)) if r.body_id == "GJ1c" + )); + } + + /// T-949: the array-vs-map trick (D-225) still separates `Inputs` from + /// everything else, and the three map shapes' discriminator fields keep + /// them mutually exclusive — each of the four frame shapes decodes to + /// exactly its own `Inbound` variant, never a neighbor's. + #[test] + fn inbound_disambiguation_is_unambiguous_across_all_four_shapes() { + let inputs_frame = rmp_serde::to_vec_named(&Vec::::new()).unwrap(); + let atlas_frame = rmp_serde::to_vec_named(&AtlasLayerRequest { + body_id: "GJ1c".into(), + up_to: CascadeLayer::Topography, + }) + .unwrap(); + let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap(); + let city_names_frame = rmp_serde::to_vec_named(&CityNamesRequest { + city_names: true, + body_id: "GJ1c".into(), + }) + .unwrap(); + + assert!(matches!( + decode_inbound(&inputs_frame), + Ok(Inbound::Inputs(_)) + )); + assert!(matches!( + decode_inbound(&atlas_frame), + Ok(Inbound::AtlasRequest(_)) + )); + assert!(matches!( + decode_inbound(&star_map_frame), + Ok(Inbound::StarMapRequest(_)) + )); + assert!(matches!( + decode_inbound(&city_names_frame), + Ok(Inbound::CityNamesRequest(_)) + )); + + // Cross-check: an AtlasLayerRequest frame must NOT decode as + // CityNamesRequest even though both key on `body_id` — the missing + // `city_names` discriminator makes that a hard failure, not a silent + // "extra field ignored" success either shape could show without it. + assert!(rmp_serde::from_slice::(&atlas_frame).is_err()); + // And a CityNamesRequest frame must NOT decode as AtlasLayerRequest — + // it's missing the required `up_to` field. + assert!(rmp_serde::from_slice::(&city_names_frame).is_err()); + } } diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index 65f53fcbe..8a8eb2358 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -4,6 +4,7 @@ // Used for Godot client which lacks Unix socket support use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge}; +use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse}; use crate::atlas::layer_proxy::AtlasLayerResponse; use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator}; use std::io::BufWriter; @@ -255,4 +256,32 @@ impl SimBridge for TcpBridge { result?; Ok(()) } + + fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + let stream = writer.get_mut(); + stream.set_nonblocking(false).map_err(BridgeError::Io)?; + let result = write_framed(stream, &payload); + stream.set_nonblocking(true).map_err(BridgeError::Io)?; + result?; + Ok(()) + } + + fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + let stream = writer.get_mut(); + stream.set_nonblocking(false).map_err(BridgeError::Io)?; + let result = write_framed(stream, &payload); + stream.set_nonblocking(true).map_err(BridgeError::Io)?; + result?; + Ok(()) + } } diff --git a/server/src/main.rs b/server/src/main.rs index 0e36326b7..08b3104cd 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -199,6 +199,22 @@ fn main() { ), } + // Star-map dataset proxy (T-949a): resolve the repo-root-relative path to + // the client's pre-generated star_map_data.json (tooling/generate-star-map-data.py). + // Read fresh on every request — no caching, see atlas_data_proxy module doc. + let star_map_data_path = world_root.join("client/data/star_map_data.json"); + if star_map_data_path.exists() { + tracing::info!("Star map data path resolved: {:?}", star_map_data_path); + } else { + tracing::warn!( + "star_map_data.json not found at {:?}. Star map requests will error until it exists.", + star_map_data_path + ); + } + app.insert_resource(settled_reach_server::atlas::atlas_data_proxy::StarMapDataPath( + star_map_data_path, + )); + // Settlement reader for Layer-3 placement (#955): reads a body's settlements // from systems.db on a cache miss so the cascade work item stays DB-free. match settled_reach_server::atlas::city_context_reader::CityContextReader::open( diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 079e3a565..2d53120a3 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -338,7 +338,8 @@ fn single_tick_drains_all_ready_inbound_frames() { use settled_reach_server::atlas::cascade::CascadeLayer; use settled_reach_server::atlas::layer_proxy::AtlasLayerRequest; use settled_reach_server::bridge::{ - receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, HandshakeState, ServerRunning, + receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, CityNamesRequestBuffer, + HandshakeState, ServerRunning, StarMapRequestBuffer, }; use settled_reach_server::simulation::input::InputQueue; @@ -379,6 +380,8 @@ fn single_tick_drains_all_ready_inbound_frames() { world.insert_resource(HandshakeState::Complete); world.init_resource::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world .run_system_once(receive_bridge_inputs) diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index bf8ef5869..bc88579d3 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -3,10 +3,14 @@ use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork}; use settled_reach_server::atlas::layer1::Layer1Output; -use settled_reach_server::atlas::layer_proxy::{AtlasLayerResponse, AtlasLayerStatus}; +use settled_reach_server::atlas::layer_proxy::{ + AtlasLayerResponse, AtlasLayerStatus, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, + SettlementEntry, SettlementLayer, SettlementSizeClass, +}; +use settled_reach_server::atlas::road_graph::RoadNodeKind; use settled_reach_server::bridge::types::*; use settled_reach_server::simulation::generator::{ - AttractorType, GeographicAttractor, SubBiomeVariant, + AttractorType, GeographicAttractor, MaintenanceAuthority, SubBiomeVariant, }; use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; @@ -568,11 +572,58 @@ fn generate_atlas_layer_response_fixtures() { grid_h: 256, district_basin_dirs: std::collections::BTreeMap::new(), }; + // T-960 §1/§2: a small populated RoadGraphLayer + SettlementLayer, one + // settlement (a capital) connected to one waypoint-free short edge. + let road_graph = RoadGraphLayer { + nodes: vec![ + RoadGraphNode { + position: (12, 58), + kind: RoadNodeKind::Settlement, + city_id: Some(1), + }, + RoadGraphNode { + position: (20, 70), + kind: RoadNodeKind::Settlement, + city_id: Some(2), + }, + ], + edges: vec![RoadGraphEdge { + from: 0, + to: 1, + path: vec![(12, 58), (16, 64), (20, 70)], + maintenance: MaintenanceAuthority::Administrative, + is_rail: false, + named_route_id: None, + }], + }; + let settlements = SettlementLayer { + settlements: vec![ + SettlementEntry { + city_id: 1, + name: "Port Aldren".into(), + position: (12, 58), + size_class: SettlementSizeClass::Major, + is_capital: true, + is_port: true, + }, + SettlementEntry { + city_id: 2, + name: "Farmstead Rell".into(), + position: (20, 70), + size_class: SettlementSizeClass::Minor, + is_capital: false, + is_port: false, + }, + ], + }; + let ready = AtlasLayerResponse { body_id: "GJ1c".into(), status: AtlasLayerStatus::Ready, layer1: Some(layer1), district_grid: None, + road_graph: Some(road_graph), + settlements: Some(settlements), }; write_fixture( "atlas_response_ready", @@ -584,6 +635,8 @@ fn generate_atlas_layer_response_fixtures() { status: AtlasLayerStatus::Pending, layer1: None, district_grid: None, + road_graph: None, + settlements: None, }; write_fixture( "atlas_response_pending", @@ -595,6 +648,8 @@ fn generate_atlas_layer_response_fixtures() { status: AtlasLayerStatus::NotFound, layer1: None, district_grid: None, + road_graph: None, + settlements: None, }; write_fixture( "atlas_response_not_found",