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>
705 lines
28 KiB
Rust
705 lines
28 KiB
Rust
//! Atlas data-delivery proxy (T-949) — two thin, independent endpoints
|
|
//! alongside the per-body layer-stream proxy ([`crate::atlas::layer_proxy`]):
|
|
//!
|
|
//! - **Star map** ([`StarMapRequest`]/[`StarMapResponse`]): a session-scoped,
|
|
//! non-per-body proxy over the static `client/data/star_map_data.json`
|
|
//! dataset (produced by `tooling/generate-star-map-data.py`). Reads the file
|
|
//! fresh on every request — no caching, no staleness handling — because the
|
|
//! client only asks once per session when the Atlas star-map view opens.
|
|
//! The parsed JSON is passed through as an opaque `serde_json::Value` rather
|
|
//! than a hand-mirrored Rust struct: the server has no reason to understand
|
|
//! `_meta`/`nodes`/`edges`, so this stays a genuinely thin proxy that never
|
|
//! needs a code change when the generator's JSON shape evolves.
|
|
//!
|
|
//! - **City names** ([`CityNamesRequest`]/[`CityNamesResponse`]): a per-body
|
|
//! names-only settlement list read straight from `atlas_city_names`
|
|
//! (replaces the client's legacy `markers.json` names-only read, D-223, for
|
|
//! every body except Sol). Available immediately — it does not wait on the
|
|
//! generation cascade the way `AtlasLayerResponse.settlements` does.
|
|
//!
|
|
//! **Sol exclusion (D-236):** Sol (system `GJ-0`) is permanently out of the
|
|
//! generation cascade. `handle_city_names_request` checks
|
|
//! [`CityContextReader::is_sol_body`] first and returns
|
|
//! [`CityNamesStatus::SolExcluded`] with an empty city list — the client keeps
|
|
//! its legacy authored `markers.json` read for Sol; this proxy never runs Sol
|
|
//! through any cascade or DB-derived path.
|
|
//!
|
|
//! **Inbound demux (D-225 extension):** the existing array-vs-map trick
|
|
//! (`Vec<PlayerInput>` vs. `AtlasLayerRequest`) is preserved byte-for-byte —
|
|
//! neither request type gained fields. The two new map shapes each carry a
|
|
//! **mandatory boolean discriminator field** (`star_map` / `city_names`)
|
|
//! instead of relying on "which optional field is missing" trial-order
|
|
//! fragility: `CityNamesRequest{city_names: bool, body_id: String}` and
|
|
//! `AtlasLayerRequest{body_id: String, up_to: CascadeLayer}` both key on
|
|
//! `body_id`, and serde's derived `Deserialize` silently ignores unknown
|
|
//! fields by default — so a payload carrying every field either shape wants
|
|
//! would ambiguously satisfy both if disambiguation relied on "does this
|
|
//! parse at all". Requiring a field the *other* shapes don't have at all
|
|
//! (missing required field ⇒ hard deserialize failure, not silent ignore)
|
|
//! keeps every shape mutually exclusive without adding `#[serde(deny_unknown_fields)]`
|
|
//! to `AtlasLayerRequest` (which would risk breaking any already-deployed
|
|
//! client encoder that harmlessly sends extra fields). See
|
|
//! `crate::bridge::decode_inbound` for the trial order + full rationale.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use bevy_ecs::prelude::Resource;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::atlas::city_context_reader::CityContextReader;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Star map proxy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A client request for the star-map dataset (T-949a). Non-per-body — the
|
|
/// map contains every system in the Reach.
|
|
///
|
|
/// `star_map` is the Inbound discriminator (see module doc): always `true`.
|
|
/// Its presence, not its value, is what disambiguates this map shape.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StarMapRequest {
|
|
pub star_map: bool,
|
|
}
|
|
|
|
/// Status of a [`StarMapResponse`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum StarMapStatus {
|
|
/// The dataset was read and parsed (`data` is populated).
|
|
Ready,
|
|
/// IO or JSON-parse failure (message for the client log). The client
|
|
/// should keep using whatever it already has (or its own bundled copy)
|
|
/// rather than treat this as fatal.
|
|
Error(String),
|
|
}
|
|
|
|
/// A star-map response: the parsed `star_map_data.json` contents verbatim, or
|
|
/// an error status (T-949a).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StarMapResponse {
|
|
pub status: StarMapStatus,
|
|
/// Verbatim parsed JSON (`_meta`, `nodes`, `edges` — see
|
|
/// `tooling/generate-star-map-data.py`), re-serialized as MessagePack.
|
|
/// `None` unless `status == Ready`.
|
|
pub data: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Resolved path to `client/data/star_map_data.json` (T-949a). A thin resource
|
|
/// holding just the path — the file is re-read fresh on every request (no
|
|
/// caching, per the ticket: this is a one-shot-per-session read from the
|
|
/// client, so staleness handling would be pure complexity for no benefit).
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct StarMapDataPath(pub PathBuf);
|
|
|
|
/// Serve one star-map request (T-949a): read + parse the file fresh.
|
|
pub fn handle_star_map_request(_req: &StarMapRequest, path: &Path) -> StarMapResponse {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
|
|
Ok(data) => StarMapResponse {
|
|
status: StarMapStatus::Ready,
|
|
data: Some(data),
|
|
},
|
|
Err(e) => StarMapResponse {
|
|
status: StarMapStatus::Error(format!("star_map_data.json parse error: {e}")),
|
|
data: None,
|
|
},
|
|
},
|
|
Err(e) => StarMapResponse {
|
|
status: StarMapStatus::Error(format!(
|
|
"star_map_data.json read error ({}): {e}",
|
|
path.display()
|
|
)),
|
|
data: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// City names proxy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A client request for a body's authored settlement names (T-949b).
|
|
///
|
|
/// `city_names` is the Inbound discriminator (see module doc): always `true`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CityNamesRequest {
|
|
pub city_names: bool,
|
|
pub body_id: String,
|
|
}
|
|
|
|
/// Status of a [`CityNamesResponse`].
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum CityNamesStatus {
|
|
/// Names are ready (`cities` is populated; legitimately empty for a body
|
|
/// with no authored settlements).
|
|
Ready,
|
|
/// D-236: `body_id` is a Sol body. Sol is permanently out of the
|
|
/// generation cascade and every DB-derived Atlas path — the client must
|
|
/// keep its legacy authored `markers.json` read for Sol. `cities` is empty.
|
|
SolExcluded,
|
|
/// DB/IO failure reading `atlas_city_names` (message for the client log).
|
|
Error(String),
|
|
}
|
|
|
|
/// One settlement name entry (T-949b) — see
|
|
/// [`crate::atlas::city_context_reader::CityNameRow`] for the reader-side row
|
|
/// this is built from.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CityNameEntry {
|
|
pub city_id: u64,
|
|
pub name: String,
|
|
pub is_capital: bool,
|
|
}
|
|
|
|
/// A city-names response: the body's authored settlements, or a non-ready
|
|
/// status (T-949b).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CityNamesResponse {
|
|
pub body_id: String,
|
|
pub status: CityNamesStatus,
|
|
pub cities: Vec<CityNameEntry>,
|
|
}
|
|
|
|
/// Serve one city-names request (T-949b): D-236 Sol check first, then the
|
|
/// names-only `atlas_city_names` read. `city_reader` absent (no DB opened at
|
|
/// startup) is reported as `Error`, matching `layer_proxy`'s "no body source
|
|
/// resolver" convention.
|
|
pub fn handle_city_names_request(
|
|
req: &CityNamesRequest,
|
|
city_reader: Option<&CityContextReader>,
|
|
) -> CityNamesResponse {
|
|
let Some(reader) = city_reader else {
|
|
return CityNamesResponse {
|
|
body_id: req.body_id.clone(),
|
|
status: CityNamesStatus::Error("city context reader unavailable".to_string()),
|
|
cities: Vec::new(),
|
|
};
|
|
};
|
|
|
|
match reader.is_sol_body(&req.body_id) {
|
|
Ok(true) => {
|
|
return CityNamesResponse {
|
|
body_id: req.body_id.clone(),
|
|
status: CityNamesStatus::SolExcluded,
|
|
cities: Vec::new(),
|
|
};
|
|
}
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
return CityNamesResponse {
|
|
body_id: req.body_id.clone(),
|
|
status: CityNamesStatus::Error(e.to_string()),
|
|
cities: Vec::new(),
|
|
};
|
|
}
|
|
}
|
|
|
|
match reader.read_body_city_names(&req.body_id) {
|
|
Ok(rows) => CityNamesResponse {
|
|
body_id: req.body_id.clone(),
|
|
status: CityNamesStatus::Ready,
|
|
cities: rows
|
|
.into_iter()
|
|
.map(|r| CityNameEntry {
|
|
city_id: r.city_id,
|
|
name: r.name,
|
|
is_capital: r.is_capital,
|
|
})
|
|
.collect(),
|
|
},
|
|
Err(e) => CityNamesResponse {
|
|
body_id: req.body_id.clone(),
|
|
status: CityNamesStatus::Error(e.to_string()),
|
|
cities: Vec::new(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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::*;
|
|
use rusqlite::Connection;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
static SEQ: AtomicU32 = AtomicU32::new(0);
|
|
|
|
// ─── StarMapRequest/Response ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn star_map_request_round_trips_msgpack() {
|
|
let req = StarMapRequest { star_map: true };
|
|
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
|
|
let decoded: StarMapRequest = rmp_serde::from_slice(&bytes).expect("decode");
|
|
assert!(decoded.star_map);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_star_map_request_reads_and_parses_file() {
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!("sr_starmap_{}_{n}.json", std::process::id()));
|
|
std::fs::write(
|
|
&path,
|
|
r#"{"_meta": {"v": 1}, "nodes": [1, 2], "edges": []}"#,
|
|
)
|
|
.expect("write fixture json");
|
|
|
|
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
|
|
assert_eq!(resp.status, StarMapStatus::Ready);
|
|
let data = resp.data.as_ref().expect("data present on Ready");
|
|
assert_eq!(data["nodes"][0], 1);
|
|
|
|
// Round trip the whole response through MessagePack too (the actual
|
|
// wire path).
|
|
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
|
let decoded: StarMapResponse = rmp_serde::from_slice(&bytes).expect("decode");
|
|
assert_eq!(decoded.status, StarMapStatus::Ready);
|
|
assert_eq!(decoded.data.unwrap()["nodes"][1], 2);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_star_map_request_missing_file_is_error_not_panic() {
|
|
let path = PathBuf::from("/nonexistent/sr-test/star_map_data.json");
|
|
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
|
|
assert!(matches!(resp.status, StarMapStatus::Error(_)));
|
|
assert!(resp.data.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn handle_star_map_request_malformed_json_is_error() {
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let path =
|
|
std::env::temp_dir().join(format!("sr_starmap_bad_{}_{n}.json", std::process::id()));
|
|
std::fs::write(&path, "{ not valid json").expect("write fixture json");
|
|
|
|
let resp = handle_star_map_request(&StarMapRequest { star_map: true }, &path);
|
|
assert!(matches!(resp.status, StarMapStatus::Error(_)));
|
|
assert!(resp.data.is_none());
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
// ─── CityNamesRequest/Response ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn city_names_request_round_trips_msgpack() {
|
|
let req = CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
|
|
let decoded: CityNamesRequest = rmp_serde::from_slice(&bytes).expect("decode");
|
|
assert!(decoded.city_names);
|
|
assert_eq!(decoded.body_id, "GJ1c");
|
|
}
|
|
|
|
#[test]
|
|
fn city_names_response_round_trips_msgpack() {
|
|
let resp = CityNamesResponse {
|
|
body_id: "GJ1c".into(),
|
|
status: CityNamesStatus::Ready,
|
|
cities: vec![CityNameEntry {
|
|
city_id: 1,
|
|
name: "Port Aldren".into(),
|
|
is_capital: true,
|
|
}],
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
|
let decoded: CityNamesResponse = rmp_serde::from_slice(&bytes).expect("decode");
|
|
assert_eq!(decoded.status, CityNamesStatus::Ready);
|
|
assert_eq!(decoded.cities[0].name, "Port Aldren");
|
|
assert!(decoded.cities[0].is_capital);
|
|
}
|
|
|
|
/// Minimal db mirroring what `is_sol_body` + `read_body_city_names` +
|
|
/// `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()));
|
|
let _ = std::fs::remove_file(&path);
|
|
let conn = Connection::open(&path).expect("create db");
|
|
conn.execute_batch(
|
|
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL);
|
|
CREATE TABLE system_history (
|
|
system_id TEXT PRIMARY KEY,
|
|
settlement_wave TEXT
|
|
);
|
|
CREATE TABLE atlas_city_names (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
body_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'city'
|
|
);
|
|
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");
|
|
conn.execute(
|
|
"INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)",
|
|
rusqlite::params![body_id, system_id],
|
|
)
|
|
.expect("insert body");
|
|
if let Some(wave) = settlement_wave {
|
|
conn.execute(
|
|
"INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)",
|
|
rusqlite::params![system_id, wave],
|
|
)
|
|
.expect("insert system_history");
|
|
}
|
|
drop(conn);
|
|
path
|
|
}
|
|
|
|
fn insert_city(db: &Path, body_id: &str, name: &str, kind: &str) {
|
|
let conn = Connection::open(db).expect("reopen");
|
|
conn.execute(
|
|
"INSERT INTO atlas_city_names (body_id, name, kind) VALUES (?1, ?2, ?3)",
|
|
rusqlite::params![body_id, name, kind],
|
|
)
|
|
.expect("insert city");
|
|
}
|
|
|
|
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(
|
|
&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
},
|
|
None,
|
|
);
|
|
assert!(matches!(resp.status, CityNamesStatus::Error(_)));
|
|
assert!(resp.cities.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn handle_city_names_request_sol_body_is_excluded() {
|
|
// D-236: system_id = GJ-0 → SolExcluded, no DB row read for cities.
|
|
let db = make_db("Earth", "GJ-0", None);
|
|
insert_city(&db, "Earth", "London", "capital");
|
|
let reader = CityContextReader::open(&db).expect("open");
|
|
|
|
let resp = handle_city_names_request(
|
|
&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "Earth".into(),
|
|
},
|
|
Some(&reader),
|
|
);
|
|
assert_eq!(resp.status, CityNamesStatus::SolExcluded);
|
|
assert!(
|
|
resp.cities.is_empty(),
|
|
"Sol-excluded response must carry no cities even though the row exists"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_city_names_request_sol_body_via_settlement_wave_is_excluded() {
|
|
// D-236's second signal: settlement_wave = 'origin' excludes even a
|
|
// non-GJ-0 system_id.
|
|
let db = make_db("Weirdbody", "GJ-999", Some("origin"));
|
|
let reader = CityContextReader::open(&db).expect("open");
|
|
|
|
let resp = handle_city_names_request(
|
|
&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "Weirdbody".into(),
|
|
},
|
|
Some(&reader),
|
|
);
|
|
assert_eq!(resp.status, CityNamesStatus::SolExcluded);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_city_names_request_ordinary_body_returns_names() {
|
|
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
|
|
insert_city(&db, "GJ1c", "Port Aldren", "capital");
|
|
insert_city(&db, "GJ1c", "Millbrook", "city");
|
|
let reader = CityContextReader::open(&db).expect("open");
|
|
|
|
let resp = handle_city_names_request(
|
|
&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
},
|
|
Some(&reader),
|
|
);
|
|
assert_eq!(resp.status, CityNamesStatus::Ready);
|
|
assert_eq!(resp.cities.len(), 2);
|
|
assert_eq!(resp.cities[0].name, "Port Aldren");
|
|
assert!(resp.cities[0].is_capital);
|
|
assert_eq!(resp.cities[1].name, "Millbrook");
|
|
assert!(!resp.cities[1].is_capital);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_city_names_request_unknown_body_is_ready_with_empty_list() {
|
|
// Matches read_body_settlements' existing convention: unknown body →
|
|
// empty list under Ready, not a distinct NotFound status.
|
|
let db = make_db("GJ1c", "GJ-1", Some("first_wave"));
|
|
let reader = CityContextReader::open(&db).expect("open");
|
|
|
|
let resp = handle_city_names_request(
|
|
&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "ghost".into(),
|
|
},
|
|
Some(&reader),
|
|
);
|
|
assert_eq!(resp.status, CityNamesStatus::Ready);
|
|
assert!(resp.cities.is_empty());
|
|
}
|
|
|
|
// ─── 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());
|
|
}
|
|
}
|