feat(simulation): atlas proxy layers + data-delivery endpoints (T-960, T-949)
T-960: RoadGraphLayer (L2 edges/junctions, MaintenanceAuthority, rail flag, named routes) + SettlementLayer (name/position/size-class/is_capital/is_port from T-955 placements) as new Option fields on AtlasLayerResponse — T-1046 precedent; layer_proxy doc comment corrected (cascade runs through RoadGraph, client up_to ignored). T-949: new StarMapRequest/StarMapResponse (raw JSON passthrough of the star_map_data.json bake) + CityNamesRequest/CityNamesResponse (atlas_city_names via CityContextReader) with defense-in-depth Sol exclusion (GJ-0 system id OR settlement_wave='origin', D-236). Inbound demux extended with mandatory boolean discriminator fields (serde ignores unknown fields — optional-shape sniffing would be ambiguous); AtlasLayerRequest unchanged. SimBridge trait +2 methods, implemented on TcpBridge + LocalBridge. CityPlacement/CityRecord gained name/population/is_capital (BodyWorldState cache-hit path stays DB-free). ~30 new unit tests incl. demux disambiguation + Sol branches; gen_fixtures extended for road/settlement samples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -275,7 +275,8 @@ impl CityContextReader {
|
||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, economic_role, population, settlement_class
|
||||
"SELECT id, name, economic_role, population, settlement_class,
|
||||
COALESCE(kind, 'city')
|
||||
FROM atlas_city_names
|
||||
WHERE body_id = ?1
|
||||
ORDER BY id",
|
||||
@@ -289,13 +290,14 @@ impl CityContextReader {
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (id, name, role, population, sclass) =
|
||||
let (id, name, role, population, sclass, kind) =
|
||||
r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let city_id = id as u64;
|
||||
let settlement_class = match sclass.as_deref() {
|
||||
@@ -315,6 +317,7 @@ impl CityContextReader {
|
||||
settlement_class,
|
||||
population,
|
||||
economic_role: role.unwrap_or_else(|| "residential".to_string()),
|
||||
is_capital: kind == "capital",
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -349,6 +352,99 @@ impl CityContextReader {
|
||||
Err(e) => Err(CityContextReadError::Db(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// D-236 Sol-exclusion gate: `true` if `body_id`'s system is Sol.
|
||||
///
|
||||
/// Sol (system `GJ-0`) is permanently out of the generation cascade — the
|
||||
/// two signals D-236 names as equivalent gate flags are checked directly:
|
||||
/// `bodies.system_id = 'GJ-0'` *or* the joined
|
||||
/// `system_history.settlement_wave = 'origin'`. Either one alone is
|
||||
/// sufficient (defence in depth; in practice they always agree — `'origin'`
|
||||
/// is a one-off wave value only ever assigned to GJ-0).
|
||||
///
|
||||
/// An unknown `body_id` is **not** treated as Sol (`Ok(false)`) — that's a
|
||||
/// distinct "no such body" outcome the caller's own not-found handling
|
||||
/// covers (mirrors [`read_body_dominant_faction`](Self::read_body_dominant_faction)'s
|
||||
/// convention). Only a DB/mutex error fails.
|
||||
pub fn is_sol_body(&self, body_id: &str) -> Result<bool, CityContextReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let result = conn.query_row(
|
||||
"SELECT b.system_id, sh.settlement_wave
|
||||
FROM bodies AS b
|
||||
LEFT JOIN system_history AS sh ON sh.system_id = b.system_id
|
||||
WHERE b.body_id = ?1",
|
||||
[body_id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||
);
|
||||
match result {
|
||||
Ok((system_id, settlement_wave)) => {
|
||||
Ok(system_id == "GJ-0" || settlement_wave.as_deref() == Some("origin"))
|
||||
}
|
||||
// Body not present → not (specifically) Sol; the caller's own
|
||||
// not-found handling applies to the "unknown body" case.
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
|
||||
Err(e) => Err(CityContextReadError::Db(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every authored settlement **name** on `body_id` from
|
||||
/// `atlas_city_names` (T-949 — replaces the client's names-only
|
||||
/// `markers.json` read for non-Sol bodies, D-223/D-236). Unlike
|
||||
/// [`read_body_settlements`](Self::read_body_settlements) this returns only
|
||||
/// the id/name/capital-flag triple — no economic/placement fields — and is
|
||||
/// available immediately (it doesn't require the generation cascade to have
|
||||
/// placed anything). Ordered by `id`. An unknown body yields an empty list
|
||||
/// (matches `read_body_settlements`'s convention); only a DB/mutex error
|
||||
/// fails.
|
||||
pub fn read_body_city_names(
|
||||
&self,
|
||||
body_id: &str,
|
||||
) -> Result<Vec<CityNameRow>, CityContextReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, COALESCE(kind, 'city')
|
||||
FROM atlas_city_names
|
||||
WHERE body_id = ?1
|
||||
ORDER BY id",
|
||||
)
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([body_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (id, name, kind) = r.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||
out.push(CityNameRow {
|
||||
city_id: id as u64,
|
||||
name,
|
||||
is_capital: kind == "capital",
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the T-949 names-only read (see
|
||||
/// [`CityContextReader::read_body_city_names`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CityNameRow {
|
||||
pub city_id: u64,
|
||||
pub name: String,
|
||||
pub is_capital: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1057,4 +1153,114 @@ mod tests {
|
||||
"unrecognized class defaults to PopulationBudget"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── read_body_city_names (T-949) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_body_city_names_returns_id_name_capital() {
|
||||
let db = make_settlements_db(&[
|
||||
("Capital", "financial", 2_000_000, Some("NameLocked")),
|
||||
("Outpost", "extraction", 5_000, None),
|
||||
]);
|
||||
let conn = Connection::open(&db).expect("reopen");
|
||||
conn.execute(
|
||||
"UPDATE atlas_city_names SET kind = 'capital' WHERE name = 'Capital'",
|
||||
[],
|
||||
)
|
||||
.expect("set capital");
|
||||
drop(conn);
|
||||
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
let names = reader.read_body_city_names("PlanetX").expect("read");
|
||||
assert_eq!(names.len(), 2);
|
||||
// Ordered by id == insertion order.
|
||||
assert_eq!(names[0].name, "Capital");
|
||||
assert!(names[0].is_capital);
|
||||
assert_eq!(names[1].name, "Outpost");
|
||||
assert!(
|
||||
!names[1].is_capital,
|
||||
"the default 'city' kind must not read as capital"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_city_names_unknown_body_is_empty() {
|
||||
let db = make_settlements_db(&[("Solo", "residential", 10_000, None)]);
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
assert!(
|
||||
reader
|
||||
.read_body_city_names("Ghost")
|
||||
.expect("read")
|
||||
.is_empty(),
|
||||
"unknown body yields no names, matching read_body_settlements' convention"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── is_sol_body (T-949, D-236) ──────────────────────────────────────────
|
||||
|
||||
/// Minimal db for `is_sol_body`: one `bodies` row + an optional
|
||||
/// `system_history` row carrying `settlement_wave`.
|
||||
fn make_sol_db(body_id: &str, system_id: &str, settlement_wave: Option<&str>) -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_ctxsol_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let conn = Connection::open(&path).expect("create db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE bodies (body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL);
|
||||
CREATE TABLE system_history (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
settlement_wave TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![body_id, system_id],
|
||||
)
|
||||
.expect("insert body");
|
||||
if let Some(wave) = settlement_wave {
|
||||
conn.execute(
|
||||
"INSERT INTO system_history (system_id, settlement_wave) VALUES (?1, ?2)",
|
||||
rusqlite::params![system_id, wave],
|
||||
)
|
||||
.expect("insert system_history");
|
||||
}
|
||||
drop(conn);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sol_body_true_for_gj0_system_id() {
|
||||
let db = make_sol_db("Earth", "GJ-0", None);
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
assert!(reader.is_sol_body("Earth").expect("query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sol_body_true_for_origin_settlement_wave() {
|
||||
// D-236 names `system_id = 'GJ-0'` and `settlement_wave = 'origin'` as
|
||||
// equivalent gate signals — a body whose system carries the 'origin'
|
||||
// wave (even under a hypothetically different system_id) must also be
|
||||
// excluded, not just a literal "GJ-0" string match.
|
||||
let db = make_sol_db("Weirdbody", "GJ-999", Some("origin"));
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
assert!(reader.is_sol_body("Weirdbody").expect("query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sol_body_false_for_ordinary_body() {
|
||||
let db = make_sol_db("GJ1c", "GJ-1", Some("first_wave"));
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
assert!(!reader.is_sol_body("GJ1c").expect("query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sol_body_false_for_unknown_body() {
|
||||
let db = make_sol_db("GJ1c", "GJ-1", None);
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
assert!(
|
||||
!reader.is_sol_body("ghost").expect("query"),
|
||||
"an unknown body is not (specifically) Sol-excluded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user