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());
}
}