feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)

One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).

T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:10:27 +02:00
co-authored by Claude Fable 5
parent befdb689c1
commit 8da9670e0f
28 changed files with 1244 additions and 467 deletions
+255 -2
View File
@@ -215,6 +215,120 @@ pub fn handle_city_names_request(
}
}
// ---------------------------------------------------------------------------
// Feature names proxy (T-1169)
// ---------------------------------------------------------------------------
/// A client request for a body's reserved geographic feature names (T-1169) —
/// mirrors [`CityNamesRequest`] exactly (D-236 pattern), its own message type
/// per the same Inbound-demux discriminator discipline the module doc
/// describes: outside the [`crate::atlas::layer_proxy::AtlasLayerResponse`]
/// ceilings, since a name pool is unrelated to the dense per-cell wire
/// arrays those ceilings budget for.
///
/// `feature_names` is the Inbound discriminator (see module doc): always `true`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureNamesRequest {
pub feature_names: bool,
pub body_id: String,
}
/// Status of a [`FeatureNamesResponse`] — mirrors [`CityNamesStatus`] exactly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeatureNamesStatus {
/// Names are ready (`features` is populated; legitimately empty for a
/// body with no reserved feature names).
Ready,
/// D-236: `body_id` is a Sol body. Sol is permanently out of the
/// generation cascade and every DB-derived Atlas path — the client must
/// keep its legacy authored `markers.json` read for Sol. `features` is empty.
SolExcluded,
/// DB/IO failure reading `atlas_feature_names` (message for the client log).
Error(String),
}
/// One reserved feature-name entry (T-1169) — see
/// [`crate::atlas::city_context_reader::FeatureNameRow`] for the reader-side
/// row this is built from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureNameEntry {
pub feature_id: u64,
pub name: String,
/// `"river"` | `"mountain"` (T-1169 scope — see
/// [`crate::atlas::city_context_reader::FeatureNameRow::feature_type`]).
pub feature_type: String,
}
/// A feature-names response: the body's reserved geographic feature names, or
/// a non-ready status (T-1169). Mirrors [`CityNamesResponse`] exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureNamesResponse {
pub body_id: String,
pub status: FeatureNamesStatus,
pub features: Vec<FeatureNameEntry>,
}
/// Serve one feature-names request (T-1169): D-236 Sol check first (same
/// reader method [`handle_city_names_request`] uses), then the names-only
/// `atlas_feature_names` read. `city_reader` absent (no DB opened at startup)
/// is reported as `Error`, matching `handle_city_names_request`'s convention.
///
/// This is a POOL read, not a position-assignment lookup — it does not read
/// `layer1::attach_feature_names`'s cascade output (which attaches these
/// names to computed river-mouth/alpine-peak positions at generation time,
/// not at DB-read time). The client pairs this name list with position data
/// it already has from the cascade layer response.
pub fn handle_feature_names_request(
req: &FeatureNamesRequest,
city_reader: Option<&CityContextReader>,
) -> FeatureNamesResponse {
let Some(reader) = city_reader else {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error("city context reader unavailable".to_string()),
features: Vec::new(),
};
};
match reader.is_sol_body(&req.body_id) {
Ok(true) => {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::SolExcluded,
features: Vec::new(),
};
}
Ok(false) => {}
Err(e) => {
return FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error(e.to_string()),
features: Vec::new(),
};
}
}
match reader.read_body_feature_names(&req.body_id) {
Ok(rows) => FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Ready,
features: rows
.into_iter()
.map(|r| FeatureNameEntry {
feature_id: r.feature_id,
name: r.name,
feature_type: r.feature_type,
})
.collect(),
},
Err(e) => FeatureNamesResponse {
body_id: req.body_id.clone(),
status: FeatureNamesStatus::Error(e.to_string()),
features: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -312,8 +426,9 @@ mod tests {
assert!(decoded.cities[0].is_capital);
}
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` need:
/// `bodies`, `system_history`, `atlas_city_names`.
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` +
/// `read_body_feature_names` need: `bodies`, `system_history`,
/// `atlas_city_names`, `atlas_feature_names`.
fn make_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_adp_{}_{n}.db", std::process::id()));
@@ -330,6 +445,12 @@ mod tests {
body_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'city'
);
CREATE TABLE atlas_feature_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
feature_type TEXT NOT NULL
);",
)
.expect("create tables");
@@ -358,6 +479,15 @@ mod tests {
.expect("insert city");
}
fn insert_feature(db: &Path, body_id: &str, name: &str, feature_type: &str) {
let conn = Connection::open(db).expect("reopen");
conn.execute(
"INSERT INTO atlas_feature_names (body_id, name, feature_type) VALUES (?1, ?2, ?3)",
rusqlite::params![body_id, name, feature_type],
)
.expect("insert feature");
}
#[test]
fn handle_city_names_request_no_reader_is_error() {
let resp = handle_city_names_request(
@@ -448,4 +578,127 @@ mod tests {
assert_eq!(resp.status, CityNamesStatus::Ready);
assert!(resp.cities.is_empty());
}
// ─── FeatureNamesRequest/Response (T-1169) ───────────────────────────────
#[test]
fn feature_names_request_round_trips_msgpack() {
let req = FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
};
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: FeatureNamesRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert!(decoded.feature_names);
assert_eq!(decoded.body_id, "GJ1c");
}
#[test]
fn feature_names_response_round_trips_msgpack() {
let resp = FeatureNamesResponse {
body_id: "GJ1c".into(),
status: FeatureNamesStatus::Ready,
features: vec![FeatureNameEntry {
feature_id: 1,
name: "Serra Verde".into(),
feature_type: "mountain".into(),
}],
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: FeatureNamesResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.status, FeatureNamesStatus::Ready);
assert_eq!(decoded.features[0].name, "Serra Verde");
assert_eq!(decoded.features[0].feature_type, "mountain");
}
#[test]
fn handle_feature_names_request_no_reader_is_error() {
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
},
None,
);
assert!(matches!(resp.status, FeatureNamesStatus::Error(_)));
assert!(resp.features.is_empty());
}
#[test]
fn handle_feature_names_request_sol_body_is_excluded() {
// D-236: system_id = GJ-0 → SolExcluded, no DB row read for features.
let db = make_db("Earth", "GJ-0", None);
insert_feature(&db, "Earth", "Thames", "river");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "Earth".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::SolExcluded);
assert!(
resp.features.is_empty(),
"Sol-excluded response must carry no features even though the row exists"
);
}
#[test]
fn handle_feature_names_request_sol_body_via_settlement_wave_is_excluded() {
// D-236's second signal: settlement_wave = 'origin' excludes even a
// non-GJ-0 system_id.
let db = make_db("Weirdbody", "GJ-999", Some("origin"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "Weirdbody".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::SolExcluded);
}
#[test]
fn handle_feature_names_request_ordinary_body_returns_names() {
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
insert_feature(&db, "GJ1c", "Wiesenbach", "mountain");
insert_feature(&db, "GJ1c", "Kaltfluss", "river");
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::Ready);
assert_eq!(resp.features.len(), 2);
assert_eq!(resp.features[0].name, "Wiesenbach");
assert_eq!(resp.features[0].feature_type, "mountain");
assert_eq!(resp.features[1].name, "Kaltfluss");
assert_eq!(resp.features[1].feature_type, "river");
}
#[test]
fn handle_feature_names_request_unknown_body_is_ready_with_empty_list() {
// Matches handle_city_names_request's existing convention: unknown
// body → empty list under Ready, not a distinct NotFound status.
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
let reader = CityContextReader::open(&db).expect("open");
let resp = handle_feature_names_request(
&FeatureNamesRequest {
feature_names: true,
body_id: "ghost".into(),
},
Some(&reader),
);
assert_eq!(resp.status, FeatureNamesStatus::Ready);
assert!(resp.features.is_empty());
}
}
+41
View File
@@ -582,6 +582,7 @@ pub fn cascade_snapshot_for_body(
.read_body_params(body_id)
.map_err(|e| format!("read body params: {e:?}"))?;
let cities = read_cities(&db, body_id)?;
let (river_names, mountain_names) = read_feature_names(&db, body_id)?;
let hm = load_heightmap_png(&hm_path, body_id, DEFAULT_SEA_LEVEL)
.map_err(|e| format!("load heightmap: {e:?}"))?;
@@ -597,6 +598,8 @@ pub fn cascade_snapshot_for_body(
&cities,
None,
Some(&params),
&river_names,
&mountain_names,
CascadeLayer::Region,
);
Ok((snapshot, params))
@@ -658,6 +661,44 @@ fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
Ok(rows)
}
/// Read a body's reserved river/mountain name pools from `atlas_feature_names`
/// (T-1169, D-223), mirroring `read_cities`' own lightweight raw-SQL
/// discipline (this module reads `systems.db` directly rather than going
/// through `CityContextReader` — keep the two independent, matching the
/// existing `read_cities` precedent). Returns `(river_names, mountain_names)`.
fn read_feature_names(db: &PathBuf, body_id: &str) -> Result<(Vec<String>, Vec<String>), String> {
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT name, feature_type FROM atlas_feature_names
WHERE body_id = ?1 ORDER BY id",
)
.map_err(|e| format!("prepare feature name query: {e}"))?;
let rows = stmt
.query_map([body_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.map_err(|e| format!("feature name query: {e}"))?;
let mut river_names = Vec::new();
let mut mountain_names = Vec::new();
for row in rows {
let (name, feature_type) = match row {
Ok(r) => r,
Err(e) => {
eprintln!("[believability] skipped a malformed feature name row for {body_id}: {e}");
continue;
}
};
match feature_type.as_str() {
"river" => river_names.push(name),
"mountain" => mountain_names.push(name),
_ => {}
}
}
Ok((river_names, mountain_names))
}
/// Glob `*/bodies/<body>/heightmap.png` under the committed wiki tree (either CWD).
fn find_heightmap(body_id: &str) -> Option<PathBuf> {
for base in ["wiki/star-systems", "../wiki/star-systems"] {
+8
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::district_profile::DistrictProfile;
use crate::atlas::layer1::FeatureNameAssignment;
use crate::atlas::region_profile::RegionProfile;
use crate::atlas::road_graph::RoadGraph;
use crate::atlas::scale::{RegionPos, SurveyCellPos};
@@ -182,6 +183,12 @@ pub struct BodyWorldState {
pub drainage_basins: Vec<DrainageBasin>,
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
pub attractors: Vec<GeographicAttractor>,
/// Named-feature position assignments (T-1169, D-223) — river-mouth and
/// alpine-peak attractors paired with reserved pool names, via
/// `layer1::attach_feature_names`. Empty until the Topography task
/// completes (mirrors `attractors`' own "empty until" convention), or if
/// the body has no reserved names in `atlas_feature_names`.
pub feature_names: Vec<FeatureNameAssignment>,
/// Settlement placements (D-211, #955). Attractor-matched city positions.
/// Empty until the Layer-3 placement task completes.
pub placements: Vec<CityPlacement>,
@@ -332,6 +339,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: RoadGraph::default(),
quarters: BTreeMap::new(),
+145 -3
View File
@@ -147,9 +147,14 @@ impl CascadeSnapshot {
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors) = match self.layer1 {
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
let (river_network, drainage_basins, attractors, feature_names) = match self.layer1 {
Some(l1) => (
l1.river_network,
l1.drainage_basins,
l1.attractors,
l1.feature_names,
),
None => (RiverNetwork::default(), Vec::new(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let districts = self
@@ -170,6 +175,7 @@ impl CascadeSnapshot {
river_network,
drainage_basins,
attractors,
feature_names,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
@@ -224,12 +230,20 @@ fn run_layer3(
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
/// `river_names`/`mountain_names` are the body's reserved-name pools (T-1169,
/// D-223, from `atlas_feature_names`), supplied by the caller — mirrors
/// `cities`' own pre-resolved, DB-free-cascade pattern. Empty slices are the
/// correct input for a body with no reserved names, or a caller (tests,
/// `aliveness_probe`) that hasn't pre-resolved them; `attach_feature_names`
/// degrades gracefully (every attractor position simply gets no name).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
@@ -272,6 +286,31 @@ pub fn run_cascade_from_heightmap(
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
// T-1169: attach reserved names (D-223) to the strongest river-mouth
// and alpine-peak attractors. Cheap (two sorts + zips over the
// already-computed attractor list, no new terrain work) and
// deterministic given the caller-supplied pools — mirrors the
// TerritorialStatus stamp above in running once, right after Layer 1
// produces the attractors this reads.
let (river_assignments, mountain_assignments) =
layer1::attach_feature_names(&l1, river_names, mountain_names);
l1.feature_names = river_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::River,
})
.chain(
mountain_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::Mountain,
}),
)
.collect();
snapshot.layer1 = Some(l1);
snapshot.terrain_analysis = Some(ta);
}
@@ -456,6 +495,8 @@ pub fn run_cascade(
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> Result<CascadeSnapshot, HeightmapLoadError> {
// Layer 0 — the cascade's input; always loaded.
@@ -466,6 +507,8 @@ pub fn run_cascade(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
up_to,
))
}
@@ -508,6 +551,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
@@ -525,12 +570,91 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
assert_eq!(l1.body_id, "test_body");
}
/// T-1169: `run_cascade_from_heightmap` attaches reserved names to the
/// strongest river-mouth/alpine attractors when the caller supplies name
/// pools — proves the cascade wiring (`attach_feature_names` call site
/// inside the Topography block), not just the function in isolation
/// (`layer1::tests` already covers `attach_feature_names` itself).
#[test]
fn topography_layer_attaches_feature_names_when_pools_supplied() {
let river_names = vec!["Kaltfluss".to_string(), "Silberbach".to_string()];
let mountain_names = vec!["Wiesenbach".to_string()];
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&river_names,
&mountain_names,
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
// The test heightmap's `0.6r + 0.4c` ramp crosses sea_level=0.3,
// producing real river-mouth/coastal attractors — assert against
// WHATEVER attach_feature_names actually paired, not a hardcoded
// count (the exact attractor set is an implementation detail of
// feature extraction, not this test's concern).
let river_attractor_count = l1
.attractors
.iter()
.filter(|a| {
a.attractor_type == crate::simulation::generator::AttractorType::RiverMouth
})
.count();
let expected_river_assignments = river_attractor_count.min(river_names.len());
let actual_river_assignments = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.count();
assert_eq!(
actual_river_assignments, expected_river_assignments,
"every river mouth (up to pool size) must get a name"
);
if expected_river_assignments > 0 {
let names: std::collections::BTreeSet<&str> = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.map(|f| f.name.as_str())
.collect();
assert!(
names.iter().all(|n| river_names.contains(&n.to_string())),
"assigned names must come from the supplied pool"
);
}
// No pools supplied -> no assignments (the pre-wiring default).
let snap_no_pools = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
&[],
&[],
CascadeLayer::Topography,
);
assert!(
snap_no_pools
.layer1
.expect("Layer 1 should have run")
.feature_names
.is_empty(),
"empty pools must yield zero assignments"
);
}
#[test]
fn cascade_is_deterministic() {
let extract = |s: CascadeSnapshot| {
@@ -546,6 +670,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
@@ -554,6 +680,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
assert_eq!(
@@ -586,6 +714,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
@@ -609,6 +739,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::DistrictProfile,
)
};
@@ -668,6 +800,8 @@ mod tests {
&cities,
Some("concord_assembly"),
None, // body_params
&[],
&[],
CascadeLayer::Settlement,
)
};
@@ -756,6 +890,8 @@ mod tests {
&cities,
Some("independent"),
None, // body_params — road graph needs none
&[],
&[],
CascadeLayer::RoadGraph,
)
};
@@ -860,6 +996,8 @@ mod tests {
&cities,
Some("independent"),
None,
&[],
&[],
CascadeLayer::RoadGraph,
);
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
@@ -927,6 +1065,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::Region,
)
};
@@ -980,6 +1120,8 @@ mod tests {
&[],
None,
None,
&[],
&[],
CascadeLayer::Region,
);
assert!(no_params.layer_region.is_none());
+121
View File
@@ -467,6 +467,58 @@ impl CityContextReader {
}
Ok(out)
}
/// Read every reserved geographic feature **name** on `body_id` from
/// `atlas_feature_names` (T-1169 — mirrors [`read_body_city_names`]
/// exactly, D-236 pattern, over `atlas_feature_names` instead of
/// `atlas_city_names`). Returns the id/name/feature_type triple — this is
/// the raw reserved-name POOL, not a position assignment (positions come
/// from `layer1::attach_feature_names` at cascade generation time, not
/// from this reader). Ordered by `id`. An unknown body yields an empty
/// list (matches `read_body_city_names`'s convention); only a DB/mutex
/// error fails. **No Sol check here** — unlike city names, this is a raw
/// pool read with no per-body caller-facing status enum; the proxy
/// handler (`atlas_data_proxy::handle_feature_names_request`) applies the
/// same D-236 Sol exclusion `handle_city_names_request` does, BEFORE
/// calling this method, so Sol bodies never reach this query in practice.
pub fn read_body_feature_names(
&self,
body_id: &str,
) -> Result<Vec<FeatureNameRow>, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT id, name, feature_type
FROM atlas_feature_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, feature_type) =
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
out.push(FeatureNameRow {
feature_id: id as u64,
name,
feature_type,
});
}
Ok(out)
}
}
/// One row of the T-949 names-only read (see
@@ -478,6 +530,20 @@ pub struct CityNameRow {
pub is_capital: bool,
}
/// One row of the T-1169 names-only read (see
/// [`CityContextReader::read_body_feature_names`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureNameRow {
pub feature_id: u64,
pub name: String,
/// `atlas_feature_names.feature_type` — `"river"` | `"mountain"` (the two
/// pools `import_economics`/`atlas.py::populate_atlas_feature_names`
/// currently populates, T-1169 scope). Carried as a raw string, not an
/// enum — mirrors `CityNameRow.is_capital`'s discipline of staying a thin
/// passthrough of the DB row, no server-side vocabulary gate here.
pub feature_type: String,
}
// ---------------------------------------------------------------------------
// prosperity_baseline_bps derivation (D-197, partial — integer basis points)
// ---------------------------------------------------------------------------
@@ -1355,6 +1421,61 @@ mod tests {
);
}
// ─── read_body_feature_names (T-1169) ────────────────────────────────────
/// Minimal db for `read_body_feature_names`: just `atlas_feature_names`.
fn make_features_db(rows: &[(&str, &str)]) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_ctxfeat_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE atlas_feature_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
feature_type TEXT NOT NULL
);",
)
.expect("create table");
for (name, feature_type) in rows {
conn.execute(
"INSERT INTO atlas_feature_names (body_id, name, feature_type)
VALUES ('PlanetX', ?1, ?2)",
rusqlite::params![name, feature_type],
)
.expect("insert feature");
}
drop(conn);
path
}
#[test]
fn read_body_feature_names_returns_id_name_type() {
let db = make_features_db(&[("Wiesenbach", "mountain"), ("Kaltfluss", "river")]);
let reader = CityContextReader::open(&db).expect("open");
let names = reader.read_body_feature_names("PlanetX").expect("read");
assert_eq!(names.len(), 2);
// Ordered by id == insertion order.
assert_eq!(names[0].name, "Wiesenbach");
assert_eq!(names[0].feature_type, "mountain");
assert_eq!(names[1].name, "Kaltfluss");
assert_eq!(names[1].feature_type, "river");
}
#[test]
fn read_body_feature_names_unknown_body_is_empty() {
let db = make_features_db(&[("Solo Peak", "mountain")]);
let reader = CityContextReader::open(&db).expect("open");
assert!(
reader
.read_body_feature_names("Ghost")
.expect("read")
.is_empty(),
"unknown body yields no names, matching read_body_city_names' convention"
);
}
// ─── is_sol_body (T-949, D-236) ──────────────────────────────────────────
/// Minimal db for `is_sol_body`: one `bodies` row + an optional
+15
View File
@@ -91,6 +91,15 @@ pub enum GenWorkItem {
/// Boxed: `BodyParams` is large relative to other variants (clippy
/// large_enum_variant) — boxing keeps `GenWorkItem` compact.
body_params: Option<Box<BodyParams>>,
/// The body's reserved river-name pool (`atlas_feature_names`,
/// `feature_type = 'river'`), pre-resolved at dispatch time (T-1169,
/// D-223) — mirrors `cities`' own DB-free-cascade pattern. Empty if
/// the body has no reserved river names.
river_names: Vec<String>,
/// The body's reserved mountain-name pool (`atlas_feature_names`,
/// `feature_type = 'mountain'`), pre-resolved at dispatch time
/// (T-1169, D-223). Empty if the body has no reserved mountain names.
mountain_names: Vec<String>,
},
/// Generate a Phase 1 QuarterSkeleton for this city.
///
@@ -974,6 +983,8 @@ fn run_work_item(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
Ok(hm) => {
// Layer 1 runs at the GRID_W×GRID_H working resolution (D-202):
@@ -997,6 +1008,8 @@ fn run_work_item(
cities,
dominant_faction.as_deref(),
body_params.as_deref(),
river_names,
mountain_names,
up_to,
);
GenCompletion::BodyAnalyzed {
@@ -1296,6 +1309,8 @@ mod tests {
cities: vec![],
dominant_faction: None,
body_params: None, // T-1023: no body params in queue-mechanic unit tests
river_names: vec![],
mountain_names: vec![],
}
}
+34
View File
@@ -71,6 +71,35 @@ pub struct Layer1Output {
/// after the cascade consumes it.
#[serde(skip)]
pub survey_basin_dirs: BTreeMap<SurveyCellPos, BasinDirection>,
/// Named-feature position assignments (T-1169, D-223): river-mouth and
/// alpine-peak attractors paired with a reserved name from the
/// `atlas_feature_names` pool, via [`attach_feature_names`]. Empty when
/// the caller supplied no name pools (e.g. `run_layer1`'s two-arg
/// convenience form, or a body with no reserved names) — never populated
/// automatically inside `run_layer1`/`run_layer1_with_moisture`
/// themselves, since those have no DB access (D-225 DB-free-worker
/// discipline); the cascade caller pre-resolves the pools and calls
/// [`attach_feature_names`] itself (`cascade::run_cascade_from_heightmap`).
#[serde(default)]
pub feature_names: Vec<FeatureNameAssignment>,
}
/// One reserved-name-to-position pairing (T-1169) — see [`attach_feature_names`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureNameAssignment {
pub position: (u16, u16),
pub name: String,
pub feature_type: FeatureNameType,
}
/// The two pools [`attach_feature_names`] currently draws from (T-1169
/// scope — mirrors `atlas_feature_names.feature_type`'s `"river"` |
/// `"mountain"` values, but typed rather than a raw string on this
/// server-internal cascade carrier).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FeatureNameType {
River,
Mountain,
}
/// Body-wide moisture ceiling fallback for [`run_layer1`]'s hydrology solve
@@ -216,6 +245,11 @@ pub fn run_layer1_with_moisture(
grid_w: hm.width,
grid_h: hm.height,
survey_basin_dirs,
// T-1169: run_layer1/run_layer1_with_moisture have no DB access
// (D-225 DB-free-worker discipline) — feature-name attachment
// happens one level up, in the cascade caller that pre-resolved the
// name pools (see attach_feature_names's own doc).
feature_names: Vec::new(),
};
(l1, ta)
}
+115 -223
View File
@@ -83,46 +83,22 @@ pub const DISTRICT_WINDOW_MAX_N_REGION: u32 =
const WIRE_CAP_CELLS_SQRT: u32 = 64;
const _: () = assert!(WIRE_CAP_CELLS_SQRT * WIRE_CAP_CELLS_SQRT == WIRE_CAP_CELLS);
/// [`AtlasLayerRequest::window_granularity`] encoding (T-1150, zoom ladder
/// design doc §3/§5): the number of derived cells per district side. `0` on
/// the wire (the `#[serde(default)]` absent case) and `1` both mean district
/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior,
/// byte-compatible with every pre-T-1150 caller). `4` means quarter spacing
/// (512 m/cell, D-243) — Option B from the design doc: full reclassification
/// at the finer spacing via `derive_at_metres`, not a coarser-cell
/// interpolation. No other values are legal; `resolve_window_granularity`
/// clamps unrecognized values down to district (never trust the wire, same
/// discipline as `window_n`).
/// Finer-than-district spacing multipliers (T-1150, zoom ladder design doc
/// §3/§5): the number of derived cells per district side. `1` = district
/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior).
/// `4` = quarter spacing (512 m/cell, D-243) — Option B from the design doc:
/// full reclassification at the finer spacing via `derive_at_metres`, not a
/// coarser-cell interpolation.
///
/// **T-1159:** these used to also be legal VALUES of the wire-facing
/// `AtlasLayerRequest::window_granularity: u32` field (resolved via the
/// now-removed `resolve_window_granularity`) — that field is retired, fully
/// shadowed by [`WindowGranularity`] since T-1152. These constants remain as
/// internal spacing-multiplier values (see [`WindowGranularity::spacing_multiplier`],
/// [`clamp_window_n`]).
pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1;
pub const WINDOW_GRANULARITY_QUARTER: u32 = 4;
/// The `granularity: u32` value [`DistrictWindowLayer`]'s echo (and the
/// internal cache/coalescing keys) use for [`WindowGranularity::Region`] —
/// **a key-space value, never a legal WIRE INPUT.** [`resolve_window_granularity`]
/// (the legacy field's resolver) never produces this value, and a client
/// sending it on the wire in the legacy `window_granularity` field is
/// indistinguishable from any other unrecognized value — it still resolves
/// to `District` (§ `resolve_window_granularity`'s exhaustive fallback), NOT
/// `Region`. The only way to actually request `Region` is
/// `window_granularity_v2 = Some(WindowGranularity::Region)`.
///
/// **Why this exists at all, given `Region` has no legacy representation:**
/// the aliasing discipline (T-1150 design doc §3, the mandatory
/// granularity-4-vs-1 test) requires that every distinct [`WindowGranularity`]
/// occupy a distinct slot in [`DistrictWindowKey`]/the coalescing key, both of
/// which carry a `u32` granularity component for wire back-compat. Reusing
/// `0` (today's "absent" sentinel, mapped to `District`) or any value
/// `resolve_window_granularity` could legally receive would silently alias a
/// `Region` window onto a `District` or `Quarter` cache slot depending on
/// what a future caller happened to pass — exactly the bug class §3 exists
/// to close. `u32::MAX` can never collide with a real multiplier (multipliers
/// are small integers by construction — 1, 4, and any future finer rung),
/// so it's the natural "this is a key-space tag, not a spacing multiplier"
/// value. A T-1152-aware client reads `granularity_v2` and never looks at
/// this number for `Region` responses; it exists purely so the legacy `u32`
/// slot in the key tuple stays total and never lies about aliasing.
pub const WINDOW_GRANULARITY_REGION_KEY: u32 = u32::MAX;
/// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"):
/// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`,
/// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly
@@ -161,20 +137,19 @@ pub const WIRE_CAP_CELLS: u32 = 4_096;
/// reasoning `MorphologyZone`'s exhaustive-match discipline already
/// established for this codebase (D-239 §6).
///
/// **Precedence over the legacy `u32` field (documented here, the single
/// place both fields are reconciled):** [`AtlasLayerRequest::window_granularity_v2`]
/// wins whenever present and non-`None`; the legacy `u32`
/// [`AtlasLayerRequest::window_granularity`] is consulted ONLY when
/// `window_granularity_v2` is absent (`#[serde(default)]`, every pre-T-1152
/// client). This is a strict either/or, not a merge — a client sending BOTH
/// fields (a mixed old/new build, or a future client hedging compatibility)
/// gets the `v2` field's answer, silently ignoring the legacy `u32`. See
/// [`resolve_window_granularity_v2`], the single widening point for this
/// enum (mirroring `resolve_window_granularity`'s role for the legacy field).
/// **Resolution (T-1152, simplified T-1159):** [`AtlasLayerRequest::window_granularity_v2`]
/// resolves directly via [`resolve_window_granularity_v2`], the single
/// widening point for this enum. Absent (`#[serde(default)]`, `None`)
/// resolves to `District`.
///
/// **T-1159:** this used to also reconcile against a legacy
/// `AtlasLayerRequest::window_granularity: u32` field (whenever THIS field
/// was absent) — that field is retired, fully shadowed since T-1152 and
/// never sent as anything but its byte-compatible default by any caller in
/// this codebase (no external client exists, single-repo client/server pair).
///
/// **Unknown → District** at every resolution boundary (never trust the
/// wire) — same posture as the legacy `u32` path and every other wire-decoded
/// enum in this module.
/// wire) — same posture as every other wire-decoded enum in this module.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum WindowGranularity {
/// 512 m/cell (D-243 `QUARTER_M`) — finer than district, T-1150 Option B.
@@ -207,14 +182,18 @@ impl WindowGranularity {
}
}
/// The legacy `window_granularity: u32` value this variant maps to/from
/// for finer-than-district rungs — `None` for `Region`, which the legacy
/// field cannot express by construction (Tyre's note; this is precisely
/// the gap the enum exists to close). Used only by
/// [`resolve_window_granularity_v2`]'s legacy-fallback branch and by
/// [`DistrictWindowLayer`]'s echo, which still carries the legacy `u32`
/// unchanged for wire back-compat (see that struct's doc).
fn legacy_u32(self) -> Option<u32> {
/// The finer-than-district spacing multiplier ([`WINDOW_GRANULARITY_DISTRICT`]
/// / [`WINDOW_GRANULARITY_QUARTER`]) this variant corresponds to — `None`
/// for `Region`, which has no such multiplier (its spacing is coarser,
/// not a finer subdivision of a district). Used only by
/// [`clamp_window_n_v2`]'s District/Quarter branch to reuse
/// [`clamp_window_n`]'s per-axis-cap math rather than re-deriving it.
///
/// **T-1159:** this used to double as the wire-back-compat value for the
/// now-retired `window_granularity: u32` echo (`DistrictWindowLayer.granularity`)
/// — that role, and the `key_u32()` method that served it, are gone; this
/// is purely an internal spacing-multiplier lookup now.
fn spacing_multiplier(self) -> Option<u32> {
match self {
WindowGranularity::District => Some(WINDOW_GRANULARITY_DISTRICT),
WindowGranularity::Quarter => Some(WINDOW_GRANULARITY_QUARTER),
@@ -222,19 +201,6 @@ impl WindowGranularity {
}
}
/// The `u32` this variant occupies in [`DistrictWindowKey`]/the
/// coalescing key/`DistrictWindowLayer.granularity`'s echo — total over
/// every variant (unlike [`Self::legacy_u32`], which is partial). Finer-
/// than-district variants echo their real legacy multiplier (so a
/// T-1150-only client — one that reads `granularity` but has never heard
/// of `granularity_v2` — still sees the correct, meaningful number for
/// District/Quarter); `Region` echoes the reserved
/// [`WINDOW_GRANULARITY_REGION_KEY`] key-space tag (see that const's doc
/// for why `0` or any real multiplier would be unsafe here).
fn key_u32(self) -> u32 {
self.legacy_u32().unwrap_or(WINDOW_GRANULARITY_REGION_KEY)
}
/// The derived cell-grid side length (in CELLS, at this granularity) for
/// a window whose extent is `n` DISTRICTS (T-1152 step 4: "work out what
/// n means at region granularity against D-243's region=100-district
@@ -276,55 +242,22 @@ impl WindowGranularity {
}
}
/// Resolve a wire-supplied `window_granularity` value to one of the two legal
/// granularities, clamping anything else down to district spacing — **never
/// trust the wire** (same posture as `window_n`/`normalize_window_center`).
///
/// **This is THE single widening point (Tyre C2, PR #191 review) for the
/// LEGACY `u32` field only.** The field only ever expresses finer-than-district
/// integer multiples (see [`AtlasLayerRequest::window_granularity`]'s doc for
/// the full type-seam contract); adding a future finer rung means adding its
/// legal value here and nowhere else. Do NOT add a value < 1 or attempt to
/// encode coarser-than-district rungs (region/orbital) through this function
/// — that direction is [`WindowGranularity::Region`]'s job via
/// [`resolve_window_granularity_v2`], never a new magic `u32` value (the R5
/// redesign this enum performs is EXACTLY the alternative to doing that).
fn resolve_window_granularity(raw: u32) -> u32 {
if raw == WINDOW_GRANULARITY_QUARTER {
WINDOW_GRANULARITY_QUARTER
} else {
WINDOW_GRANULARITY_DISTRICT
}
}
/// Resolve a request's granularity to a [`WindowGranularity`] — **the single
/// widening point for the full (finer- and coarser-than-district) vocabulary**
/// (T-1152, mirroring [`resolve_window_granularity`]'s role for the legacy
/// `u32` alone). Precedence (documented once, here — see
/// [`WindowGranularity`]'s struct doc for the rationale):
/// (T-1152). Absent (`None`, `#[serde(default)]`) resolves to
/// [`WindowGranularity::District`] — unknown/malformed variants can't reach
/// this function at all (`rmp_serde` rejects an unrecognized enum variant
/// name at decode time, so "unknown" for this field means "absent", never
/// "present but garbage").
///
/// 1. `window_granularity_v2` present → resolved directly (unknown/malformed
/// variants can't reach this function at all — `rmp_serde` rejects an
/// unrecognized enum variant name at decode time, so "unknown" for THIS
/// field means "absent", not "present but garbage"; the legacy `u32`
/// path is what actually needs the value-level fallback because a `u32`
/// has no closed vocabulary).
/// 2. `window_granularity_v2` absent → fall back to the legacy `u32` path via
/// [`resolve_window_granularity`], mapped onto the two variants it can
/// express.
///
/// A future coarser-than-region rung is added by widening this function's
/// match AND [`WindowGranularity`]'s variant list together — never by
/// smuggling a new value through the legacy `u32` (that field's ceiling is
/// permanent per Tyre's note, not a restriction this function works around).
/// **T-1159:** this function used to fall back to the legacy `window_granularity: u32`
/// field (via the now-removed `resolve_window_granularity`) when
/// `window_granularity_v2` was absent — that fallback is retired along with
/// the field itself (fully shadowed since T-1152, no pre-T-1152 client
/// exists). A future coarser-than-region rung is added by widening this
/// function's match AND [`WindowGranularity`]'s variant list together.
fn resolve_window_granularity_v2(req: &AtlasLayerRequest) -> WindowGranularity {
match req.window_granularity_v2 {
Some(g) => g,
None => match resolve_window_granularity(req.window_granularity) {
WINDOW_GRANULARITY_QUARTER => WindowGranularity::Quarter,
_ => WindowGranularity::District,
},
}
req.window_granularity_v2.unwrap_or(WindowGranularity::District)
}
/// Clamp `window_n` against BOTH the existing per-axis cap
@@ -392,7 +325,7 @@ fn clamp_window_n_v2(raw_n: u32, granularity: WindowGranularity) -> u32 {
WindowGranularity::District | WindowGranularity::Quarter => clamp_window_n(
raw_n,
granularity
.legacy_u32()
.spacing_multiplier()
.unwrap_or(WINDOW_GRANULARITY_DISTRICT),
),
WindowGranularity::Region => {
@@ -520,36 +453,19 @@ pub struct AtlasLayerRequest {
/// from the wire** (D-226 T-1124 amendment §4).
#[serde(default)]
pub window_n: u32,
/// Window derivation granularity (T-1150, zoom ladder design doc §3/§5).
/// `0` (absent, `#[serde(default)]`) or `1` = district spacing (2,048 m,
/// today's behavior, byte-compatible with every pre-T-1150 caller); `4` =
/// quarter spacing (512 m, D-243). See [`WINDOW_GRANULARITY_DISTRICT`] /
/// [`WINDOW_GRANULARITY_QUARTER`]. Resolved via
/// [`resolve_window_granularity`] — **never trusted from the wire**,
/// unrecognized values fall back to district.
/// Window derivation granularity (T-1152, R5 redesign — see
/// [`WindowGranularity`]'s doc for the full rationale). `#[serde(default)]`
/// (`None`) resolves to [`WindowGranularity::District`] (today's default
/// behavior, byte-compatible with every pre-T-1150 caller) via
/// [`resolve_window_granularity_v2`] — **never trusted from the wire**,
/// unrecognized/absent values fall back to district.
///
/// **Type seam (Tyre C2, PR #191 review):** this field expresses ONLY
/// finer-than-district integer multiples of the district spacing — `1`
/// and `4` are legal today, and each new finer rung (e.g. a future
/// block/tile value) is a deliberate widening of
/// [`resolve_window_granularity`]'s whitelist, the single point where
/// that widening happens. It CANNOT express coarser-than-district rungs
/// (region/orbital, granularity < 1) — reusing this field for those is
/// explicitly out of scope; design doc §5/§9 R5 requires a
/// signed/log-scale value or an explicit rung enum instead. Do not smuggle
/// a "granularity 0 means region" convention into this `u32` — that is
/// the redesign R5 already flags, not a value to add here.
#[serde(default)]
pub window_granularity: u32,
/// The R5-redesigned granularity vocabulary (T-1152), able to express
/// coarser-than-district rungs the legacy `window_granularity: u32`
/// cannot (Tyre's wire-contract note — see [`WindowGranularity`]'s doc
/// for the full rationale). `#[serde(default)]` (`None`) is the absent
/// case: every pre-T-1152 client (and every T-1150 client that only ever
/// sends the legacy `u32`) omits this field entirely and is byte-compatible
/// — [`resolve_window_granularity_v2`] falls back to the legacy field
/// when this is `None`. When BOTH fields are present, THIS field wins
/// (documented once, on [`WindowGranularity`], not duplicated here).
/// **T-1159:** the legacy `window_granularity: u32` field this superseded
/// (T-1150's finer-than-district-only encoding) is retired — this enum
/// fully shadowed it since T-1152 landed, and no pre-T-1152 client exists
/// (single-repo client/server pair). See D-255(c): the `district_window`
/// carrier itself stays alive byte-unchanged for its existing consumer;
/// only the redundant `u32` alongside this enum is gone.
#[serde(default)]
pub window_granularity_v2: Option<WindowGranularity>,
/// Octave cutoff for the invented-terrain scatter (T-1149's
@@ -805,25 +721,14 @@ pub struct DistrictWindowLayer {
/// (T-1150 design doc §2: "the window's `n` stays the DISTRICT extent").
/// The derived cell grid's actual side length is `n * granularity`.
pub n: u32,
/// Derivation granularity (T-1150): [`WINDOW_GRANULARITY_DISTRICT`] (1)
/// or [`WINDOW_GRANULARITY_QUARTER`] (4) for finer-than-district rungs;
/// [`WINDOW_GRANULARITY_REGION_KEY`] (a reserved key-space tag, NOT a
/// spacing multiplier) when [`Self::granularity_v2`] is `Region` — see
/// that constant's doc. Kept unchanged (never repurposed or removed) for
/// wire back-compat with every pre-T-1152 client, which reads only this
/// field and has no concept of `granularity_v2`. Echoed so the client's
/// cache key and staleness guard can distinguish windows at different
/// finer-than-district rungs requested at the identical `(center, n)`.
pub granularity: u32,
/// The R5-redesigned granularity (T-1152) — see [`WindowGranularity`]'s
/// doc. Always populated (never `None`): the server always resolves a
/// concrete rung internally via [`resolve_window_granularity_v2`]
/// regardless of which wire field the request used, so the response
/// always carries the enum echo alongside the legacy `u32` one. A
/// T-1152-aware client reads THIS field for staleness/cache-key
/// comparison; `granularity` (above) exists only for pre-T-1152 clients,
/// who never see `Region` responses in the first place (they have no way
/// to request one).
/// concrete rung internally via [`resolve_window_granularity_v2`].
///
/// **T-1159:** the legacy `granularity: u32` echo this field used to sit
/// alongside (a wire-back-compat value for pre-T-1152 clients, which do
/// not exist — single-repo client/server pair) is retired. This enum
/// echo has been the sole granularity signal since T-1152.
pub granularity_v2: WindowGranularity,
/// The `min_wavelength_m` octave cutoff (T-1149) this window was derived
/// with, in whole metres (`0` = no cutoff). Echoed for the same reason as
@@ -1616,7 +1521,6 @@ pub fn build_district_window_layer(
DistrictWindowLayer {
center,
n,
granularity: granularity.key_u32(),
granularity_v2: granularity,
min_wl_m,
morphology,
@@ -1713,7 +1617,6 @@ fn build_district_window_layer_serial(
DistrictWindowLayer {
center,
n,
granularity: granularity.key_u32(),
granularity_v2: granularity,
min_wl_m,
morphology,
@@ -2295,6 +2198,11 @@ pub fn handle_atlas_request(
// in DistrictProfile.basin_direction (BodyWorldState.districts) and is
// not needed again here. Supply an empty map.
survey_basin_dirs: std::collections::BTreeMap::new(),
// T-1169: mirrors state.attractors.clone() above — feature_names
// is stored on BodyWorldState (see cascade::into_body_world_state)
// and cloned back out here, same reconstruction discipline as
// every other Layer1Output field on this cache-hit path.
feature_names: state.feature_names.clone(),
};
let district_grid = build_district_grid(state);
let road_graph = build_road_graph_layer(state);
@@ -2346,6 +2254,36 @@ pub fn handle_atlas_request(
}
None => (Vec::new(), None),
};
// Pre-resolve this body's reserved river/mountain name pools so
// the Rayon work item stays DB-free (T-1169, D-223, same D-225
// pattern as `cities` above). Read failure is non-fatal: log and
// fall back to no names (attach_feature_names degrades gracefully
// — every attractor simply gets no name).
let (river_names, mountain_names) = match city_reader {
Some(reader) => match reader.read_body_feature_names(&req.body_id) {
Ok(rows) => {
let mut rivers = Vec::new();
let mut mountains = Vec::new();
for row in rows {
match row.feature_type.as_str() {
"river" => rivers.push(row.name),
"mountain" => mountains.push(row.name),
_ => {}
}
}
(rivers, mountains)
}
Err(e) => {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"feature name read failed; attaching no names"
);
(Vec::new(), Vec::new())
}
},
None => (Vec::new(), Vec::new()),
};
// Pre-resolve body physical params so the Rayon work item stays
// DB-free (D-225 pattern). Read failures are non-fatal: log and
// fall back to None (cascade stops at Settlement, pre-T-1032
@@ -2373,6 +2311,8 @@ pub fn handle_atlas_request(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
},
GenPriority::Immediate,
);
@@ -2457,6 +2397,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
@@ -2636,7 +2577,6 @@ mod tests {
assert_eq!(layer.center, (10, -5));
assert_eq!(layer.n, n);
assert_eq!(layer.granularity, WINDOW_GRANULARITY_DISTRICT);
assert_eq!(layer.granularity_v2, WindowGranularity::District);
assert_eq!(layer.min_wl_m, 0);
let cells = (n * n) as usize;
@@ -3894,7 +3834,6 @@ mod tests {
let mk = |center, n| DistrictWindowLayer {
center,
n,
granularity: WINDOW_GRANULARITY_DISTRICT,
granularity_v2: WindowGranularity::District,
min_wl_m: 0,
morphology: vec![0; (n * n) as usize],
@@ -3943,7 +3882,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4004,8 +3942,7 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: 32,
window_granularity: WINDOW_GRANULARITY_QUARTER,
window_granularity_v2: None,
window_granularity_v2: Some(WindowGranularity::Quarter),
window_min_wl_m: 0,
};
@@ -4035,7 +3972,8 @@ mod tests {
});
let layer = window_completion.expect("DeriveWindow must complete for GJ1c");
assert_eq!(
layer.granularity, WINDOW_GRANULARITY_QUARTER,
layer.granularity_v2,
WindowGranularity::Quarter,
"granularity must echo back as requested (4 is within budget on its own)"
);
assert_eq!(
@@ -4045,36 +3983,9 @@ mod tests {
}
// -------------------------------------------------------------------
// resolve_window_granularity / clamp_window_n (T-1150)
// clamp_window_n (T-1150)
// -------------------------------------------------------------------
#[test]
fn resolve_window_granularity_maps_known_values() {
assert_eq!(resolve_window_granularity(0), WINDOW_GRANULARITY_DISTRICT);
assert_eq!(
resolve_window_granularity(WINDOW_GRANULARITY_DISTRICT),
WINDOW_GRANULARITY_DISTRICT
);
assert_eq!(
resolve_window_granularity(WINDOW_GRANULARITY_QUARTER),
WINDOW_GRANULARITY_QUARTER
);
}
/// Never trust the wire: an unrecognized granularity value (garbage, or a
/// future rung not yet implemented) falls back to district, never panics
/// or propagates un-vetted.
#[test]
fn resolve_window_granularity_unknown_value_falls_back_to_district() {
for garbage in [2, 3, 5, 100, u32::MAX] {
assert_eq!(
resolve_window_granularity(garbage),
WINDOW_GRANULARITY_DISTRICT,
"unrecognized granularity {garbage} must fall back to district"
);
}
}
/// District granularity: the per-axis DISTRICT_WINDOW_MAX_N cap alone
/// governs (64² × 1² = 4,096 = WIRE_CAP_CELLS exactly, so the cap is
/// never tighter than DISTRICT_WINDOW_MAX_N at granularity 1).
@@ -4283,7 +4194,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((10, -5)),
window_n: 4,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 4_000,
};
@@ -4292,7 +4202,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((10, -5)),
window_n: 4,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 4_300,
};
@@ -4510,7 +4419,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((12276, 3021)),
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4567,7 +4475,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((4, 383)), // the hand-computed canonical twin
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4650,7 +4557,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4659,8 +4565,7 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_QUARTER,
window_granularity_v2: None,
window_granularity_v2: Some(WindowGranularity::Quarter),
window_min_wl_m: 0,
};
@@ -4750,8 +4655,6 @@ mod tests {
.district_window
.expect("quarter request must hit its own cached entry");
assert_eq!(district_layer.granularity, WINDOW_GRANULARITY_DISTRICT);
assert_eq!(quarter_layer.granularity, WINDOW_GRANULARITY_QUARTER);
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
assert_eq!(quarter_layer.granularity_v2, WindowGranularity::Quarter);
// n echoes the DISTRICT extent unchanged at both granularities
@@ -4810,7 +4713,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
window_granularity: WINDOW_GRANULARITY_DISTRICT,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -4819,9 +4721,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: center,
window_n: n,
// Legacy field is irrelevant here — window_granularity_v2 takes
// precedence per resolve_window_granularity_v2's documented rule.
window_granularity: 0,
window_granularity_v2: Some(WindowGranularity::Region),
window_min_wl_m: 0,
};
@@ -4911,12 +4810,7 @@ mod tests {
assert_eq!(district_layer.granularity_v2, WindowGranularity::District);
assert_eq!(region_layer.granularity_v2, WindowGranularity::Region);
// The legacy u32 echo must NEVER collide with a real multiplier —
// WINDOW_GRANULARITY_REGION_KEY is the reserved key-space tag (see
// that constant's doc), distinct from both WINDOW_GRANULARITY_DISTRICT
// (1) and WINDOW_GRANULARITY_QUARTER (4).
assert_eq!(region_layer.granularity, WINDOW_GRANULARITY_REGION_KEY);
assert_ne!(region_layer.granularity, district_layer.granularity);
assert_ne!(region_layer.granularity_v2, district_layer.granularity_v2);
// n echoes the DISTRICT extent unchanged (design doc §2), same as
// every other rung — the derived CELL GRID is what differs.
@@ -4959,7 +4853,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((0, 0)),
window_n: DISTRICT_WINDOW_MAX_N_REGION * 10,
window_granularity: 0,
window_granularity_v2: Some(WindowGranularity::Region),
window_min_wl_m: 0,
};
@@ -5019,7 +4912,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: Some((12276, 3021)), // raw, out-of-range
window_n: 4,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -5100,7 +4992,6 @@ mod tests {
let window = DistrictWindowLayer {
center: (10, -5),
n: 2,
granularity: WINDOW_GRANULARITY_DISTRICT,
granularity_v2: WindowGranularity::District,
min_wl_m: 0,
morphology: vec![0, 8, 14, 16],
@@ -5161,8 +5052,8 @@ mod tests {
assert_eq!(decoded.window_center, None);
assert_eq!(decoded.window_n, 0);
assert_eq!(
decoded.window_granularity, 0,
"T-1150: absent window_granularity decodes to 0 (district), byte-compatible"
decoded.window_granularity_v2, None,
"T-1152/T-1159: absent window_granularity_v2 decodes to None (district), byte-compatible"
);
assert_eq!(
decoded.window_min_wl_m, 0,
@@ -5514,6 +5405,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
@@ -5784,7 +5676,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
}
@@ -5861,6 +5752,7 @@ mod tests {
river_network: RiverNetwork::default(),
drainage_basins: vec![],
attractors: vec![],
feature_names: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
+60 -3
View File
@@ -15,7 +15,8 @@ use std::collections::BTreeMap;
use std::sync::Arc;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
handle_city_names_request, handle_feature_names_request, handle_star_map_request,
StarMapDataPath,
};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::body_params_reader::BodyParamsReaderResource;
@@ -52,7 +53,8 @@ use crate::atlas::trait_swerve::{
};
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, FeatureNamesRequestBuffer,
FeatureNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
@@ -83,6 +85,10 @@ impl Plugin for GenerationPlugin {
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(
Update,
serve_feature_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
@@ -201,6 +207,26 @@ fn serve_city_names_requests(
}
}
/// Drain inbound feature-names requests and serve each through the proxy
/// (T-1169): D-236 Sol check, then the names-only `atlas_feature_names` read.
/// Mirrors [`serve_city_names_requests`] exactly.
fn serve_feature_names_requests(
mut requests: ResMut<FeatureNamesRequestBuffer>,
mut responses: ResMut<FeatureNamesResponseBuffer>,
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 (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_feature_names_request(&req, reader)));
}
}
/// Drain inbound data-browser requests and serve each through the proxy
/// (D-254 §4, T-1131): one of the six v1 registry-tier entity kinds, dispatched
/// to `BrowseReader` by `(kind, query)`.
@@ -1043,6 +1069,8 @@ mod tests {
cities: vec![],
dominant_faction: None,
body_params: None, // T-1023: no DB params in this unit test
river_names: vec![],
mountain_names: vec![],
},
GenPriority::Immediate,
);
@@ -1079,7 +1107,6 @@ mod tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
},
@@ -1193,6 +1220,36 @@ mod tests {
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
#[test]
fn serve_feature_names_without_reader_is_error() {
use crate::atlas::atlas_data_proxy::{FeatureNamesRequest, FeatureNamesStatus};
let mut world = World::new();
world.insert_resource(FeatureNamesRequestBuffer(vec![(
ConnectionId(0),
FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".to_string(),
},
)]));
world.insert_resource(FeatureNamesResponseBuffer::default());
// No CityContextReaderResource.
let mut sched = Schedule::default();
sched.add_systems(serve_feature_names_requests);
sched.run(&mut world);
let responses = world.resource::<FeatureNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
assert!(matches!(
responses.0[0].1.status,
FeatureNamesStatus::Error(_)
));
assert!(world.resource::<FeatureNamesRequestBuffer>().0.is_empty());
}
fn sample_read_set() -> CityEconomicReadSet {
use crate::simulation::generator::SettlementClass;
CityEconomicReadSet {
+11 -1
View File
@@ -3,7 +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::atlas_data_proxy::{CityNamesResponse, FeatureNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
@@ -169,6 +169,16 @@ impl SimBridge for LocalBridge {
Ok(())
}
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> 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_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
+145 -12
View File
@@ -7,7 +7,8 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use crate::atlas::atlas_data_proxy::{
CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse,
CityNamesRequest, CityNamesResponse, FeatureNamesRequest, FeatureNamesResponse,
StarMapRequest, StarMapResponse,
};
use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
@@ -80,7 +81,11 @@ pub enum BridgeError {
/// exactly this kind of required marker field — there is no natural ceiling
/// on the DEMUX mechanism itself, only a discipline reminder that a new
/// shape should justify why it can't ride an existing one (as
/// `StepCanvasRequest` does, D-255(c)).
/// `StepCanvasRequest` does, D-255(c)). [`FeatureNamesRequest`] (T-1169) is
/// the SEVENTH shape, riding the identical discriminator convention
/// (`feature_names: bool` + `body_id`, D-236 pattern mirroring
/// `CityNamesRequest` exactly) — confirming the "no natural ceiling, just
/// justify the new shape" discipline this note predicted.
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
@@ -97,6 +102,9 @@ pub enum Inbound {
/// A D-255(a) step-canvas data-canvas request (T-1181, the D-225
/// tagged-envelope migration, executed).
StepCanvasRequest(StepCanvasRequest),
/// A per-body reserved-feature-names request (T-1169, D-236 pattern —
/// mirrors [`Self::CityNamesRequest`]).
FeatureNamesRequest(FeatureNamesRequest),
}
/// Key-presence probe for the defensive multi-shape check in
@@ -111,14 +119,17 @@ struct ShapeProbe {
city_names: Option<serde::de::IgnoredAny>,
browse: Option<serde::de::IgnoredAny>,
step_canvas: Option<serde::de::IgnoredAny>,
feature_names: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131,
/// T-1181). Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest`
/// (map, `body_id`+`up_to`) → `StarMapRequest` (map, `star_map`
/// discriminator) → `CityNamesRequest` (map, `city_names` discriminator +
/// `body_id`) → `BrowseRequest` (map, `browse` discriminator) →
/// `StepCanvasRequest` (map, `step_canvas` discriminator).
/// T-1181, T-1169). Tries, in order: `Vec<PlayerInput>` (array) →
/// `AtlasLayerRequest` (map, `body_id`+`up_to`) → `StarMapRequest` (map,
/// `star_map` discriminator) → `CityNamesRequest` (map, `city_names`
/// discriminator + `body_id`) → `BrowseRequest` (map, `browse`
/// discriminator) → `FeatureNamesRequest` (map, `feature_names`
/// discriminator + `body_id`) → `StepCanvasRequest` (map, `step_canvas`
/// discriminator).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
@@ -126,7 +137,7 @@ struct ShapeProbe {
/// more than one shape — e.g. a buggy encoder emitting
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
/// satisfying none of the six shapes is a genuinely malformed input frame.
/// satisfying none of the seven 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));
@@ -141,21 +152,24 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
let city_names = probe.city_names.is_some();
let browse = probe.browse.is_some();
let step_canvas = probe.step_canvas.is_some();
let feature_names = probe.feature_names.is_some();
let shapes = usize::from(atlas)
+ usize::from(star_map)
+ usize::from(city_names)
+ usize::from(browse)
+ usize::from(step_canvas);
+ usize::from(step_canvas)
+ usize::from(feature_names);
if shapes > 1 {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}, feature_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
browse,
step_canvas,
feature_names,
dump_len,
payload.len(),
&payload[..dump_len]
@@ -178,6 +192,9 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(req) = rmp_serde::from_slice::<BrowseRequest>(payload) {
return Ok(Inbound::BrowseRequest(req));
}
if let Ok(req) = rmp_serde::from_slice::<FeatureNamesRequest>(payload) {
return Ok(Inbound::FeatureNamesRequest(req));
}
match rmp_serde::from_slice::<StepCanvasRequest>(payload) {
Ok(req) => Ok(Inbound::StepCanvasRequest(req)),
Err(e) => {
@@ -229,6 +246,9 @@ pub trait SimBridge: Send + Sync {
/// Send a city-names response to the client (T-949b).
fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>;
/// Send a feature-names response to the client (T-1169).
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> Result<(), BridgeError>;
/// Send a browse response to the client (T-1131).
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>;
@@ -479,6 +499,27 @@ impl BridgeResource {
}
}
/// Send a feature-names response to exactly the connection that
/// requested it (T-1169 — mirrors [`Self::send_city_names_response_to`]
/// exactly).
pub fn send_feature_names_response_to(
&self,
id: ConnectionId,
resp: &FeatureNamesResponse,
) -> Result<(), BridgeError> {
match self.connection(id) {
Some(c) => c.bridge.send_feature_names_response(resp),
None => {
tracing::debug!(
"feature names response for {:?} dropped — connection {:?} no longer present",
resp.body_id,
id
);
Ok(())
}
}
}
/// Send a browse response to exactly the connection that requested it
/// (T-1131 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names).
@@ -573,6 +614,7 @@ pub fn receive_bridge_inputs(
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
mut browse_requests: ResMut<BrowseRequestBuffer>,
mut step_canvas_requests: ResMut<StepCanvasRequestBuffer>,
mut feature_names_requests: ResMut<FeatureNamesRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(mut bridge) = bridge else { return };
@@ -621,6 +663,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((player_id, req));
}
Ok(Some(Inbound::FeatureNamesRequest(req))) => {
feature_names_requests.0.push((player_id, req));
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
@@ -738,6 +783,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((reader_id, req));
}
Ok(Some(Inbound::FeatureNamesRequest(req))) => {
feature_names_requests.0.push((reader_id, req));
}
Ok(None) => break,
Err(BridgeError::Disconnected) => {
tracing::info!("Reader connection {:?} disconnected", reader_id);
@@ -952,6 +1000,37 @@ pub fn send_city_names_responses(
}
}
/// Inbound feature-names requests routed off the bridge (T-1169), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct FeatureNamesRequestBuffer(pub Vec<(ConnectionId, FeatureNamesRequest)>);
/// Outbound feature-names responses, filled by the proxy serve system and
/// flushed to the client in `PostSnapshot` (T-1169). Connection-tagged
/// (D-254 §2).
#[derive(Resource, Default)]
pub struct FeatureNamesResponseBuffer(pub Vec<(ConnectionId, FeatureNamesResponse)>);
/// Flush buffered feature-names responses to their requesting connections
/// (T-1169; D-254 §2 per-connection routing). A failed send is logged but not
/// fatal. Mirrors [`send_city_names_responses`] exactly.
pub fn send_feature_names_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<FeatureNamesResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for (id, resp) in buffer.0.drain(..) {
if let Err(e) = bridge.send_feature_names_response_to(id, &resp) {
tracing::warn!(
"failed to send feature names response for {} to {:?}: {}",
resp.body_id,
id,
e
);
}
}
}
/// Inbound data-browser requests routed off the bridge (T-1131), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
@@ -1170,6 +1249,8 @@ impl Plugin for BridgePlugin {
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
.init_resource::<FeatureNamesRequestBuffer>()
.init_resource::<FeatureNamesResponseBuffer>()
.init_resource::<BrowseRequestBuffer>()
.init_resource::<BrowseResponseBuffer>()
.init_resource::<StepCanvasRequestBuffer>()
@@ -1198,6 +1279,10 @@ impl Plugin for BridgePlugin {
Update,
send_city_names_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_feature_names_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_browse_responses.in_set(TickPhase::PostSnapshot),
@@ -1288,7 +1373,6 @@ mod inbound_tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
};
@@ -1324,6 +1408,56 @@ mod inbound_tests {
));
}
/// T-1169: `FeatureNamesRequest` mirrors `CityNamesRequest`'s demux test
/// exactly — the seventh shape rides the same discriminator convention.
#[test]
fn demux_routes_feature_names_requests() {
let req = FeatureNamesRequest {
feature_names: true,
body_id: "GJ1c".into(),
};
let frame = rmp_serde::to_vec_named(&req).unwrap();
assert!(matches!(
decode_inbound(&frame),
Ok(Inbound::FeatureNamesRequest(r)) if r.body_id == "GJ1c"
));
// Cross-check: a CityNamesRequest frame must NOT decode as
// FeatureNamesRequest even though both key on `body_id` — the
// missing `feature_names` discriminator makes that a hard failure.
let city_names_frame = rmp_serde::to_vec_named(&CityNamesRequest {
city_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(rmp_serde::from_slice::<FeatureNamesRequest>(&city_names_frame).is_err());
assert!(rmp_serde::from_slice::<CityNamesRequest>(&frame).is_err());
}
/// PR #176 review H1's union-frame rejection extended to the seventh
/// shape (T-1169): a frame carrying BOTH `city_names` and
/// `feature_names` discriminators must be rejected, not silently routed
/// to whichever shape `decode_inbound` tries first.
#[test]
fn ambiguous_city_and_feature_names_union_frame_is_rejected() {
#[derive(serde::Serialize)]
struct CityAndFeature {
city_names: bool,
feature_names: bool,
body_id: String,
}
let frame = rmp_serde::to_vec_named(&CityAndFeature {
city_names: true,
feature_names: true,
body_id: "GJ1c".into(),
})
.unwrap();
assert!(
decode_inbound(&frame).is_err(),
"city_names+feature_names union frame must be rejected"
);
}
/// 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
@@ -1336,7 +1470,6 @@ mod inbound_tests {
up_to: CascadeLayer::Topography,
window_center: None,
window_n: 0,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 0,
})
+15 -1
View File
@@ -4,7 +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::atlas_data_proxy::{CityNamesResponse, FeatureNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
@@ -316,6 +316,20 @@ impl SimBridge for TcpBridge {
Ok(())
}
fn send_feature_names_response(&self, resp: &FeatureNamesResponse) -> 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_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self