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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user