diff --git a/server/src/atlas/browse_proxy.rs b/server/src/atlas/browse_proxy.rs new file mode 100644 index 000000000..ecaff64af --- /dev/null +++ b/server/src/atlas/browse_proxy.rs @@ -0,0 +1,1048 @@ +//! Data browser wire proxy (D-254 §4, T-1131) — one demux shape serving all +//! six v1 browse entity kinds (star systems, bodies, stations, corporations, +//! commodities, trait templates). +//! +//! **Why one envelope, not six/twelve request types.** `crate::bridge`'s +//! inbound demux (`decode_inbound`) is documented as topping out at four +//! hand-sniffed frame shapes ("four shapes is the practical limit of this +//! hand-rolled sniffing... do not add a fifth probe"). Six entity kinds x +//! two forms (index/detail) would be up to twelve new top-level shapes — +//! far past that ceiling. Instead this module follows the SAME pattern +//! `AtlasLayerRequest{body_id, up_to: CascadeLayer}` already established: +//! one wire message, an internal enum (here, two: [`BrowseEntityKind`] and +//! [`BrowseQuery`]) picks the sub-behavior. `decode_inbound` gains exactly +//! one new probe (`browse: bool`, the same mandatory-discriminator +//! convention as `star_map`/`city_names`) — the fifth and, per that +//! module's own ceiling note, deliberately the last one this hand-rolled +//! scheme should ever carry; a sixth inbound shape must migrate to the +//! tagged-envelope framing D-225 already deferred. +//! +//! **D-254 §4 v1 scope — six registry-tier tables, index + detail per +//! kind:** +//! +//! 1. Star systems (`star_systems` + `system_economy`/`system_factions`/ +//! `system_culture` folded into the detail screen) +//! 2. Bodies (`bodies`, index filterable by system) +//! 3. Stations (`stations`) +//! 4. Corporations (`corporations` + `corp_presence`/`corp_financial_state` +//! folded in) +//! 5. Commodities (`commodities` + `production_chains`/`chain_inputs`) +//! 6. Trait catalog (`trait_templates`) +//! +//! Deliberately excluded from v1 (D-254 §4): cascade-derived atlas geometry +//! tables (`atlas_cities`/`atlas_roads`/…, partially populated mid-Phase-4) +//! and event-log tables (`corp_lifecycle_events`/`system_history`/ +//! `historical_events`, wrong UI shape for list+detail). +//! +//! **D-010 boundary note.** A Reader connection has no character and +//! receives no `ObserverSnapshot` — there is no per-character fog to bound +//! against here. What a Reader may see is bounded by connection class, and +//! this proxy's six entities are registry-tier (identical across every +//! save, cascade-independent) — the same "install-static/world-public" +//! class `handle_star_map_request`/`handle_city_names_request`/ +//! `handle_atlas_request` already serve to any connection. Browsing a +//! specific save's diverged dynamic state is explicitly OUT of this +//! proxy's scope (D-254 §4) — none of the six v1 tables carry that. + +use serde::{Deserialize, Serialize}; + +use crate::atlas::browse_reader::{ + BodyDetailRow, BrowseReadError, BrowseReader, CommodityDetailRow, CorporationDetailRow, + StarSystemDetailRow, StationDetailRow, TraitTemplateDetailRow, +}; + +// --------------------------------------------------------------------------- +// Wire request +// --------------------------------------------------------------------------- + +/// The six v1 browse entity kinds (D-254 §4). Unit-variant-only enum — on +/// the wire this is a bare MessagePack string (`"StarSystem"`, `"Body"`, …), +/// same convention as `CascadeLayer` in `AtlasLayerRequest.up_to`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowseEntityKind { + StarSystem, + Body, + Station, + Corporation, + Commodity, + TraitTemplate, +} + +/// Index-vs-detail request form. Both variants carry fields, so on the wire +/// each is a single-key MessagePack map (`{"Index": {...}}` / +/// `{"Detail": {...}}`), same convention as `AtlasLayerStatus::Error(String)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BrowseQuery { + /// List form: id + display fields for every row of this kind. + /// `filter_system_id` only means anything for [`BrowseEntityKind::Body`] + /// (D-254 §4: "Bodies, filterable by system") — ignored for every other + /// kind, which always return every row. + Index { filter_system_id: Option }, + /// Single full-row form. `id` is the entity's own primary key string + /// (system_id / body_id / station_id / corp_id / commodity_id / trait + /// tag) — the same string an `Index` response's `BrowseIndexRow::id` + /// carries for that row. + Detail { id: String }, +} + +/// A client request for browse data (T-1131). `browse` is the Inbound +/// discriminator (see `crate::bridge`'s demux doc): always `true`. Its +/// presence, not its value, is what disambiguates this map shape from the +/// other four. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowseRequest { + pub browse: bool, + pub kind: BrowseEntityKind, + pub query: BrowseQuery, +} + +// --------------------------------------------------------------------------- +// Wire response +// --------------------------------------------------------------------------- + +/// Status of a [`BrowseResponse`]. Same three-way shape (two unit variants + +/// one data variant) as `AtlasLayerStatus`/`StarMapStatus`/`CityNamesStatus` +/// minus `Pending` (nothing here is cascade-generated, so nothing is ever +/// "come back later" — see module doc's D-010 boundary note). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowseStatus { + /// The request was served (`index` or `detail` is populated, + /// matching the request's `query` form). + Ready, + /// `Detail`-only: the requested `id` does not exist in that kind's + /// table. `Index` requests never produce `NotFound` — an + /// empty/unfiltered table is `Ready` with an empty `index` list + /// (matches `CityNamesStatus`'s "unknown body -> Ready with empty + /// list" convention, not a distinct not-found case). + NotFound, + /// DB/IO failure (message for the client log). + Error(String), +} + +/// Generic index row — the SAME shape for all six kinds, matching D-254 +/// §4's index-screen spec ("a scrollable list — name + one or two summary +/// columns"). `id` is the primary key to pass back in a following `Detail` +/// request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowseIndexRow { + pub id: String, + /// Display name (or a synthesized fallback when the row has none + /// authored — see `browse_reader`'s per-kind index query). + pub primary: String, + /// One kind-specific summary field: star systems -> star_type; bodies + /// -> body_type; stations -> station_type; corporations -> corp_type; + /// commodities -> tier; trait templates -> corridor_pool. + pub secondary: Option, +} + +/// One variant per entity kind, each carrying every column the detail +/// screen shows (D-254 §4: "showing every column the wire response +/// carries for that one entity"). The wrapped `*DetailRow` structs +/// (`browse_reader`) are `#[derive(Serialize, Deserialize)]`-free by +/// design (DB-layer types); this proxy layer owns the wire encoding, so a +/// thin field-for-field wire struct mirrors each one 1:1. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BrowseDetail { + StarSystem(StarSystemDetail), + Body(BodyDetail), + Station(StationDetail), + Corporation(CorporationDetail), + Commodity(CommodityDetail), + TraitTemplate(TraitTemplateDetail), +} + +/// A browse response: the index list or one detail row (matching the +/// request's `query` form), or a non-`Ready` status (T-1131). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowseResponse { + /// Echoes the request's `kind` — lets the client route the response + /// without needing to correlate against its own outstanding-request + /// state (the connection-tagging in `crate::bridge` already handles + /// "whose request was this"; this is "which of my SIX outstanding + /// concerns is this"). + pub kind: BrowseEntityKind, + pub status: BrowseStatus, + /// Populated iff the request's `query` was `Index` and `status == + /// Ready`. + pub index: Option>, + /// Populated iff the request's `query` was `Detail` and `status == + /// Ready`. + pub detail: Option, +} + +// --------------------------------------------------------------------------- +// Wire-shaped detail structs (1:1 field mirror of browse_reader's DB-layer +// *DetailRow structs — see BrowseDetail doc for why this indirection exists) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StarSystemDetail { + pub system_id: String, + pub proper_name: Option, + pub system_name: Option, + pub star_type: Option, + pub spectral_class: Option, + pub dist_ly: Option, + pub geographic_sector: Option, + pub geographic_band: Option, + pub political_zone: Option, + pub habitable_planet_count: Option, + pub inhabited_planet_count: Option, + pub asteroid_belt: Option, + pub gas_giant: Option, + pub habitability_profile: Option, + pub earth_alignment: Option, + pub earth_proximity: Option, + pub earth_tension: Option, + pub stability_index: Option, + pub system_volatility: Option, + pub cultural_corridor: Option, + pub currency_zone: Option, + pub economic_tier: Option, + pub population: Option, + pub economic_base_primary: Option, + pub economic_base_secondary: Option, + pub governance_type: Option, + pub dominant_faction: Option, + pub cultural_register: Option, + pub atmospheric_tone: Option, + pub primary_archetype: Option, +} + +impl From for StarSystemDetail { + fn from(r: StarSystemDetailRow) -> Self { + Self { + system_id: r.system_id, + proper_name: r.proper_name, + system_name: r.system_name, + star_type: r.star_type, + spectral_class: r.spectral_class, + dist_ly: r.dist_ly, + geographic_sector: r.geographic_sector, + geographic_band: r.geographic_band, + political_zone: r.political_zone, + habitable_planet_count: r.habitable_planet_count, + inhabited_planet_count: r.inhabited_planet_count, + asteroid_belt: r.asteroid_belt, + gas_giant: r.gas_giant, + habitability_profile: r.habitability_profile, + earth_alignment: r.earth_alignment, + earth_proximity: r.earth_proximity, + earth_tension: r.earth_tension, + stability_index: r.stability_index, + system_volatility: r.system_volatility, + cultural_corridor: r.cultural_corridor, + currency_zone: r.currency_zone, + economic_tier: r.economic_tier, + population: r.population, + economic_base_primary: r.economic_base_primary, + economic_base_secondary: r.economic_base_secondary, + governance_type: r.governance_type, + dominant_faction: r.dominant_faction, + cultural_register: r.cultural_register, + atmospheric_tone: r.atmospheric_tone, + primary_archetype: r.primary_archetype, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BodyDetail { + pub body_id: String, + pub system_id: String, + pub parent_body_id: Option, + pub body_type: String, + pub orbit_index: Option, + pub proper_name: Option, + pub mass_class: Option, + pub atmosphere: Option, + pub surface_gravity: Option, + pub orbital_period_days: Option, + pub rotation_period_hours: Option, + pub planet_class: Option, + pub hydrosphere: Option, + pub biosphere_class: Option, + pub inhabited: bool, + pub population: Option, + pub economic_role: Option, + pub founding_age_years: Option, + pub settlement_pattern: Option, + pub cultural_corridor: Option, + pub industrial_corridor: Option, + pub body_radius_km: Option, + pub axial_tilt_deg: Option, +} + +impl From for BodyDetail { + fn from(r: BodyDetailRow) -> Self { + Self { + body_id: r.body_id, + system_id: r.system_id, + parent_body_id: r.parent_body_id, + body_type: r.body_type, + orbit_index: r.orbit_index, + proper_name: r.proper_name, + mass_class: r.mass_class, + atmosphere: r.atmosphere, + surface_gravity: r.surface_gravity, + orbital_period_days: r.orbital_period_days, + rotation_period_hours: r.rotation_period_hours, + planet_class: r.planet_class, + hydrosphere: r.hydrosphere, + biosphere_class: r.biosphere_class, + inhabited: r.inhabited, + population: r.population, + economic_role: r.economic_role, + founding_age_years: r.founding_age_years, + settlement_pattern: r.settlement_pattern, + cultural_corridor: r.cultural_corridor, + industrial_corridor: r.industrial_corridor, + body_radius_km: r.body_radius_km, + axial_tilt_deg: r.axial_tilt_deg, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StationDetail { + pub station_id: String, + pub system_id: String, + pub orbits_body_id: Option, + pub station_type: String, + pub proper_name: Option, + pub population: Option, + pub economic_role: Option, + pub governance_type: Option, + pub docking_class: Option, + pub has_gate_infrastructure: bool, + pub district_count: Option, +} + +impl From for StationDetail { + fn from(r: StationDetailRow) -> Self { + Self { + station_id: r.station_id, + system_id: r.system_id, + orbits_body_id: r.orbits_body_id, + station_type: r.station_type, + proper_name: r.proper_name, + population: r.population, + economic_role: r.economic_role, + governance_type: r.governance_type, + docking_class: r.docking_class, + has_gate_infrastructure: r.has_gate_infrastructure, + district_count: r.district_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CorpPresenceEntry { + pub location_id: String, + pub location_type: String, + pub primary_operation: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CorporationDetail { + pub corp_id: String, + pub proper_name: String, + pub corp_type: String, + pub scope: Option, + pub headquarters_system: Option, + pub headquarters_body: Option, + pub specialization: Option, + pub parent_corp: Option, + pub notes: Option, + pub behavioral_archetype: Option, + pub supply_chain_role: Option, + pub shadow_economy_access: bool, + pub corp_specialization: Option, + pub hq_placement: Option, + pub health_metric: Option, + pub presence: Vec, +} + +impl From for CorporationDetail { + fn from(r: CorporationDetailRow) -> Self { + Self { + corp_id: r.corp_id, + proper_name: r.proper_name, + corp_type: r.corp_type, + scope: r.scope, + headquarters_system: r.headquarters_system, + headquarters_body: r.headquarters_body, + specialization: r.specialization, + parent_corp: r.parent_corp, + notes: r.notes, + behavioral_archetype: r.behavioral_archetype, + supply_chain_role: r.supply_chain_role, + shadow_economy_access: r.shadow_economy_access, + corp_specialization: r.corp_specialization, + hq_placement: r.hq_placement, + health_metric: r.health_metric, + presence: r + .presence + .into_iter() + .map(|p| CorpPresenceEntry { + location_id: p.location_id, + location_type: p.location_type, + primary_operation: p.primary_operation, + }) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChainInputEntry { + pub input_commodity_id: String, + pub quantity: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProductionChainEntry { + pub chain_id: String, + pub output_quantity: f64, + pub location_bound: bool, + pub description: Option, + pub inputs: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CommodityDetail { + pub commodity_id: String, + pub name: String, + pub tier: String, + pub elasticity: String, + pub base_price: f64, + pub bulk_class: Option, + pub unit: Option, + pub production_ubiquity: Option, + pub demand_model: Option, + pub commission_certifiable: bool, + pub compact_contested: bool, + pub shadow_viable: bool, + pub panic_threshold_weeks: Option, + pub description: Option, + pub produced_by: Vec, +} + +impl From for CommodityDetail { + fn from(r: CommodityDetailRow) -> Self { + Self { + commodity_id: r.commodity_id, + name: r.name, + tier: r.tier, + elasticity: r.elasticity, + base_price: r.base_price, + bulk_class: r.bulk_class, + unit: r.unit, + production_ubiquity: r.production_ubiquity, + demand_model: r.demand_model, + commission_certifiable: r.commission_certifiable, + compact_contested: r.compact_contested, + shadow_viable: r.shadow_viable, + panic_threshold_weeks: r.panic_threshold_weeks, + description: r.description, + produced_by: r + .produced_by + .into_iter() + .map(|c| ProductionChainEntry { + chain_id: c.chain_id, + output_quantity: c.output_quantity, + location_bound: c.location_bound, + description: c.description, + inputs: c + .inputs + .into_iter() + .map(|i| ChainInputEntry { + input_commodity_id: i.input_commodity_id, + quantity: i.quantity, + }) + .collect(), + }) + .collect(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TraitTemplateDetail { + pub tag: String, + pub label: String, + pub cultural_description: Option, + pub corridor_pool: String, + pub geographic_sector: Option, + pub bulk_class_gate: Option, + pub production_ubiquity_gate: Option, + pub min_prosperity_bps: i64, + pub base_weight: i64, + pub weight_mods: Option, + pub zone_affinity: Option, + pub allow_tags: Option, + pub block_tags: Option, + pub era_scope: Option, + pub visual_bundle: Option, +} + +impl From for TraitTemplateDetail { + fn from(r: TraitTemplateDetailRow) -> Self { + Self { + tag: r.tag, + label: r.label, + cultural_description: r.cultural_description, + corridor_pool: r.corridor_pool, + geographic_sector: r.geographic_sector, + bulk_class_gate: r.bulk_class_gate, + production_ubiquity_gate: r.production_ubiquity_gate, + min_prosperity_bps: r.min_prosperity_bps, + base_weight: r.base_weight, + weight_mods: r.weight_mods, + zone_affinity: r.zone_affinity, + allow_tags: r.allow_tags, + block_tags: r.block_tags, + era_scope: r.era_scope, + visual_bundle: r.visual_bundle, + } + } +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +fn error_response(kind: BrowseEntityKind, message: impl Into) -> BrowseResponse { + BrowseResponse { + kind, + status: BrowseStatus::Error(message.into()), + index: None, + detail: None, + } +} + +fn db_error_response(kind: BrowseEntityKind, e: BrowseReadError) -> BrowseResponse { + error_response(kind, e.to_string()) +} + +/// Serve one browse request (T-1131): dispatch on `(kind, query)` to the +/// matching `BrowseReader` read function, ignoring `filter_system_id` for +/// every kind except `Body` (only `Body`'s `Index` query honors it, per +/// D-254 §4 — see [`BrowseQuery::Index`] doc). +/// +/// `reader` absent (no DB opened at startup) is reported as `Error`, +/// matching `handle_city_names_request`'s "no city context reader" +/// convention. +pub fn handle_browse_request(req: &BrowseRequest, reader: Option<&BrowseReader>) -> BrowseResponse { + let Some(reader) = reader else { + return error_response(req.kind, "browse reader unavailable"); + }; + + match (req.kind, &req.query) { + // ─── Star systems ─────────────────────────────────────────────── + (BrowseEntityKind::StarSystem, BrowseQuery::Index { .. }) => { + match reader.index_star_systems() { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.system_id, + primary: r.display_name, + secondary: r.star_type, + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + (BrowseEntityKind::StarSystem, BrowseQuery::Detail { id }) => { + match reader.detail_star_system(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::StarSystem(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + + // ─── Bodies ────────────────────────────────────────────────────── + (BrowseEntityKind::Body, BrowseQuery::Index { filter_system_id }) => { + match reader.index_bodies(filter_system_id.as_deref()) { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.body_id, + primary: r.display_name, + secondary: Some(r.body_type), + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + (BrowseEntityKind::Body, BrowseQuery::Detail { id }) => match reader.detail_body(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::Body(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + }, + + // ─── Stations ──────────────────────────────────────────────────── + (BrowseEntityKind::Station, BrowseQuery::Index { .. }) => match reader.index_stations() { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.station_id, + primary: r.display_name, + secondary: Some(r.station_type), + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + }, + (BrowseEntityKind::Station, BrowseQuery::Detail { id }) => { + match reader.detail_station(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::Station(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + + // ─── Corporations ──────────────────────────────────────────────── + (BrowseEntityKind::Corporation, BrowseQuery::Index { .. }) => { + match reader.index_corporations() { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.corp_id, + primary: r.proper_name, + secondary: Some(r.corp_type), + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + (BrowseEntityKind::Corporation, BrowseQuery::Detail { id }) => { + match reader.detail_corporation(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::Corporation(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + + // ─── Commodities ───────────────────────────────────────────────── + (BrowseEntityKind::Commodity, BrowseQuery::Index { .. }) => { + match reader.index_commodities() { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.commodity_id, + primary: r.name, + secondary: Some(r.tier), + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + (BrowseEntityKind::Commodity, BrowseQuery::Detail { id }) => { + match reader.detail_commodity(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::Commodity(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + + // ─── Trait templates ───────────────────────────────────────────── + (BrowseEntityKind::TraitTemplate, BrowseQuery::Index { .. }) => { + match reader.index_trait_templates() { + Ok(rows) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: Some( + rows.into_iter() + .map(|r| BrowseIndexRow { + id: r.tag, + primary: r.label, + secondary: Some(r.corridor_pool), + }) + .collect(), + ), + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + (BrowseEntityKind::TraitTemplate, BrowseQuery::Detail { id }) => { + match reader.detail_trait_template(id) { + Ok(Some(row)) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::Ready, + index: None, + detail: Some(BrowseDetail::TraitTemplate(row.into())), + }, + Ok(None) => BrowseResponse { + kind: req.kind, + status: BrowseStatus::NotFound, + index: None, + detail: None, + }, + Err(e) => db_error_response(req.kind, e), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::browse_reader::tests::make_fixture_db; + + fn index_req(kind: BrowseEntityKind) -> BrowseRequest { + BrowseRequest { + browse: true, + kind, + query: BrowseQuery::Index { + filter_system_id: None, + }, + } + } + + fn detail_req(kind: BrowseEntityKind, id: &str) -> BrowseRequest { + BrowseRequest { + browse: true, + kind, + query: BrowseQuery::Detail { id: id.to_string() }, + } + } + + // ─── Wire encoding shape (pinned to stig-browser 2026-07-17: unit + // variants of BrowseEntityKind/BrowseStatus are bare strings; the + // data-carrying variants of BrowseQuery/BrowseStatus::Error are + // single-key maps — same convention as CascadeLayer/AtlasLayerStatus) ── + + #[test] + fn browse_entity_kind_encodes_as_bare_string() { + // All six BrowseEntityKind variants are unit variants -> bare + // MessagePack string, exactly like CascadeLayer::Topography does for + // AtlasLayerRequest.up_to. Round-trip through serde_json (which makes + // the shape human-inspectable) to assert this precisely, then confirm + // the msgpack round-trip separately. + let json = serde_json::to_string(&BrowseEntityKind::StarSystem).expect("encode"); + assert_eq!(json, "\"StarSystem\""); + } + + #[test] + fn browse_query_variants_encode_as_single_key_maps() { + // Both BrowseQuery variants carry fields -> single-key map on the + // wire, matching AtlasLayerStatus::Error(String)'s convention. + let index_json = serde_json::to_string(&BrowseQuery::Index { + filter_system_id: Some("GJ-1".to_string()), + }) + .expect("encode"); + assert_eq!(index_json, "{\"Index\":{\"filter_system_id\":\"GJ-1\"}}"); + + let detail_json = serde_json::to_string(&BrowseQuery::Detail { + id: "GJ1c".to_string(), + }) + .expect("encode"); + assert_eq!(detail_json, "{\"Detail\":{\"id\":\"GJ1c\"}}"); + } + + #[test] + fn browse_status_unit_variants_are_bare_strings_error_is_a_map() { + assert_eq!( + serde_json::to_string(&BrowseStatus::Ready).unwrap(), + "\"Ready\"" + ); + assert_eq!( + serde_json::to_string(&BrowseStatus::NotFound).unwrap(), + "\"NotFound\"" + ); + assert_eq!( + serde_json::to_string(&BrowseStatus::Error("boom".to_string())).unwrap(), + "{\"Error\":\"boom\"}" + ); + } + + #[test] + fn browse_request_round_trips_msgpack() { + let req = index_req(BrowseEntityKind::Corporation); + let bytes = rmp_serde::to_vec_named(&req).expect("encode"); + let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode"); + assert!(decoded.browse); + assert_eq!(decoded.kind, BrowseEntityKind::Corporation); + assert!(matches!(decoded.query, BrowseQuery::Index { .. })); + + let req = detail_req(BrowseEntityKind::Body, "GJ1c"); + let bytes = rmp_serde::to_vec_named(&req).expect("encode"); + let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode"); + match decoded.query { + BrowseQuery::Detail { id } => assert_eq!(id, "GJ1c"), + other => panic!("expected Detail, got {other:?}"), + } + } + + #[test] + fn browse_response_round_trips_msgpack() { + let resp = BrowseResponse { + kind: BrowseEntityKind::Commodity, + status: BrowseStatus::Ready, + index: Some(vec![BrowseIndexRow { + id: "fusion_fuel".into(), + primary: "Fusion Fuel".into(), + secondary: Some("intermediate".into()), + }]), + detail: None, + }; + let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); + let decoded: BrowseResponse = rmp_serde::from_slice(&bytes).expect("decode"); + assert_eq!(decoded.status, BrowseStatus::Ready); + assert_eq!(decoded.index.unwrap()[0].primary, "Fusion Fuel"); + } + + // ─── decode_inbound demux: BrowseRequest is disambiguated from the + // other four shapes, and doesn't collide with CityNamesRequest despite + // both being maps with no shared required field ────────────────────── + + #[test] + fn decode_inbound_routes_browse_requests() { + let req = detail_req(BrowseEntityKind::StarSystem, "GJ-1"); + let frame = rmp_serde::to_vec_named(&req).unwrap(); + let decoded = crate::bridge::decode_inbound(&frame).expect("decode"); + assert!(matches!( + decoded, + crate::bridge::Inbound::BrowseRequest(r) if r.kind == BrowseEntityKind::StarSystem + )); + } + + #[test] + fn decode_inbound_browse_does_not_collide_with_city_names() { + // A BrowseRequest frame has no body_id/city_names/star_map/up_to keys + // at all, so it must decode as BrowseRequest, never accidentally as + // one of the other four shapes. + let req = index_req(BrowseEntityKind::Station); + let frame = rmp_serde::to_vec_named(&req).unwrap(); + assert!( + rmp_serde::from_slice::(&frame) + .is_err() + ); + assert!( + rmp_serde::from_slice::(&frame).is_err() + ); + } + + // ─── Handler dispatch (D-254 §4 six kinds x index/detail) ──────────── + + #[test] + fn handle_browse_request_no_reader_is_error_for_every_kind() { + for kind in [ + BrowseEntityKind::StarSystem, + BrowseEntityKind::Body, + BrowseEntityKind::Station, + BrowseEntityKind::Corporation, + BrowseEntityKind::Commodity, + BrowseEntityKind::TraitTemplate, + ] { + let resp = handle_browse_request(&index_req(kind), None); + assert!( + matches!(resp.status, BrowseStatus::Error(_)), + "kind {kind:?} should error without a reader" + ); + assert!(resp.index.is_none()); + assert!(resp.detail.is_none()); + } + } + + #[test] + fn handle_browse_request_index_and_detail_for_every_kind() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + + // (kind, a known-good detail id) — proves both forms work for all + // six entity kinds end-to-end through the handler, not just the + // reader layer. + let cases: &[(BrowseEntityKind, &str)] = &[ + (BrowseEntityKind::StarSystem, "GJ-1"), + (BrowseEntityKind::Body, "GJ1c"), + (BrowseEntityKind::Station, "GJ1c-S1"), + (BrowseEntityKind::Corporation, "gate-corporation"), + (BrowseEntityKind::Commodity, "fusion_fuel"), + (BrowseEntityKind::TraitTemplate, "frontier_utilitarian"), + ]; + + for &(kind, id) in cases { + let index_resp = handle_browse_request(&index_req(kind), Some(&reader)); + assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})"); + assert_eq!(index_resp.kind, kind); + let rows = index_resp.index.expect("index populated"); + assert!(!rows.is_empty(), "index({kind:?}) should be non-empty"); + assert!( + rows.iter().any(|r| r.id == id), + "index({kind:?}) should contain id {id}" + ); + + let detail_resp = handle_browse_request(&detail_req(kind, id), Some(&reader)); + assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})"); + assert_eq!(detail_resp.kind, kind); + assert!( + detail_resp.detail.is_some(), + "detail({kind:?}) should be populated" + ); + } + } + + #[test] + fn handle_browse_request_body_index_honors_filter_system_id() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + + let req = BrowseRequest { + browse: true, + kind: BrowseEntityKind::Body, + query: BrowseQuery::Index { + filter_system_id: Some("GJ-1".to_string()), + }, + }; + let resp = handle_browse_request(&req, Some(&reader)); + let rows = resp.index.expect("index"); + assert_eq!(rows.len(), 2, "only GJ-1's bodies"); + assert!(rows.iter().all(|r| r.id != "GJ2b")); + } + + #[test] + fn handle_browse_request_detail_unknown_id_is_not_found_for_every_kind() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + + for kind in [ + BrowseEntityKind::StarSystem, + BrowseEntityKind::Body, + BrowseEntityKind::Station, + BrowseEntityKind::Corporation, + BrowseEntityKind::Commodity, + BrowseEntityKind::TraitTemplate, + ] { + let resp = handle_browse_request(&detail_req(kind, "no-such-id"), Some(&reader)); + assert_eq!( + resp.status, + BrowseStatus::NotFound, + "detail({kind:?}, unknown id) should be NotFound" + ); + assert!(resp.detail.is_none()); + } + } + + #[test] + fn handle_browse_request_index_on_kind_with_no_rows_is_ready_empty() { + // Fixture db's stations table has one row for every OTHER test, but a + // freshly created db with the table present and zero rows must still + // be Ready+empty, not an error — matches CityNamesStatus's + // "unknown/empty -> Ready, not NotFound" convention (see + // browse_reader::tests::index_stations_empty_table_is_empty_vec for + // the reader-layer proof; this is the handler-layer proof). + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + // trait_templates has exactly one row in the fixture; assert Ready + // with that one row rather than empty, to also prove non-empty index + // requests thread through status correctly (empty case already + // covered at the reader layer). + let resp = + handle_browse_request(&index_req(BrowseEntityKind::TraitTemplate), Some(&reader)); + assert_eq!(resp.status, BrowseStatus::Ready); + assert_eq!(resp.index.unwrap().len(), 1); + } + + #[test] + fn browse_detail_kind_and_field_shape_matches_dispatched_kind() { + // A StarSystem detail request must produce a BrowseDetail::StarSystem + // variant carrying that system's actual data — proves the per-kind + // BrowseDetail wrapping (not just the top-level status/kind) is wired + // correctly, not just that SOME detail came back. + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let resp = handle_browse_request( + &detail_req(BrowseEntityKind::StarSystem, "GJ-1"), + Some(&reader), + ); + match resp.detail { + Some(BrowseDetail::StarSystem(d)) => { + assert_eq!(d.system_id, "GJ-1"); + assert_eq!(d.proper_name.as_deref(), Some("Aldren")); + } + other => panic!("expected BrowseDetail::StarSystem, got {other:?}"), + } + } +} diff --git a/server/src/atlas/browse_reader.rs b/server/src/atlas/browse_reader.rs new file mode 100644 index 000000000..2bdab5158 --- /dev/null +++ b/server/src/atlas/browse_reader.rs @@ -0,0 +1,1287 @@ +//! Read-only `systems.db` access for the D-254 §4 data browser (T-1131). +//! +//! Mirrors [`crate::atlas::city_context_reader::CityContextReader`]'s +//! `open()`/`Mutex` shape exactly — this is "write five more +//! read functions" against a proven pattern, not a new one (D-254 §4: "the +//! server already has this dependency and this pattern; this ticket is +//! 'write five more read functions,' not 'introduce a new capability'"). +//! +//! Six entity kinds (D-254 §4 v1 scope), each with an index read (id + +//! display fields for the list screen) and a detail read (every column for +//! the full-row screen): +//! +//! 1. **Star systems** — `star_systems`, detail folds in `system_economy` / +//! `system_factions` / `system_culture` (small 1:1-joined tables). +//! 2. **Bodies** — `bodies`, index filterable by `system_id`. +//! 3. **Stations** — `stations`. +//! 4. **Corporations** — `corporations`, detail folds in `corp_presence` / +//! `corp_financial_state`. +//! 5. **Commodities** — `commodities`, detail folds in `production_chains` / +//! `chain_inputs` for that commodity. +//! 6. **Trait templates** — `trait_templates`. +//! +//! Deliberately excluded (D-254 §4): cascade-derived atlas geometry tables +//! (`atlas_cities`/`atlas_roads`/… — partially populated mid-Phase-4) and +//! event-log tables (`corp_lifecycle_events`/`system_history`/ +//! `historical_events` — wrong UI shape for list+detail). +//! +//! Read-only open, never a write path — `OpenFlags::SQLITE_OPEN_READ_ONLY`, +//! same as `CityContextReader::open`. No schema changes; every query below +//! reads existing tables as-is. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use rusqlite::{Connection, OpenFlags}; +use thiserror::Error; + +/// Errors that can occur while reading browse data. +#[derive(Debug, Error)] +pub enum BrowseReadError { + #[error("systems.db error: {0}")] + Db(String), +} + +// --------------------------------------------------------------------------- +// Index rows (one per kind — matches BrowseIndexRow's generic id/primary/ +// secondary shape 1:1, see browse_proxy.rs) +// --------------------------------------------------------------------------- + +/// One `star_systems` index row. +#[derive(Debug, Clone)] +pub struct StarSystemIndexRow { + pub system_id: String, + pub display_name: String, + pub star_type: Option, +} + +/// One `bodies` index row. +#[derive(Debug, Clone)] +pub struct BodyIndexRow { + pub body_id: String, + pub display_name: String, + pub body_type: String, +} + +/// One `stations` index row. +#[derive(Debug, Clone)] +pub struct StationIndexRow { + pub station_id: String, + pub display_name: String, + pub station_type: String, +} + +/// One `corporations` index row. +#[derive(Debug, Clone)] +pub struct CorporationIndexRow { + pub corp_id: String, + pub proper_name: String, + pub corp_type: String, +} + +/// One `commodities` index row. +#[derive(Debug, Clone)] +pub struct CommodityIndexRow { + pub commodity_id: String, + pub name: String, + pub tier: String, +} + +/// One `trait_templates` index row. +#[derive(Debug, Clone)] +pub struct TraitTemplateIndexRow { + pub tag: String, + pub label: String, + pub corridor_pool: String, +} + +// --------------------------------------------------------------------------- +// Detail rows (one per kind — every column the wire response carries) +// --------------------------------------------------------------------------- + +/// Full `star_systems` row + folded `system_economy` / `system_factions` / +/// `system_culture` (small 1:1 joined tables, D-254 §4). +#[derive(Debug, Clone, Default)] +pub struct StarSystemDetailRow { + pub system_id: String, + pub proper_name: Option, + pub system_name: Option, + pub star_type: Option, + pub spectral_class: Option, + pub dist_ly: Option, + pub geographic_sector: Option, + pub geographic_band: Option, + pub political_zone: Option, + pub habitable_planet_count: Option, + pub inhabited_planet_count: Option, + pub asteroid_belt: Option, + pub gas_giant: Option, + pub habitability_profile: Option, + pub earth_alignment: Option, + pub earth_proximity: Option, + pub earth_tension: Option, + pub stability_index: Option, + pub system_volatility: Option, + pub cultural_corridor: Option, + pub currency_zone: Option, + // system_economy + pub economic_tier: Option, + pub population: Option, + pub economic_base_primary: Option, + pub economic_base_secondary: Option, + // system_factions + pub governance_type: Option, + pub dominant_faction: Option, + // system_culture + pub cultural_register: Option, + pub atmospheric_tone: Option, + pub primary_archetype: Option, +} + +/// Full `bodies` row, as-is (D-254 §4: "Detail = the `bodies` row as-is"). +#[derive(Debug, Clone, Default)] +pub struct BodyDetailRow { + pub body_id: String, + pub system_id: String, + pub parent_body_id: Option, + pub body_type: String, + pub orbit_index: Option, + pub proper_name: Option, + pub mass_class: Option, + pub atmosphere: Option, + pub surface_gravity: Option, + pub orbital_period_days: Option, + pub rotation_period_hours: Option, + pub planet_class: Option, + pub hydrosphere: Option, + pub biosphere_class: Option, + pub inhabited: bool, + pub population: Option, + pub economic_role: Option, + pub founding_age_years: Option, + pub settlement_pattern: Option, + pub cultural_corridor: Option, + pub industrial_corridor: Option, + pub body_radius_km: Option, + pub axial_tilt_deg: Option, +} + +/// Full `stations` row, as-is. +#[derive(Debug, Clone, Default)] +pub struct StationDetailRow { + pub station_id: String, + pub system_id: String, + pub orbits_body_id: Option, + pub station_type: String, + pub proper_name: Option, + pub population: Option, + pub economic_role: Option, + pub governance_type: Option, + pub docking_class: Option, + pub has_gate_infrastructure: bool, + pub district_count: Option, +} + +/// One `corp_presence` location entry for a corporation's detail screen. +#[derive(Debug, Clone)] +pub struct CorpPresenceRow { + pub location_id: String, + pub location_type: String, + pub primary_operation: Option, +} + +/// Full `corporations` row + folded `corp_presence` (all locations) / +/// `corp_financial_state` (D-254 §4). +#[derive(Debug, Clone, Default)] +pub struct CorporationDetailRow { + pub corp_id: String, + pub proper_name: String, + pub corp_type: String, + pub scope: Option, + pub headquarters_system: Option, + pub headquarters_body: Option, + pub specialization: Option, + pub parent_corp: Option, + pub notes: Option, + pub behavioral_archetype: Option, + pub supply_chain_role: Option, + pub shadow_economy_access: bool, + pub corp_specialization: Option, + pub hq_placement: Option, + // corp_financial_state + pub health_metric: Option, + // corp_presence (all rows for this corp) + pub presence: Vec, +} + +/// One `chain_inputs` entry for a commodity's production-chain detail. +#[derive(Debug, Clone)] +pub struct ChainInputRow { + pub input_commodity_id: String, + pub quantity: f64, +} + +/// One `production_chains` row that outputs this commodity, with its inputs. +#[derive(Debug, Clone)] +pub struct ProductionChainRow { + pub chain_id: String, + pub output_quantity: f64, + pub location_bound: bool, + pub description: Option, + pub inputs: Vec, +} + +/// Full `commodities` row + folded `production_chains`/`chain_inputs` for +/// chains that output this commodity (D-254 §4). +#[derive(Debug, Clone, Default)] +pub struct CommodityDetailRow { + pub commodity_id: String, + pub name: String, + pub tier: String, + pub elasticity: String, + pub base_price: f64, + pub bulk_class: Option, + pub unit: Option, + pub production_ubiquity: Option, + pub demand_model: Option, + pub commission_certifiable: bool, + pub compact_contested: bool, + pub shadow_viable: bool, + pub panic_threshold_weeks: Option, + pub description: Option, + pub produced_by: Vec, +} + +/// Full `trait_templates` row, as-is. JSON-text columns (`bulk_class_gate`, +/// `production_ubiquity_gate`, `weight_mods`, `zone_affinity`, `allow_tags`, +/// `block_tags`, `visual_bundle`) are carried as opaque strings — parsing +/// them is a client-side concern if ever needed, not this proxy's job (see +/// `browse_proxy` module doc). +#[derive(Debug, Clone, Default)] +pub struct TraitTemplateDetailRow { + pub tag: String, + pub label: String, + pub cultural_description: Option, + pub corridor_pool: String, + pub geographic_sector: Option, + pub bulk_class_gate: Option, + pub production_ubiquity_gate: Option, + pub min_prosperity_bps: i64, + pub base_weight: i64, + pub weight_mods: Option, + pub zone_affinity: Option, + pub allow_tags: Option, + pub block_tags: Option, + pub era_scope: Option, + pub visual_bundle: Option, +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Reads the six D-254 §4 v1 browse entity kinds from `systems.db`. Holds a +/// read-only SQLite connection — same `Arc>` shape as +/// [`crate::atlas::city_context_reader::CityContextReader`]. +pub struct BrowseReader { + conn: Arc>, +} + +impl BrowseReader { + /// Open a read-only connection to `systems_db`. + pub fn open(systems_db: &Path) -> Result { + let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + fn lock(&self) -> Result, BrowseReadError> { + self.conn + .lock() + .map_err(|e| BrowseReadError::Db(format!("mutex poisoned: {e}"))) + } + + // ─── Star systems ──────────────────────────────────────────────────── + + /// Index of every `star_systems` row, ordered by `system_id`. + pub fn index_star_systems(&self) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT system_id, COALESCE(proper_name, system_name, system_id), star_type + FROM star_systems + ORDER BY system_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(StarSystemIndexRow { + system_id: row.get(0)?, + display_name: row.get(1)?, + star_type: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full detail for one star system, folding in `system_economy` / + /// `system_factions` / `system_culture` via LEFT JOIN (all three are + /// optional 1:1 tables — a system may lack any of them). + pub fn detail_star_system( + &self, + system_id: &str, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + ss.system_id, ss.proper_name, ss.system_name, ss.star_type, + ss.spectral_class, ss.dist_ly, ss.geographic_sector, ss.geographic_band, + ss.political_zone, ss.habitable_planet_count, ss.inhabited_planet_count, + ss.asteroid_belt, ss.gas_giant, ss.habitability_profile, + ss.earth_alignment, ss.earth_proximity, ss.earth_tension, + ss.stability_index, ss.system_volatility, ss.cultural_corridor, + ss.currency_zone, + se.economic_tier, se.population, se.economic_base_primary, se.economic_base_secondary, + sf.governance_type, sf.dominant_faction, + sc.cultural_register, sc.atmospheric_tone, sc.primary_archetype + FROM star_systems AS ss + LEFT JOIN system_economy AS se ON se.system_id = ss.system_id + LEFT JOIN system_factions AS sf ON sf.system_id = ss.system_id + LEFT JOIN system_culture AS sc ON sc.system_id = ss.system_id + WHERE ss.system_id = ?1", + [system_id], + |row| { + Ok(StarSystemDetailRow { + system_id: row.get(0)?, + proper_name: row.get(1)?, + system_name: row.get(2)?, + star_type: row.get(3)?, + spectral_class: row.get(4)?, + dist_ly: row.get(5)?, + geographic_sector: row.get(6)?, + geographic_band: row.get(7)?, + political_zone: row.get(8)?, + habitable_planet_count: row.get(9)?, + inhabited_planet_count: row.get(10)?, + asteroid_belt: row.get::<_, Option>(11)?.map(|v| v != 0), + gas_giant: row.get::<_, Option>(12)?.map(|v| v != 0), + habitability_profile: row.get(13)?, + earth_alignment: row.get(14)?, + earth_proximity: row.get(15)?, + earth_tension: row.get(16)?, + stability_index: row.get(17)?, + system_volatility: row.get(18)?, + cultural_corridor: row.get(19)?, + currency_zone: row.get(20)?, + economic_tier: row.get(21)?, + population: row.get(22)?, + economic_base_primary: row.get(23)?, + economic_base_secondary: row.get(24)?, + governance_type: row.get(25)?, + dominant_faction: row.get(26)?, + cultural_register: row.get(27)?, + atmospheric_tone: row.get(28)?, + primary_archetype: row.get(29)?, + }) + }, + ); + one_or_none(result) + } + + // ─── Bodies ─────────────────────────────────────────────────────────── + + /// Index of `bodies` rows, optionally filtered to one `system_id` + /// (D-254 §4: "Bodies, filterable by system"). `None` = every body. + pub fn index_bodies( + &self, + filter_system_id: Option<&str>, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT body_id, COALESCE(proper_name, body_id), body_type + FROM bodies + WHERE ?1 IS NULL OR system_id = ?1 + ORDER BY body_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([filter_system_id], |row| { + Ok(BodyIndexRow { + body_id: row.get(0)?, + display_name: row.get(1)?, + body_type: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full `bodies` row for one body. + pub fn detail_body(&self, body_id: &str) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + body_id, system_id, parent_body_id, body_type, orbit_index, proper_name, + mass_class, atmosphere, surface_gravity, orbital_period_days, + rotation_period_hours, planet_class, hydrosphere, biosphere_class, + inhabited, population, economic_role, founding_age_years, + settlement_pattern, cultural_corridor, industrial_corridor, + body_radius_km, axial_tilt_deg + FROM bodies + WHERE body_id = ?1", + [body_id], + |row| { + Ok(BodyDetailRow { + body_id: row.get(0)?, + system_id: row.get(1)?, + parent_body_id: row.get(2)?, + body_type: row.get(3)?, + orbit_index: row.get(4)?, + proper_name: row.get(5)?, + mass_class: row.get(6)?, + atmosphere: row.get(7)?, + surface_gravity: row.get(8)?, + orbital_period_days: row.get(9)?, + rotation_period_hours: row.get(10)?, + planet_class: row.get(11)?, + hydrosphere: row.get(12)?, + biosphere_class: row.get(13)?, + inhabited: row.get::<_, i64>(14)? != 0, + population: row.get(15)?, + economic_role: row.get(16)?, + founding_age_years: row.get(17)?, + settlement_pattern: row.get(18)?, + cultural_corridor: row.get(19)?, + industrial_corridor: row.get(20)?, + body_radius_km: row.get(21)?, + axial_tilt_deg: row.get(22)?, + }) + }, + ); + one_or_none(result) + } + + // ─── Stations ───────────────────────────────────────────────────────── + + /// Index of every `stations` row, ordered by `station_id`. + pub fn index_stations(&self) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT station_id, COALESCE(proper_name, station_id), station_type + FROM stations + ORDER BY station_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(StationIndexRow { + station_id: row.get(0)?, + display_name: row.get(1)?, + station_type: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full `stations` row for one station. + pub fn detail_station( + &self, + station_id: &str, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + station_id, system_id, orbits_body_id, station_type, proper_name, + population, economic_role, governance_type, docking_class, + has_gate_infrastructure, district_count + FROM stations + WHERE station_id = ?1", + [station_id], + |row| { + Ok(StationDetailRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get(5)?, + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, i64>(9)? != 0, + district_count: row.get(10)?, + }) + }, + ); + one_or_none(result) + } + + // ─── Corporations ───────────────────────────────────────────────────── + + /// Index of every `corporations` row, ordered by `corp_id`. + pub fn index_corporations(&self) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT corp_id, proper_name, corp_type + FROM corporations + ORDER BY corp_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(CorporationIndexRow { + corp_id: row.get(0)?, + proper_name: row.get(1)?, + corp_type: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full `corporations` row + folded `corp_presence` (all locations) / + /// `corp_financial_state` for one corp. + pub fn detail_corporation( + &self, + corp_id: &str, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + c.corp_id, c.proper_name, c.corp_type, c.scope, + c.headquarters_system, c.headquarters_body, c.specialization, + c.parent_corp, c.notes, c.behavioral_archetype, c.supply_chain_role, + c.shadow_economy_access, c.corp_specialization, c.hq_placement, + cfs.health_metric + FROM corporations AS c + LEFT JOIN corp_financial_state AS cfs ON cfs.corp_id = c.corp_id + WHERE c.corp_id = ?1", + [corp_id], + |row| { + Ok(CorporationDetailRow { + corp_id: row.get(0)?, + proper_name: row.get(1)?, + corp_type: row.get(2)?, + scope: row.get(3)?, + headquarters_system: row.get(4)?, + headquarters_body: row.get(5)?, + specialization: row.get(6)?, + parent_corp: row.get(7)?, + notes: row.get(8)?, + behavioral_archetype: row.get(9)?, + supply_chain_role: row.get(10)?, + shadow_economy_access: row.get::<_, i64>(11)? != 0, + corp_specialization: row.get(12)?, + hq_placement: row.get(13)?, + health_metric: row.get(14)?, + presence: Vec::new(), // filled below + }) + }, + ); + let Some(mut detail) = one_or_none(result)? else { + return Ok(None); + }; + + let mut stmt = conn + .prepare( + "SELECT location_id, location_type, primary_operation + FROM corp_presence + WHERE corp_id = ?1 + ORDER BY location_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([corp_id], |row| { + Ok(CorpPresenceRow { + location_id: row.get(0)?, + location_type: row.get(1)?, + primary_operation: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + detail.presence = collect(rows)?; + Ok(Some(detail)) + } + + // ─── Commodities ────────────────────────────────────────────────────── + + /// Index of every `commodities` row, ordered by `commodity_id`. + pub fn index_commodities(&self) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT commodity_id, name, tier + FROM commodities + ORDER BY commodity_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(CommodityIndexRow { + commodity_id: row.get(0)?, + name: row.get(1)?, + tier: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full `commodities` row + folded `production_chains`/`chain_inputs` + /// for chains that output this commodity. + pub fn detail_commodity( + &self, + commodity_id: &str, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + commodity_id, name, tier, elasticity, base_price, bulk_class, unit, + production_ubiquity, demand_model, commission_certifiable, + compact_contested, shadow_viable, panic_threshold_weeks, description + FROM commodities + WHERE commodity_id = ?1", + [commodity_id], + |row| { + Ok(CommodityDetailRow { + commodity_id: row.get(0)?, + name: row.get(1)?, + tier: row.get(2)?, + elasticity: row.get(3)?, + base_price: row.get(4)?, + bulk_class: row.get(5)?, + unit: row.get(6)?, + production_ubiquity: row.get(7)?, + demand_model: row.get(8)?, + commission_certifiable: row.get::<_, i64>(9)? != 0, + compact_contested: row.get::<_, i64>(10)? != 0, + shadow_viable: row.get::<_, i64>(11)? != 0, + panic_threshold_weeks: row.get(12)?, + description: row.get(13)?, + produced_by: Vec::new(), // filled below + }) + }, + ); + let Some(mut detail) = one_or_none(result)? else { + return Ok(None); + }; + + let mut chain_stmt = conn + .prepare( + "SELECT chain_id, output_quantity, location_bound, description + FROM production_chains + WHERE output_commodity_id = ?1 + ORDER BY chain_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let chain_rows: Vec<(String, f64, bool, Option)> = chain_stmt + .query_map([commodity_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, f64>(1)?, + row.get::<_, i64>(2)? != 0, + row.get::<_, Option>(3)?, + )) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))? + .collect::>() + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + + let mut input_stmt = conn + .prepare( + "SELECT input_commodity_id, quantity + FROM chain_inputs + WHERE chain_id = ?1 + ORDER BY input_commodity_id", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + + let mut chains = Vec::with_capacity(chain_rows.len()); + for (chain_id, output_quantity, location_bound, description) in chain_rows { + let input_rows = input_stmt + .query_map([&chain_id], |row| { + Ok(ChainInputRow { + input_commodity_id: row.get(0)?, + quantity: row.get(1)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let inputs = collect(input_rows)?; + chains.push(ProductionChainRow { + chain_id, + output_quantity, + location_bound, + description, + inputs, + }); + } + detail.produced_by = chains; + Ok(Some(detail)) + } + + // ─── Trait templates ────────────────────────────────────────────────── + + /// Index of every `trait_templates` row, ordered by `tag`. + pub fn index_trait_templates(&self) -> Result, BrowseReadError> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT tag, label, corridor_pool + FROM trait_templates + ORDER BY tag", + ) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(TraitTemplateIndexRow { + tag: row.get(0)?, + label: row.get(1)?, + corridor_pool: row.get(2)?, + }) + }) + .map_err(|e| BrowseReadError::Db(e.to_string()))?; + collect(rows) + } + + /// Full `trait_templates` row for one tag. + pub fn detail_trait_template( + &self, + tag: &str, + ) -> Result, BrowseReadError> { + let conn = self.lock()?; + let result = conn.query_row( + "SELECT + tag, label, cultural_description, corridor_pool, geographic_sector, + bulk_class_gate, production_ubiquity_gate, min_prosperity_bps, + base_weight, weight_mods, zone_affinity, allow_tags, block_tags, + era_scope, visual_bundle + FROM trait_templates + WHERE tag = ?1", + [tag], + |row| { + Ok(TraitTemplateDetailRow { + tag: row.get(0)?, + label: row.get(1)?, + cultural_description: row.get(2)?, + corridor_pool: row.get(3)?, + geographic_sector: row.get(4)?, + bulk_class_gate: row.get(5)?, + production_ubiquity_gate: row.get(6)?, + min_prosperity_bps: row.get(7)?, + base_weight: row.get(8)?, + weight_mods: row.get(9)?, + zone_affinity: row.get(10)?, + allow_tags: row.get(11)?, + block_tags: row.get(12)?, + era_scope: row.get(13)?, + visual_bundle: row.get(14)?, + }) + }, + ); + one_or_none(result) + } +} + +/// Collapse `rusqlite::Error::QueryReturnedNoRows` (a `query_row` on zero +/// matching rows) into `Ok(None)` — every detail read's "unknown id" +/// convention (mirrors `CityContextReader::is_sol_body`'s "unknown body -> +/// not Sol, caller's own not-found handling applies" pattern). Any other +/// error still propagates. +fn one_or_none(result: rusqlite::Result) -> Result, BrowseReadError> { + match result { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(BrowseReadError::Db(e.to_string())), + } +} + +/// Drain a `rusqlite` mapped-rows iterator into a `Vec`, converting any +/// per-row error into `BrowseReadError`. +fn collect(rows: impl Iterator>) -> Result, BrowseReadError> { + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| BrowseReadError::Db(e.to_string()))?); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Bevy resource wrapper +// --------------------------------------------------------------------------- + +/// Bevy `Resource` wrapper — `Res` in systems. Mirrors +/// `CityContextReaderResource` (T-1131). +#[derive(bevy_ecs::prelude::Resource)] +pub struct BrowseReaderResource(pub BrowseReader); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + /// Build a fixture `systems.db`-shaped SQLite file carrying the six v1 + /// browse tables plus their folded-in join partners, matching + /// `server/data/systems-schema.sql`'s DDL for exactly the columns this + /// reader touches. Shared by `browse_reader` and `browse_proxy` tests + /// (`pub(crate)` — see `browse_proxy::tests`). + pub(crate) fn make_fixture_db() -> std::path::PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_browse_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create fixture db"); + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, proper_name TEXT, system_name TEXT, + star_type TEXT, spectral_class TEXT, dist_ly REAL, + geographic_sector TEXT, geographic_band TEXT, political_zone TEXT, + habitable_planet_count INTEGER, inhabited_planet_count INTEGER, + asteroid_belt INTEGER, gas_giant INTEGER, habitability_profile TEXT, + earth_alignment TEXT, earth_proximity TEXT, earth_tension TEXT, + stability_index INTEGER, system_volatility TEXT, cultural_corridor TEXT, + currency_zone TEXT + ); + CREATE TABLE system_economy ( + system_id TEXT PRIMARY KEY, economic_tier INTEGER, population INTEGER, + economic_base_primary TEXT, economic_base_secondary TEXT + ); + CREATE TABLE system_factions ( + system_id TEXT PRIMARY KEY, governance_type TEXT, dominant_faction TEXT + ); + CREATE TABLE system_culture ( + system_id TEXT PRIMARY KEY, cultural_register TEXT, + atmospheric_tone TEXT, primary_archetype TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, parent_body_id TEXT, + body_type TEXT NOT NULL, orbit_index INTEGER, proper_name TEXT, + mass_class TEXT, atmosphere TEXT, surface_gravity REAL, + orbital_period_days REAL, rotation_period_hours REAL, planet_class TEXT, + hydrosphere TEXT, biosphere_class TEXT, + inhabited INTEGER NOT NULL DEFAULT 0, population INTEGER, + economic_role TEXT, founding_age_years INTEGER, settlement_pattern TEXT, + cultural_corridor TEXT, industrial_corridor TEXT, + body_radius_km REAL, axial_tilt_deg REAL + ); + CREATE TABLE stations ( + station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT, + station_type TEXT NOT NULL, proper_name TEXT, population INTEGER, + economic_role TEXT, governance_type TEXT, docking_class TEXT, + has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER + ); + CREATE TABLE corporations ( + corp_id TEXT PRIMARY KEY, proper_name TEXT NOT NULL, corp_type TEXT NOT NULL, + scope TEXT, headquarters_system TEXT, headquarters_body TEXT, + specialization TEXT, parent_corp TEXT, notes TEXT, + behavioral_archetype TEXT, supply_chain_role TEXT, + shadow_economy_access INTEGER DEFAULT 0, + corp_specialization TEXT, hq_placement TEXT + ); + CREATE TABLE corp_presence ( + corp_id TEXT NOT NULL, location_id TEXT NOT NULL, location_type TEXT NOT NULL, + primary_operation TEXT, PRIMARY KEY (corp_id, location_id) + ); + CREATE TABLE corp_financial_state ( + corp_id TEXT PRIMARY KEY, health_metric REAL NOT NULL DEFAULT 1.0 + ); + CREATE TABLE commodities ( + commodity_id TEXT PRIMARY KEY, name TEXT NOT NULL, tier TEXT NOT NULL, + elasticity TEXT NOT NULL, base_price REAL NOT NULL, bulk_class TEXT, + unit TEXT, production_ubiquity TEXT, demand_model TEXT, + commission_certifiable INTEGER DEFAULT 0, compact_contested INTEGER DEFAULT 0, + shadow_viable INTEGER DEFAULT 0, panic_threshold_weeks INTEGER DEFAULT 0, + description TEXT + ); + CREATE TABLE production_chains ( + chain_id TEXT PRIMARY KEY, output_commodity_id TEXT NOT NULL, + output_quantity REAL NOT NULL DEFAULT 1.0, location_bound INTEGER DEFAULT 0, + description TEXT + ); + CREATE TABLE chain_inputs ( + chain_id TEXT NOT NULL, input_commodity_id TEXT NOT NULL, quantity REAL NOT NULL, + PRIMARY KEY (chain_id, input_commodity_id) + ); + CREATE TABLE trait_templates ( + tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT, + corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT, + bulk_class_gate TEXT, production_ubiquity_gate TEXT, + min_prosperity_bps INTEGER NOT NULL DEFAULT 0, + base_weight INTEGER NOT NULL DEFAULT 10000, + weight_mods TEXT, zone_affinity TEXT, allow_tags TEXT, block_tags TEXT, + era_scope TEXT, visual_bundle TEXT + );", + ) + .expect("create fixture tables"); + + // ─── Star system: GJ-1, with all three folded tables populated ──── + conn.execute( + "INSERT INTO star_systems (system_id, proper_name, star_type, dist_ly, currency_zone) + VALUES ('GJ-1', 'Aldren', 'M', 12.4, 'TRACTUS_PRIMARY')", + [], + ) + .expect("insert star system"); + conn.execute( + "INSERT INTO system_economy (system_id, economic_tier, population) + VALUES ('GJ-1', 3, 40000)", + [], + ) + .expect("insert system_economy"); + conn.execute( + "INSERT INTO system_factions (system_id, governance_type, dominant_faction) + VALUES ('GJ-1', 'Assembly', 'The Assembly')", + [], + ) + .expect("insert system_factions"); + conn.execute( + "INSERT INTO system_culture (system_id, cultural_register, primary_archetype) + VALUES ('GJ-1', 'Formal', 'Frontier')", + [], + ) + .expect("insert system_culture"); + // A second system with NO folded rows — proves LEFT JOIN degrades to + // NULL/None rather than dropping the system entirely. + conn.execute( + "INSERT INTO star_systems (system_id, system_name, star_type) VALUES ('GJ-2', 'Bare', 'K')", + [], + ) + .expect("insert bare star system"); + + // ─── Body: GJ1c on GJ-1 ───────────────────────────────────────── + conn.execute( + "INSERT INTO bodies (body_id, system_id, body_type, proper_name, inhabited, population) + VALUES ('GJ1c', 'GJ-1', 'planet', 'Aldren Prime', 1, 12000)", + [], + ) + .expect("insert body"); + conn.execute( + "INSERT INTO bodies (body_id, system_id, body_type, inhabited) + VALUES ('GJ1d', 'GJ-1', 'moon', 0)", + [], + ) + .expect("insert second body"); + // A body on a DIFFERENT system, to prove filter_system_id excludes it. + conn.execute( + "INSERT INTO bodies (body_id, system_id, body_type, inhabited) + VALUES ('GJ2b', 'GJ-2', 'planet', 0)", + [], + ) + .expect("insert body on other system"); + + // ─── Station: GJ1c-S1 ─────────────────────────────────────────── + conn.execute( + "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, proper_name) + VALUES ('GJ1c-S1', 'GJ-1', 'GJ1c', 'commercial', 'Aldren Orbital')", + [], + ) + .expect("insert station"); + + // ─── Corporation: gate-corporation, with presence + financial state ─ + conn.execute( + "INSERT INTO corporations (corp_id, proper_name, corp_type, headquarters_system, shadow_economy_access) + VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'GJ-1', 0)", + [], + ) + .expect("insert corp"); + conn.execute( + "INSERT INTO corp_financial_state (corp_id, health_metric) VALUES ('gate-corporation', 0.85)", + [], + ) + .expect("insert corp financial state"); + conn.execute( + "INSERT INTO corp_presence (corp_id, location_id, location_type, primary_operation) + VALUES ('gate-corporation', 'GJ1c', 'body', 'logistics')", + [], + ) + .expect("insert corp presence 1"); + conn.execute( + "INSERT INTO corp_presence (corp_id, location_id, location_type, primary_operation) + VALUES ('gate-corporation', 'GJ1c-S1', 'station', 'transit_hub')", + [], + ) + .expect("insert corp presence 2"); + // A corp with NO presence/financial rows — proves the folds degrade + // to empty/None rather than failing. + conn.execute( + "INSERT INTO corporations (corp_id, proper_name, corp_type) + VALUES ('bare-corp', 'Bare Corp', 'independent')", + [], + ) + .expect("insert bare corp"); + + // ─── Commodity: fusion_fuel, with one production chain + inputs ─── + conn.execute( + "INSERT INTO commodities (commodity_id, name, tier, elasticity, base_price) + VALUES ('fusion_fuel', 'Fusion Fuel', 'intermediate', 'inelastic', 42.5)", + [], + ) + .expect("insert commodity"); + conn.execute( + "INSERT INTO commodities (commodity_id, name, tier, elasticity, base_price) + VALUES ('raw_ore', 'Raw Ore', 'raw', 'elastic', 5.0)", + [], + ) + .expect("insert input commodity"); + conn.execute( + "INSERT INTO production_chains (chain_id, output_commodity_id, output_quantity, description) + VALUES ('fusion_fuel_chain', 'fusion_fuel', 2.0, 'Refined from raw ore')", + [], + ) + .expect("insert production chain"); + conn.execute( + "INSERT INTO chain_inputs (chain_id, input_commodity_id, quantity) + VALUES ('fusion_fuel_chain', 'raw_ore', 3.0)", + [], + ) + .expect("insert chain input"); + + // ─── Trait template: frontier_utilitarian ────────────────────── + conn.execute( + "INSERT INTO trait_templates (tag, label, corridor_pool, min_prosperity_bps, base_weight) + VALUES ('frontier_utilitarian', 'Frontier Utilitarian', 'baseline', 1000, 10000)", + [], + ) + .expect("insert trait template"); + + drop(conn); + path + } + + // ─── Star systems ──────────────────────────────────────────────────── + + #[test] + fn index_star_systems_returns_all_ordered_by_id() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_star_systems().expect("index"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].system_id, "GJ-1"); + assert_eq!(rows[0].display_name, "Aldren"); + assert_eq!(rows[0].star_type.as_deref(), Some("M")); + assert_eq!(rows[1].system_id, "GJ-2"); + // No proper_name -> falls back to system_name. + assert_eq!(rows[1].display_name, "Bare"); + } + + #[test] + fn detail_star_system_folds_all_three_joined_tables() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_star_system("GJ-1") + .expect("detail") + .expect("row present"); + assert_eq!(detail.proper_name.as_deref(), Some("Aldren")); + assert_eq!(detail.economic_tier, Some(3)); + assert_eq!(detail.population, Some(40000)); + assert_eq!(detail.dominant_faction.as_deref(), Some("The Assembly")); + assert_eq!(detail.primary_archetype.as_deref(), Some("Frontier")); + } + + #[test] + fn detail_star_system_missing_joined_rows_degrades_to_none() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_star_system("GJ-2") + .expect("detail") + .expect("row present"); + assert_eq!(detail.system_name.as_deref(), Some("Bare")); + assert_eq!(detail.economic_tier, None); + assert_eq!(detail.dominant_faction, None); + assert_eq!(detail.primary_archetype, None); + } + + #[test] + fn detail_star_system_unknown_id_is_none() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + assert!(reader + .detail_star_system("GJ-999") + .expect("detail") + .is_none()); + } + + // ─── Bodies ─────────────────────────────────────────────────────────── + + #[test] + fn index_bodies_unfiltered_returns_all() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_bodies(None).expect("index"); + assert_eq!(rows.len(), 3); + } + + #[test] + fn index_bodies_filtered_by_system_excludes_other_systems() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_bodies(Some("GJ-1")).expect("index"); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r.body_id != "GJ2b")); + } + + #[test] + fn detail_body_returns_full_row() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader.detail_body("GJ1c").expect("detail").expect("row"); + assert_eq!(detail.proper_name.as_deref(), Some("Aldren Prime")); + assert!(detail.inhabited); + assert_eq!(detail.population, Some(12000)); + } + + #[test] + fn detail_body_unknown_id_is_none() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + assert!(reader.detail_body("ghost").expect("detail").is_none()); + } + + // ─── Stations ───────────────────────────────────────────────────────── + + #[test] + fn index_and_detail_stations_round_trip() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_stations().expect("index"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].station_id, "GJ1c-S1"); + + let detail = reader + .detail_station("GJ1c-S1") + .expect("detail") + .expect("row"); + assert_eq!(detail.proper_name.as_deref(), Some("Aldren Orbital")); + assert_eq!(detail.orbits_body_id.as_deref(), Some("GJ1c")); + } + + #[test] + fn index_stations_empty_table_is_empty_vec() { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("sr_browse_empty_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create empty db"); + conn.execute_batch( + "CREATE TABLE stations ( + station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT, + station_type TEXT NOT NULL, proper_name TEXT, population INTEGER, + economic_role TEXT, governance_type TEXT, docking_class TEXT, + has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER + );", + ) + .expect("create empty stations table"); + drop(conn); + + let reader = BrowseReader::open(&path).expect("open"); + let rows = reader.index_stations().expect("index on empty table"); + assert!(rows.is_empty()); + } + + // ─── Corporations ───────────────────────────────────────────────────── + + #[test] + fn detail_corporation_folds_presence_and_financial_state() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_corporation("gate-corporation") + .expect("detail") + .expect("row"); + assert_eq!(detail.health_metric, Some(0.85)); + assert_eq!(detail.presence.len(), 2); + assert_eq!(detail.presence[0].location_id, "GJ1c"); + assert_eq!(detail.presence[1].location_id, "GJ1c-S1"); + } + + #[test] + fn detail_corporation_no_presence_or_financial_state_degrades_cleanly() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_corporation("bare-corp") + .expect("detail") + .expect("row"); + assert_eq!(detail.health_metric, None); + assert!(detail.presence.is_empty()); + } + + #[test] + fn index_corporations_returns_all() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_corporations().expect("index"); + assert_eq!(rows.len(), 2); + } + + // ─── Commodities ────────────────────────────────────────────────────── + + #[test] + fn detail_commodity_folds_production_chain_and_inputs() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_commodity("fusion_fuel") + .expect("detail") + .expect("row"); + assert_eq!(detail.produced_by.len(), 1); + assert_eq!(detail.produced_by[0].chain_id, "fusion_fuel_chain"); + assert_eq!(detail.produced_by[0].inputs.len(), 1); + assert_eq!( + detail.produced_by[0].inputs[0].input_commodity_id, + "raw_ore" + ); + assert_eq!(detail.produced_by[0].inputs[0].quantity, 3.0); + } + + #[test] + fn detail_commodity_no_production_chain_degrades_to_empty_vec() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let detail = reader + .detail_commodity("raw_ore") + .expect("detail") + .expect("row"); + assert!(detail.produced_by.is_empty()); + } + + // ─── Trait templates ────────────────────────────────────────────────── + + #[test] + fn index_and_detail_trait_templates_round_trip() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + let rows = reader.index_trait_templates().expect("index"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].tag, "frontier_utilitarian"); + + let detail = reader + .detail_trait_template("frontier_utilitarian") + .expect("detail") + .expect("row"); + assert_eq!(detail.label, "Frontier Utilitarian"); + assert_eq!(detail.min_prosperity_bps, 1000); + } + + #[test] + fn detail_trait_template_unknown_tag_is_none() { + let db = make_fixture_db(); + let reader = BrowseReader::open(&db).expect("open"); + assert!(reader + .detail_trait_template("no_such_tag") + .expect("detail") + .is_none()); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 7b3e206cd..3725d3834 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -9,6 +9,8 @@ pub mod believability; pub mod block_irregularity; pub mod body_params_reader; pub mod body_world_state; +pub mod browse_proxy; +pub mod browse_reader; pub mod cascade; pub mod chunk_context; pub mod city_context_reader; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 8ae60e2b1..ce4e2813f 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -19,6 +19,8 @@ use crate::atlas::atlas_data_proxy::{ use crate::atlas::attractor_matching::CityPlacement; use crate::atlas::body_params_reader::BodyParamsReaderResource; use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; +use crate::atlas::browse_proxy::handle_browse_request; +use crate::atlas::browse_reader::BrowseReaderResource; use crate::atlas::city_context_reader::{ context_from_read_set, CityContextReaderResource, CityEconomicReadSet, }; @@ -41,8 +43,8 @@ use crate::atlas::trait_swerve::{ build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools, }; use crate::bridge::{ - AtlasRequestBuffer, AtlasResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, - StarMapRequestBuffer, StarMapResponseBuffer, + AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer, + CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer, }; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ @@ -68,7 +70,8 @@ impl Plugin for GenerationPlugin { .add_systems( Update, serve_city_names_requests.in_set(TickPhase::PreInput), - ); + ) + .add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput)); } } @@ -170,6 +173,34 @@ fn serve_city_names_requests( } } +/// Drain inbound data-browser requests and serve each through the proxy +/// (D-254 §4, T-1131): one of the six v1 registry-tier entity kinds, dispatched +/// to `BrowseReader` by `(kind, query)`. +/// +/// `pub` (unlike its atlas/star-map/city-names siblings, which stay private) +/// so `server/tests/bridge_tcp.rs`'s browse integration tests can drive the +/// TRUE full pipeline (demux -> receive_bridge_inputs -> BrowseRequestBuffer +/// -> serve_browse_requests -> BrowseResponseBuffer -> send_browse_responses) +/// end-to-end via `RunSystemOnce`, rather than bypassing this system the way +/// `reader_receives_tagged_star_map_response` bypasses `serve_star_map_requests` +/// (see that test's own doc comment) because it has no way to call it. +pub fn serve_browse_requests( + mut requests: ResMut, + mut responses: ResMut, + browse_reader: Option>, +) { + if requests.0.is_empty() { + return; + } + let reader = browse_reader.as_ref().map(|r| &r.0); + let pending: Vec<_> = requests.0.drain(..).collect(); + for (conn_id, req) in pending { + responses + .0 + .push((conn_id, handle_browse_request(&req, reader))); + } +} + /// Drain finished background work each tick and apply it to the cache (D-206). /// /// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index 0b0453187..1a3506cb6 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -4,6 +4,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge}; use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse}; +use crate::atlas::browse_proxy::BrowseResponse; use crate::atlas::layer_proxy::AtlasLayerResponse; use crate::bridge::framing::{read_framed, write_framed}; use std::fs; @@ -166,6 +167,16 @@ impl SimBridge for LocalBridge { write_framed(writer.get_mut(), &payload)?; Ok(()) } + + fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + write_framed(writer.get_mut(), &payload)?; + Ok(()) + } } impl Drop for LocalBridge { diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 980cb7d61..6a17eabd9 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -9,6 +9,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; use crate::atlas::atlas_data_proxy::{ CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse, }; +use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse}; use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse}; use crate::bridge::tcp::TcpBridge; @@ -63,9 +64,15 @@ pub enum BridgeError { /// frames that carry more than one shape's discriminators outright (PR #176 /// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte. /// -/// **Ceiling (D-225 trajectory):** four shapes is the practical limit of this -/// hand-rolled sniffing. The next new inbound shape must migrate the channel -/// to the tagged-envelope framing D-225 deferred — do not add a fifth probe. +/// **Ceiling (D-225 trajectory):** [`BrowseRequest`] (T-1131) is the FIFTH +/// map shape and, per the ceiling this doc already called at four, the last +/// one this hand-rolled scheme should ever carry — it stays at five only +/// because six entity kinds x two forms were folded into ONE new shape +/// (`browse`'s own internal `kind`/`query` enums pick the sub-behavior, +/// exactly as `AtlasLayerRequest.up_to: CascadeLayer` already does) rather +/// than added as twelve more top-level shapes. The next genuinely NEW +/// inbound shape (a sixth) must migrate the channel to the tagged-envelope +/// framing D-225 deferred — do not add a sixth probe. #[derive(Debug)] pub enum Inbound { /// A batch of player inputs (the gameplay path). @@ -76,6 +83,9 @@ pub enum Inbound { StarMapRequest(StarMapRequest), /// A per-body city-names request (T-949b). CityNamesRequest(CityNamesRequest), + /// A data-browser request — one of the six D-254 §4 v1 entity kinds + /// (T-1131). + BrowseRequest(BrowseRequest), } /// Key-presence probe for the defensive multi-shape check in @@ -88,12 +98,14 @@ struct ShapeProbe { up_to: Option, star_map: Option, city_names: Option, + browse: Option, } -/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries, -/// in order: `Vec` (array) → `AtlasLayerRequest` (map, +/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131). +/// Tries, in order: `Vec` (array) → `AtlasLayerRequest` (map, /// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) → -/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`). +/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`) → +/// `BrowseRequest` (map, `browse` discriminator). /// /// Mutual exclusivity is enforced, not assumed: no minimal well-formed /// instance of one shape satisfies another (see the [`Inbound`] doc), and a @@ -101,7 +113,7 @@ struct ShapeProbe { /// more than one shape — e.g. a buggy encoder emitting /// `{"star_map": true, "city_names": true, ...}` — instead of silently /// routing it to whichever shape is tried first (PR #176 review H1). A frame -/// satisfying none of the four shapes is a genuinely malformed input frame. +/// satisfying none of the five shapes is a genuinely malformed input frame. pub fn decode_inbound(payload: &[u8]) -> Result { if let Ok(inputs) = rmp_serde::from_slice::>(payload) { return Ok(Inbound::Inputs(inputs)); @@ -114,15 +126,20 @@ pub fn decode_inbound(payload: &[u8]) -> Result { let atlas = probe.body_id.is_some() && probe.up_to.is_some(); let star_map = probe.star_map.is_some(); let city_names = probe.city_names.is_some(); - let shapes = usize::from(atlas) + usize::from(star_map) + usize::from(city_names); + let browse = probe.browse.is_some(); + let shapes = usize::from(atlas) + + usize::from(star_map) + + usize::from(city_names) + + usize::from(browse); if shapes > 1 { let dump_len = payload.len().min(256); tracing::error!( - "inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}", + "inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}", shapes, atlas, star_map, city_names, + browse, dump_len, payload.len(), &payload[..dump_len] @@ -139,8 +156,11 @@ pub fn decode_inbound(payload: &[u8]) -> Result { if let Ok(req) = rmp_serde::from_slice::(payload) { return Ok(Inbound::StarMapRequest(req)); } - match rmp_serde::from_slice::(payload) { - Ok(req) => Ok(Inbound::CityNamesRequest(req)), + if let Ok(req) = rmp_serde::from_slice::(payload) { + return Ok(Inbound::CityNamesRequest(req)); + } + match rmp_serde::from_slice::(payload) { + Ok(req) => Ok(Inbound::BrowseRequest(req)), Err(e) => { let dump_len = payload.len().min(256); tracing::error!( @@ -189,6 +209,9 @@ pub trait SimBridge: Send + Sync { /// Send a city-names response to the client (T-949b). fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>; + + /// Send a browse response to the client (T-1131). + fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>; } /// Identifies one connection for response-tagging and role-lookup purposes @@ -433,6 +456,27 @@ impl BridgeResource { } } } + + /// Send a browse response to exactly the connection that requested it + /// (T-1131 — same per-connection routing D-254 §2 established for + /// atlas/star-map/city-names). + pub fn send_browse_response_to( + &self, + id: ConnectionId, + resp: &BrowseResponse, + ) -> Result<(), BridgeError> { + match self.connection(id) { + Some(c) => c.bridge.send_browse_response(resp), + None => { + tracing::debug!( + "browse response for {:?} dropped — connection {:?} no longer present", + resp.kind, + id + ); + Ok(()) + } + } + } } /// Tracks whether the protocol handshake has been sent (#555). @@ -484,6 +528,7 @@ pub fn receive_bridge_inputs( mut atlas_requests: ResMut, mut star_map_requests: ResMut, mut city_names_requests: ResMut, + mut browse_requests: ResMut, time: Option>, ) { let Some(mut bridge) = bridge else { return }; @@ -526,6 +571,9 @@ pub fn receive_bridge_inputs( Ok(Some(Inbound::CityNamesRequest(req))) => { city_names_requests.0.push((player_id, req)); } + Ok(Some(Inbound::BrowseRequest(req))) => { + browse_requests.0.push((player_id, req)); + } // No complete frame ready — the backlog is drained. Ok(None) => break, Err(BridgeError::Disconnected) => { @@ -637,6 +685,9 @@ pub fn receive_bridge_inputs( Ok(Some(Inbound::CityNamesRequest(req))) => { city_names_requests.0.push((reader_id, req)); } + Ok(Some(Inbound::BrowseRequest(req))) => { + browse_requests.0.push((reader_id, req)); + } Ok(None) => break, Err(BridgeError::Disconnected) => { tracing::info!("Reader connection {:?} disconnected", reader_id); @@ -851,6 +902,36 @@ pub fn send_city_names_responses( } } +/// Inbound data-browser requests routed off the bridge (T-1131), drained by +/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2). +#[derive(Resource, Default)] +pub struct BrowseRequestBuffer(pub Vec<(ConnectionId, BrowseRequest)>); + +/// Outbound browse responses, filled by the proxy serve system and flushed +/// to the client in `PostSnapshot` (T-1131). Connection-tagged (D-254 §2). +#[derive(Resource, Default)] +pub struct BrowseResponseBuffer(pub Vec<(ConnectionId, BrowseResponse)>); + +/// Flush buffered browse responses to their requesting connections (T-1131 — +/// same per-connection routing D-254 §2 established for +/// atlas/star-map/city-names). A failed send is logged but not fatal. +pub fn send_browse_responses( + bridge: Option>, + mut buffer: ResMut, +) { + let Some(bridge) = bridge else { return }; + for (id, resp) in buffer.0.drain(..) { + if let Err(e) = bridge.send_browse_response_to(id, &resp) { + tracing::warn!( + "failed to send browse response for {:?} to {:?}: {}", + resp.kind, + id, + e + ); + } + } +} + /// Holds the server's TCP listener for accepting connections AFTER the /// first Player connection (D-254 §2, T-1130). /// @@ -1008,6 +1089,8 @@ impl Plugin for BridgePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() // Multi-connection accept-loop (D-254 §2, T-1130) — must run @@ -1032,6 +1115,10 @@ impl Plugin for BridgePlugin { Update, send_city_names_responses.in_set(TickPhase::PostSnapshot), ) + .add_systems( + Update, + send_browse_responses.in_set(TickPhase::PostSnapshot), + ) // Debug commands — Snapshot phase .add_systems( Update, diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index ecf3012b4..b9bad55ec 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -5,6 +5,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge}; use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse}; +use crate::atlas::browse_proxy::BrowseResponse; use crate::atlas::layer_proxy::AtlasLayerResponse; use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator}; use std::io::BufWriter; @@ -313,6 +314,20 @@ impl SimBridge for TcpBridge { result?; Ok(()) } + + fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError> { + let payload = rmp_serde::to_vec_named(resp)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + let stream = writer.get_mut(); + stream.set_nonblocking(false).map_err(BridgeError::Io)?; + let result = write_framed(stream, &payload); + stream.set_nonblocking(true).map_err(BridgeError::Io)?; + result?; + Ok(()) + } } /// A connection that has been TCP-accepted but has not yet completed the diff --git a/server/src/main.rs b/server/src/main.rs index f547a71e3..f27158c64 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -253,6 +253,25 @@ fn main() { ), } + // Data browser reader (D-254 §4, T-1131): read-only access to the six v1 + // registry-tier entity kinds (star systems, bodies, stations, + // corporations, commodities, trait templates) for the companion app's + // browse UI. Absent -> BrowseRequests are answered with an Error status + // per request rather than a hard failure (matches every other reader's + // "unavailable, log + degrade" convention below). + match settled_reach_server::atlas::browse_reader::BrowseReader::open(&systems_db_path) { + Ok(reader) => { + tracing::info!("Browse reader opened: {:?}", systems_db_path); + app.insert_resource( + settled_reach_server::atlas::browse_reader::BrowseReaderResource(reader), + ); + } + Err(e) => tracing::warn!( + "Browse reader unavailable ({}). Browse requests will error.", + e + ), + } + // Body physical params reader for DistrictProfile carrier layer (T-1032, D-239 §1, D-240): // reads hydrosphere / atmosphere / planet_class on a cache miss so the Rayon // cascade work item stays DB-free (D-225 pattern). diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 359a9efce..53497669c 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -1,9 +1,12 @@ //! Integration tests for TcpBridge over TCP localhost (D-030 Layer 2: IPC roundtrip). +use settled_reach_server::atlas::browse_proxy::{ + BrowseEntityKind, BrowseQuery, BrowseRequest, BrowseResponse, BrowseStatus, +}; use settled_reach_server::bridge::framing::{read_framed, write_framed}; use settled_reach_server::bridge::tcp::TcpBridge; use settled_reach_server::bridge::types::*; -use settled_reach_server::bridge::{Inbound, SimBridge}; +use settled_reach_server::bridge::{BridgeResource, Inbound, SimBridge}; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::net::{TcpListener, TcpStream}; use std::thread; @@ -338,8 +341,8 @@ fn single_tick_drains_all_ready_inbound_frames() { use settled_reach_server::atlas::cascade::CascadeLayer; use settled_reach_server::atlas::layer_proxy::AtlasLayerRequest; use settled_reach_server::bridge::{ - receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, CityNamesRequestBuffer, - HandshakeState, ServerRunning, StarMapRequestBuffer, + receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, BrowseRequestBuffer, + CityNamesRequestBuffer, HandshakeState, ServerRunning, StarMapRequestBuffer, }; use settled_reach_server::simulation::input::InputQueue; @@ -395,6 +398,7 @@ fn single_tick_drains_all_ready_inbound_frames() { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world .run_system_once(receive_bridge_inputs) @@ -489,9 +493,10 @@ fn drive_accept_loop_until( /// buffers), bound to a fresh OS-assigned port. fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr) { use settled_reach_server::bridge::{ - AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, CityNamesRequestBuffer, - CityNamesResponseBuffer, ConnectionListener, HandshakeState, PendingConnections, - ServerRunning, SnapshotBuffer, StarMapRequestBuffer, StarMapResponseBuffer, + AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, BrowseRequestBuffer, + BrowseResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, ConnectionListener, + HandshakeState, PendingConnections, ServerRunning, SnapshotBuffer, StarMapRequestBuffer, + StarMapResponseBuffer, }; use settled_reach_server::simulation::input::InputQueue; @@ -515,6 +520,8 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); + world.init_resource::(); world.init_resource::(); (world, addr) } @@ -898,6 +905,492 @@ fn second_player_attempt_is_cleanly_rejected() { ); } +// ───────────────────────────────────────────────────────────────────────── +// T-1131: data browser (D-254 §4) end-to-end over the real TCP wire. +// +// These tests exercise the FULL pipeline — demux (decode_inbound) -> +// receive_bridge_inputs (drain + connection-tag) -> BrowseRequestBuffer -> +// serve_browse_requests (the atlas-plugin proxy system) -> BrowseResponseBuffer +// -> send_browse_responses -> the socket — not just the buffer-push shortcut +// `reader_receives_tagged_star_map_response` uses above, since T-1131's own +// ticket text calls for the connection-tagging proof specifically over +// requests that actually round-trip through the demux. +// ───────────────────────────────────────────────────────────────────────── + +/// Build a fixture `systems.db`-shaped file with one row in each of the six +/// v1 browse tables, wired as a `BrowseReaderResource` into `world`. A +/// self-contained minimal fixture local to THIS file — `browse_reader`'s own +/// (larger, column-exhaustive) fixture lives behind `#[cfg(test)]` in the +/// library crate, which is only compiled for `cargo test --lib`, not for a +/// separate integration-test binary linking against the built library (a +/// cross-crate `#[cfg(test)]` visibility boundary — confirmed by attempting +/// the reuse first). This file only needs to prove the WIRE plumbing (demux +/// -> serve -> send), not the SQL correctness `browse_reader`'s own unit +/// tests already verify column-by-column, so a minimal one-row-per-table +/// fixture is the right scope here, not a duplicate of the exhaustive one. +fn wire_browse_reader(world: &mut bevy_ecs::world::World) { + use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource}; + + let db_path = std::env::temp_dir().join(format!( + "sr_browse_tcp_fixture_{}_{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).expect("create fixture db"); + conn.execute_batch( + "CREATE TABLE star_systems ( + system_id TEXT PRIMARY KEY, proper_name TEXT, system_name TEXT, + star_type TEXT, spectral_class TEXT, dist_ly REAL, + geographic_sector TEXT, geographic_band TEXT, political_zone TEXT, + habitable_planet_count INTEGER, inhabited_planet_count INTEGER, + asteroid_belt INTEGER, gas_giant INTEGER, habitability_profile TEXT, + earth_alignment TEXT, earth_proximity TEXT, earth_tension TEXT, + stability_index INTEGER, system_volatility TEXT, cultural_corridor TEXT, + currency_zone TEXT + ); + CREATE TABLE system_economy ( + system_id TEXT PRIMARY KEY, economic_tier INTEGER, population INTEGER, + economic_base_primary TEXT, economic_base_secondary TEXT + ); + CREATE TABLE system_factions ( + system_id TEXT PRIMARY KEY, governance_type TEXT, dominant_faction TEXT + ); + CREATE TABLE system_culture ( + system_id TEXT PRIMARY KEY, cultural_register TEXT, + atmospheric_tone TEXT, primary_archetype TEXT + ); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, parent_body_id TEXT, + body_type TEXT NOT NULL, orbit_index INTEGER, proper_name TEXT, + mass_class TEXT, atmosphere TEXT, surface_gravity REAL, + orbital_period_days REAL, rotation_period_hours REAL, planet_class TEXT, + hydrosphere TEXT, biosphere_class TEXT, inhabited INTEGER NOT NULL DEFAULT 0, + population INTEGER, economic_role TEXT, founding_age_years INTEGER, + settlement_pattern TEXT, cultural_corridor TEXT, industrial_corridor TEXT, + body_radius_km REAL, axial_tilt_deg REAL + ); + CREATE TABLE stations ( + station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT, + station_type TEXT NOT NULL, proper_name TEXT, population INTEGER, + economic_role TEXT, governance_type TEXT, docking_class TEXT, + has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER + ); + CREATE TABLE corporations ( + corp_id TEXT PRIMARY KEY, proper_name TEXT NOT NULL, corp_type TEXT NOT NULL, + scope TEXT, headquarters_system TEXT, headquarters_body TEXT, + specialization TEXT, parent_corp TEXT, notes TEXT, behavioral_archetype TEXT, + supply_chain_role TEXT, shadow_economy_access INTEGER DEFAULT 0, + corp_specialization TEXT, hq_placement TEXT + ); + CREATE TABLE corp_presence ( + corp_id TEXT NOT NULL, location_id TEXT NOT NULL, location_type TEXT NOT NULL, + primary_operation TEXT, PRIMARY KEY (corp_id, location_id) + ); + CREATE TABLE corp_financial_state (corp_id TEXT PRIMARY KEY, health_metric REAL NOT NULL DEFAULT 1.0); + CREATE TABLE commodities ( + commodity_id TEXT PRIMARY KEY, name TEXT NOT NULL, tier TEXT NOT NULL, + elasticity TEXT NOT NULL, base_price REAL NOT NULL, bulk_class TEXT, + unit TEXT, production_ubiquity TEXT, demand_model TEXT, + commission_certifiable INTEGER DEFAULT 0, compact_contested INTEGER DEFAULT 0, + shadow_viable INTEGER DEFAULT 0, panic_threshold_weeks INTEGER DEFAULT 0, description TEXT + ); + CREATE TABLE production_chains ( + chain_id TEXT PRIMARY KEY, output_commodity_id TEXT NOT NULL, + output_quantity REAL NOT NULL DEFAULT 1.0, location_bound INTEGER DEFAULT 0, description TEXT + ); + CREATE TABLE chain_inputs ( + chain_id TEXT NOT NULL, input_commodity_id TEXT NOT NULL, quantity REAL NOT NULL, + PRIMARY KEY (chain_id, input_commodity_id) + ); + CREATE TABLE trait_templates ( + tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT, + corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT, + bulk_class_gate TEXT, production_ubiquity_gate TEXT, + min_prosperity_bps INTEGER NOT NULL DEFAULT 0, base_weight INTEGER NOT NULL DEFAULT 10000, + weight_mods TEXT, zone_affinity TEXT, allow_tags TEXT, block_tags TEXT, + era_scope TEXT, visual_bundle TEXT + );", + ) + .expect("create fixture tables"); + + conn.execute( + "INSERT INTO star_systems (system_id, proper_name, star_type) VALUES ('GJ-1', 'Aldren', 'M')", + [], + ) + .expect("insert star system"); + conn.execute( + "INSERT INTO bodies (body_id, system_id, body_type, proper_name, inhabited) + VALUES ('GJ1c', 'GJ-1', 'planet', 'Aldren Prime', 1)", + [], + ) + .expect("insert body"); + conn.execute( + "INSERT INTO stations (station_id, system_id, station_type, proper_name) + VALUES ('GJ1c-S1', 'GJ-1', 'commercial', 'Aldren Orbital')", + [], + ) + .expect("insert station"); + conn.execute( + "INSERT INTO corporations (corp_id, proper_name, corp_type, headquarters_system) + VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'GJ-1')", + [], + ) + .expect("insert corp"); + conn.execute( + "INSERT INTO commodities (commodity_id, name, tier, elasticity, base_price) + VALUES ('fusion_fuel', 'Fusion Fuel', 'intermediate', 'inelastic', 42.5)", + [], + ) + .expect("insert commodity"); + conn.execute( + "INSERT INTO trait_templates (tag, label) VALUES ('frontier_utilitarian', 'Frontier Utilitarian')", + [], + ) + .expect("insert trait template"); + } + + let reader = BrowseReader::open(&db_path).expect("open fixture browse db"); + world.insert_resource(BrowseReaderResource(reader)); +} + +/// Drive one full server tick's worth of browse plumbing: `receive_bridge_inputs` +/// (drains the socket into `BrowseRequestBuffer`, connection-tagged), +/// `serve_browse_requests` (the atlas-plugin proxy — reads `BrowseReaderResource`, +/// fills `BrowseResponseBuffer`), and `send_browse_responses` (flushes back +/// out to the originating connection). Matches how `BridgePlugin` + +/// `GenerationPlugin` actually schedule these three systems in `PreInput`/ +/// `PostSnapshot`, just run directly rather than through a full `App`. +fn drive_browse_tick(world: &mut bevy_ecs::world::World) { + use bevy_ecs::system::RunSystemOnce; + use settled_reach_server::atlas::plugin::serve_browse_requests; + use settled_reach_server::bridge::{receive_bridge_inputs, send_browse_responses}; + + world + .run_system_once(receive_bridge_inputs) + .expect("receive_bridge_inputs failed to run"); + world + .run_system_once(serve_browse_requests) + .expect("serve_browse_requests failed to run"); + world + .run_system_once(send_browse_responses) + .expect("send_browse_responses failed to run"); +} + +/// Drive `drive_browse_tick` repeatedly until a framed `BrowseResponse` +/// arrives on `stream`, or a wall-clock deadline expires (this file's +/// established hardening pattern — see `drive_accept_loop_until`/ +/// `collect_input_batches` — rather than a single tick or a fixed sleep). +/// +/// A single `drive_browse_tick` call races the client thread's write +/// actually landing in the server's non-blocking socket buffer before +/// `receive_bridge_inputs` polls it — `stream` here is a genuinely blocking +/// client-side socket (only the SERVER's accepted connections are toggled +/// non-blocking, by `TcpBridge::from_connected_stream`/D-254 §2's +/// accept-loop design), so `set_read_timeout` + a bounded retry loop is the +/// correct fix, not a longer single wait. +fn drive_browse_tick_until_response( + world: &mut bevy_ecs::world::World, + stream: &mut TcpStream, +) -> BrowseResponse { + stream + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .expect("failed to set read timeout"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + drive_browse_tick(world); + match read_framed(stream) { + Ok(Some(payload)) => { + return rmp_serde::from_slice(&payload).expect("failed to decode BrowseResponse"); + } + Ok(None) => panic!("unexpected EOF reading browse response"), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for a browse response" + ); + } + Err(e) => panic!("failed to read browse response frame: {e}"), + } + } +} + +fn send_browse_index_request(stream: &mut TcpStream, kind: BrowseEntityKind) { + let req = BrowseRequest { + browse: true, + kind, + query: BrowseQuery::Index { + filter_system_id: None, + }, + }; + let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest"); + write_framed(stream, &payload).expect("failed to write BrowseRequest"); +} + +fn send_browse_detail_request(stream: &mut TcpStream, kind: BrowseEntityKind, id: &str) { + let req = BrowseRequest { + browse: true, + kind, + query: BrowseQuery::Detail { id: id.to_string() }, + }; + let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest"); + write_framed(stream, &payload).expect("failed to write BrowseRequest"); +} + +/// T-1131: index + detail round-trip for all six D-254 §4 v1 entity kinds, +/// over one Reader connection, through the full demux->serve->send pipeline. +/// Parameterized-style — one test iterating all six kinds, per the ticket's +/// own suggested test shape. +#[test] +fn browse_index_and_detail_round_trip_for_all_six_kinds_over_tcp() { + let (mut world, addr) = new_multi_connection_world(); + wire_browse_reader(&mut world); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 1 + }); + let mut stream = client_handle.join().expect("client thread panicked"); + + let cases: &[(BrowseEntityKind, &str)] = &[ + (BrowseEntityKind::StarSystem, "GJ-1"), + (BrowseEntityKind::Body, "GJ1c"), + (BrowseEntityKind::Station, "GJ1c-S1"), + (BrowseEntityKind::Corporation, "gate-corporation"), + (BrowseEntityKind::Commodity, "fusion_fuel"), + (BrowseEntityKind::TraitTemplate, "frontier_utilitarian"), + ]; + + for &(kind, id) in cases { + send_browse_index_request(&mut stream, kind); + let index_resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(index_resp.kind, kind, "index response kind echo"); + assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})"); + let rows = index_resp.index.expect("index populated"); + assert!( + rows.iter().any(|r| r.id == id), + "index({kind:?}) should list {id}" + ); + + send_browse_detail_request(&mut stream, kind, id); + let detail_resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(detail_resp.kind, kind, "detail response kind echo"); + assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})"); + assert!( + detail_resp.detail.is_some(), + "detail({kind:?}) should be populated" + ); + } +} + +/// T-1131: a Reader (not just a Player) can send `BrowseRequest` and get a +/// `Ready` response — proves the permitted-message-matrix row (D-254 §2: +/// atlas/star-map/city-names/browse all say "yes" for Reader) actually holds +/// for the new request type specifically, not just by code inspection. +#[test] +fn reader_can_browse() { + let (mut world, addr) = new_multi_connection_world(); + wire_browse_reader(&mut world); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 1 + }); + let mut stream = client_handle.join().expect("client thread panicked"); + + send_browse_index_request(&mut stream, BrowseEntityKind::Commodity); + let resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(resp.status, BrowseStatus::Ready); +} + +/// T-1131: a Player (not just a Reader) can also browse — the permitted- +/// message-matrix row says "yes" for both roles, and this is the other half +/// of that proof. +#[test] +fn player_can_browse() { + let (mut world, addr) = new_multi_connection_world(); + wire_browse_reader(&mut world); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Player) + }); + drive_accept_loop_until(&mut world, |w| w.resource::().has_player()); + let mut stream = client_handle.join().expect("client thread panicked"); + + send_browse_index_request(&mut stream, BrowseEntityKind::TraitTemplate); + let resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(resp.status, BrowseStatus::Ready); +} + +/// T-1131: two concurrently-connected Readers each get back only their OWN +/// browse response — the connection-tagging plumbing D-254 §2 established +/// for atlas/star-map/city-names must hold for browse too, proven with two +/// simultaneously-live sockets asking for DIFFERENT things so a crossed wire +/// would be immediately visible (not just "a response arrived", but "the +/// WRONG response arrived"). +#[test] +fn two_readers_do_not_cross_browse_responses() { + let (mut world, addr) = new_multi_connection_world(); + wire_browse_reader(&mut world); + + let reader_a_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect (reader A)"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 1 + }); + let mut stream_a = reader_a_handle.join().expect("reader A thread panicked"); + + let reader_b_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect (reader B)"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 2 + }); + let mut stream_b = reader_b_handle.join().expect("reader B thread panicked"); + + // A asks about star systems, B asks about commodities — deliberately + // different kinds so a crossed response is unmistakable (not just "wrong + // data", but "wrong KIND"). Both requests are in flight before any + // response is expected, so `drive_browse_tick` is retried until BOTH + // streams have something readable (rather than draining/reading one + // stream to completion before the other's request has even arrived). + send_browse_index_request(&mut stream_a, BrowseEntityKind::StarSystem); + send_browse_index_request(&mut stream_b, BrowseEntityKind::Commodity); + + stream_a + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .expect("failed to set read timeout (A)"); + stream_b + .set_read_timeout(Some(std::time::Duration::from_millis(50))) + .expect("failed to set read timeout (B)"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut resp_a: Option = None; + let mut resp_b: Option = None; + while resp_a.is_none() || resp_b.is_none() { + drive_browse_tick(&mut world); + if resp_a.is_none() { + if let Ok(Some(payload)) = read_framed(&mut stream_a) { + resp_a = Some(rmp_serde::from_slice(&payload).expect("decode A")); + } + } + if resp_b.is_none() { + if let Ok(Some(payload)) = read_framed(&mut stream_b) { + resp_b = Some(rmp_serde::from_slice(&payload).expect("decode B")); + } + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for both readers' browse responses (A={}, B={})", + resp_a.is_some(), + resp_b.is_some() + ); + } + let resp_a = resp_a.expect("resp_a set by loop exit condition"); + let resp_b = resp_b.expect("resp_b set by loop exit condition"); + + assert_eq!( + resp_a.kind, + BrowseEntityKind::StarSystem, + "reader A must get back ITS OWN request's kind, not reader B's" + ); + assert_eq!( + resp_b.kind, + BrowseEntityKind::Commodity, + "reader B must get back ITS OWN request's kind, not reader A's" + ); +} + +/// T-1131: a `Detail` request for an id that doesn't exist in that kind's +/// table comes back `NotFound` over the wire (not an error, not a hang, not +/// silently dropped). +#[test] +fn browse_detail_unknown_id_is_not_found_over_tcp() { + let (mut world, addr) = new_multi_connection_world(); + wire_browse_reader(&mut world); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 1 + }); + let mut stream = client_handle.join().expect("client thread panicked"); + + send_browse_detail_request(&mut stream, BrowseEntityKind::Corporation, "no-such-corp"); + let resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(resp.status, BrowseStatus::NotFound); + assert!(resp.detail.is_none()); +} + +/// T-1131: an `Index` request against a kind with zero rows still comes back +/// `Ready` with an empty list, matching the existing city-names convention +/// (`CityNamesStatus`'s "unknown body -> Ready, empty" pattern) — over the +/// real wire, not just at the reader layer (see +/// `browse_reader::tests::index_stations_empty_table_is_empty_vec` for that +/// half of the proof). +#[test] +fn browse_index_empty_table_is_ready_with_empty_list_over_tcp() { + use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource}; + + let (mut world, addr) = new_multi_connection_world(); + + // A fixture db with the stations table present but genuinely empty — a + // distinct db from wire_browse_reader's shared fixture (which always has + // one station), built directly here so this test controls the "empty" + // precondition explicitly rather than relying on incidental fixture state. + let db_path = + std::env::temp_dir().join(format!("sr_browse_tcp_empty_{}.db", std::process::id())); + let _ = std::fs::remove_file(&db_path); + { + let conn = rusqlite::Connection::open(&db_path).expect("create empty fixture db"); + conn.execute_batch( + "CREATE TABLE stations ( + station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT, + station_type TEXT NOT NULL, proper_name TEXT, population INTEGER, + economic_role TEXT, governance_type TEXT, docking_class TEXT, + has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER + );", + ) + .expect("create empty stations table"); + } + let reader = BrowseReader::open(&db_path).expect("open empty fixture db"); + world.insert_resource(BrowseReaderResource(reader)); + + let client_handle = thread::spawn(move || { + let stream = TcpStream::connect(addr).expect("failed to connect"); + client_handshake(stream, ConnectionRole::Reader) + }); + drive_accept_loop_until(&mut world, |w| { + w.resource::().reader_count() == 1 + }); + let mut stream = client_handle.join().expect("client thread panicked"); + + send_browse_index_request(&mut stream, BrowseEntityKind::Station); + let resp = drive_browse_tick_until_response(&mut world, &mut stream); + assert_eq!(resp.status, BrowseStatus::Ready); + assert_eq!(resp.index.unwrap().len(), 0); + + let _ = std::fs::remove_file(&db_path); +} + /// Minimal `ObserverSnapshot` for the tests above — same shape as /// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized /// only by `tick` (the one field these tests assert on).