feat(simulation): atlas proxy layers + data-delivery endpoints (T-960, T-949)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:45:25 +02:00
co-authored by Claude Fable 5
parent 6bda9f1697
commit 37881acba0
15 changed files with 1654 additions and 23 deletions
+447
View File
@@ -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<PlayerInput>` 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<serde_json::Value>,
}
/// 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::<serde_json::Value>(&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<CityNameEntry>,
}
/// 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());
}
}
+73
View File
@@ -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![
+4 -1
View File
@@ -541,18 +541,21 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, 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}"))?
+3
View File
@@ -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();
+208 -2
View File
@@ -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<String>>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, Option<String>>(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<bool, CityContextReadError> {
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<String>>(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<Vec<CityNameRow>, 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"
);
}
}
+425 -5
View File
@@ -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<DistrictGridLayer>,
/// 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<RoadGraphLayer>,
/// 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<SettlementLayer>,
}
/// 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<u64>,
}
/// 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<String>,
}
/// 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<RoadGraphNode>,
pub edges: Vec<RoadGraphEdge>,
}
/// 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<RoadGraphLayer> {
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,000999,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<SettlementEntry>,
}
/// `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<SettlementLayer> {
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(),
+1
View File
@@ -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;
+152 -2
View File
@@ -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<StarMapRequestBuffer>,
mut responses: ResMut<StarMapResponseBuffer>,
path: Option<Res<StarMapDataPath>>,
) {
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<CityNamesRequestBuffer>,
mut responses: ResMut<CityNamesResponseBuffer>,
city_reader: Option<Res<CityContextReaderResource>>,
) {
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::<AtlasRequestBuffer>().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::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].status, StarMapStatus::Ready);
assert!(world.resource::<StarMapRequestBuffer>().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::<StarMapResponseBuffer>();
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::<CityNamesResponseBuffer>();
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::<CityNamesRequestBuffer>().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 {
+3
View File
@@ -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,
}
}
+21
View File
@@ -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 {
+211 -10
View File
@@ -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<PlayerInput>` 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<PlayerInput>` 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<PlayerInput>),
/// 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<PlayerInput>` (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<PlayerInput>` (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<Inbound, BridgeError> {
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
return Ok(Inbound::Inputs(inputs));
}
match rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
Ok(req) => Ok(Inbound::AtlasRequest(req)),
if let Ok(req) = rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
return Ok(Inbound::AtlasRequest(req));
}
if let Ok(req) = rmp_serde::from_slice::<StarMapRequest>(payload) {
return Ok(Inbound::StarMapRequest(req));
}
match rmp_serde::from_slice::<CityNamesRequest>(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<HandshakeState>,
mut error_buffer: ResMut<SimErrorBuffer>,
mut atlas_requests: ResMut<AtlasRequestBuffer>,
mut star_map_requests: ResMut<StarMapRequestBuffer>,
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
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<StarMapRequest>);
/// 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<StarMapResponse>);
/// 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<Res<BridgeResource>>,
mut buffer: ResMut<StarMapResponseBuffer>,
) {
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<CityNamesRequest>);
/// 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<CityNamesResponse>);
/// 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<Res<BridgeResource>>,
mut buffer: ResMut<CityNamesResponseBuffer>,
) {
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::<SnapshotBuffer>()
@@ -325,10 +434,19 @@ impl Plugin for BridgePlugin {
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.init_resource::<AtlasRequestBuffer>()
.init_resource::<AtlasResponseBuffer>()
.init_resource::<StarMapRequestBuffer>()
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
// 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::<PlayerInput>::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::<CityNamesRequest>(&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::<AtlasLayerRequest>(&city_names_frame).is_err());
}
}
+29
View File
@@ -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(())
}
}
+16
View File
@@ -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(
+4 -1
View File
@@ -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::<SimErrorBuffer>();
world.init_resource::<AtlasRequestBuffer>();
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
+57 -2
View File
@@ -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",