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:
2026-07-14 15:45:25 +02:00
co-authored by Claude Fable 5
parent 6bda9f1697
commit 37881acba0
15 changed files with 1654 additions and 23 deletions
+208 -2
View File
@@ -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"
);
}
}