From d48d72fd31f5c0e093e43641dc9bec9c2da503ff Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 10:01:21 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(engine):=20T-1131=20browse=20data=20pr?= =?UTF-8?q?oxy=20=E2=80=94=20six=20entity=20kinds,=20index+detail,=20one?= =?UTF-8?q?=20wire=20envelope=20(D-254=20SS4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browse_reader.rs: BrowseReader on the CityContextReader::open() pattern — six index + six detail reads against systems.db (bodies filterable by containing system; system/corporation/commodity details fold their join partners). browse_proxy.rs: BrowseRequest{browse, kind, query} / BrowseResponse{kind, status, index, detail} wire types + dispatcher; BrowseIndexRow{id, primary, secondary} generic across kinds; BrowseDetail a per-kind enum of field-exhaustive structs. Demux: Inbound::BrowseRequest is the FIFTH map shape — deliberately the last; the doc's four-shape ceiling is re-pinned at five with rationale (six kinds x two forms folded into ONE envelope whose internal enums pick sub-behavior, the AtlasLayerRequest.up_to precedent, instead of twelve top-level shapes) and a hard rule that a sixth shape must migrate to the D-225 tagged-envelope framing. Served for BOTH roles, connection-tagged 1:1 in-order like atlas/starmap/citynames. serve_browse_requests pub so integration tests drive the true end-to-end pipeline. Wire-only per D-254 (T-949 precedent); v1 exclusions (cascade geometry, event logs) respected. 30 unit tests + 7 bridge_tcp integration tests (six-kind round-trip over real TCP, reader-can-browse, no crossed responses between two readers, unknown-id NotFound, empty-table Ready); 2 pre-existing tests updated for the new receive_bridge_inputs parameter. Co-Authored-By: Claude Fable 5 --- server/src/atlas/browse_proxy.rs | 1048 +++++++++++++++++++++++ server/src/atlas/browse_reader.rs | 1287 +++++++++++++++++++++++++++++ server/src/atlas/mod.rs | 2 + server/src/atlas/plugin.rs | 37 +- server/src/bridge/local.rs | 11 + server/src/bridge/mod.rs | 109 ++- server/src/bridge/tcp.rs | 15 + server/src/main.rs | 19 + server/tests/bridge_tcp.rs | 505 ++++++++++- 9 files changed, 3013 insertions(+), 20 deletions(-) create mode 100644 server/src/atlas/browse_proxy.rs create mode 100644 server/src/atlas/browse_reader.rs 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). -- 2.54.0 From 90c562a6a3af090c4b752804f16f15f2ee8b0be7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 10:24:39 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(ui):=20T-1133=20data=20browser=20?= =?UTF-8?q?=E2=80=94=20implant/browser=20app,=20six=20index+detail=20scree?= =?UTF-8?q?ns=20(D-254=20SS4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New implant app (app_path implant/browser, key B, fullscreen), sibling to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable index (live search-mode typing) -> generic detail, parameterized per kind, composed entirely from D-169 components. available_in_companion left unset (default true) — the app appears in the companion shell automatically via the generic-host seam, zero companion-side wiring. browser_adapter.gd is the sole home of literal wire field names: maps Oscar's BrowseResponse contract ({id, primary, secondary} index rows; BrowseDetail enum-as-single-key-map) to view models for all six kinds, folding join partners (system economy/factions/culture, corporation presence, commodity production chains with nested Leontief inputs). browse_protocol.gd split out of protocol.gd (max-file-lines); sim_bridge gains browse_response_received + request_browse_index/detail. Live-data catch: a present-but-NULL key (unnamed asteroid belt proper_name) bypasses Dictionary.get fallbacks and rendered '' — _display_or() null-vs-absent helper applied across all six detail mappers, 4 regression tests distinct from the absent-key cases. 43 gdUnit adapter cases; full suite 3074 green. Live-verified against a real server + real systems.db: all six kinds Ready with real row counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus ids. Spawn-mode DB resolution issue found during verification is pre-existing (cwd-relative data/systems.db) — server-side fix follows separately. Co-Authored-By: Claude Fable 5 --- client/scripts/autoloads/sim_bridge.gd | 52 +- client/scripts/protocol/browse_protocol.gd | 86 +++ client/scripts/protocol/protocol.gd | 49 +- client/tests/test_browser_adapter.gd | 502 ++++++++++++++++++ client/ui/implant/apps/browser/app.tres | 12 + .../implant/apps/browser/browser_adapter.gd | 438 +++++++++++++++ client/ui/implant/apps/browser/browser_app.gd | 159 ++++++ .../ui/implant/apps/browser/browser_app.tscn | 20 + .../apps/browser/screens/detail_screen.gd | 152 ++++++ .../apps/browser/screens/index_screen.gd | 253 +++++++++ .../apps/browser/screens/kind_menu_screen.gd | 87 +++ 11 files changed, 1801 insertions(+), 9 deletions(-) create mode 100644 client/scripts/protocol/browse_protocol.gd create mode 100644 client/tests/test_browser_adapter.gd create mode 100644 client/ui/implant/apps/browser/app.tres create mode 100644 client/ui/implant/apps/browser/browser_adapter.gd create mode 100644 client/ui/implant/apps/browser/browser_app.gd create mode 100644 client/ui/implant/apps/browser/browser_app.tscn create mode 100644 client/ui/implant/apps/browser/screens/detail_screen.gd create mode 100644 client/ui/implant/apps/browser/screens/index_screen.gd create mode 100644 client/ui/implant/apps/browser/screens/kind_menu_screen.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index aaaab9765..0f03e41b9 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -6,6 +6,7 @@ signal snapshot_received(snapshot: Dictionary) signal atlas_layers_received(response: Dictionary) signal star_map_received(response: Dictionary) # T-949: StarMapResponse signal city_names_received(response: Dictionary) # T-949: CityNamesResponse +signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse signal handshake_complete signal handshake_failed(reason: String) @@ -490,6 +491,50 @@ func request_city_names(body_id: String) -> void: ) +## Request one entity kind's index list from the browser proxy (T-1131/ +## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Index} +## frame; the response arrives via browse_response_received. filter_system_id +## only means anything for kind == "Body" (bodies filter by containing +## system) — pass "" (default) for every other kind or for an unfiltered +## Body index. No "wanted before connect" replay bookkeeping like +## request_star_map(): the browser screens call this on-demand while the app +## is open and connected (a live drill-down request, not a session-scoped +## dataset fetched once at boot), so a request issued before CONNECTED is +## simply not sent — the screen re-requests on its own enter()/refresh path +## the next time it's shown. +func request_browse_index(kind: String, filter_system_id: String = "") -> void: + if test_mode or _bridge == null or state != ConnectionState.CONNECTED: + return + var bytes := Protocol.encode_browse_request(kind, "Index", filter_system_id) + if bytes.is_empty(): + return + var err: int = _bridge.send_message(bytes) + if err != OK: + push_error( + "SimBridge: failed to send browse index request for %s: %s" % [kind, error_string(err)] + ) + + +## Request one entity's full detail row from the browser proxy (T-1131/ +## T-1133, D-254 §4). Live mode only — sends a BrowseRequest{kind, Detail} +## frame; the response arrives via browse_response_received. entity_id is the +## same "id" field an Index row returned for this kind. +func request_browse_detail(kind: String, entity_id: String) -> void: + if test_mode or _bridge == null or state != ConnectionState.CONNECTED: + return + var bytes := Protocol.encode_browse_request(kind, "Detail", entity_id) + if bytes.is_empty(): + return + var err: int = _bridge.send_message(bytes) + if err != OK: + push_error( + ( + "SimBridge: failed to send browse detail request for %s/%s: %s" + % [kind, entity_id, error_string(err)] + ) + ) + + # Poll for snapshot from simulation. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: @@ -516,8 +561,8 @@ func poll_snapshot() -> Variant: # so they aren't silently dropped when server ticks faster than client consumes. func receive_bytes(bytes: PackedByteArray) -> void: # Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/ - # citynames): all response kinds and snapshots are msgpack maps, told - # apart by field. + # citynames; T-1131/T-1133 adds browse): all response kinds and + # snapshots are msgpack maps, told apart by field. var inbound := Protocol.decode_inbound(bytes) if inbound.kind == "atlas": atlas_layers_received.emit(inbound.value) @@ -528,6 +573,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: if inbound.kind == "citynames": city_names_received.emit(inbound.value) return + if inbound.kind == "browse": + browse_response_received.emit(inbound.value) + return if inbound.kind != "snapshot": push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size()) return diff --git a/client/scripts/protocol/browse_protocol.gd b/client/scripts/protocol/browse_protocol.gd new file mode 100644 index 000000000..523f3388c --- /dev/null +++ b/client/scripts/protocol/browse_protocol.gd @@ -0,0 +1,86 @@ +class_name BrowseProtocol +## BrowseRequest/BrowseResponse wire codec (T-1131/T-1133, D-254 §4) — the +## six-entity data browser proxy (star systems, bodies, stations, +## corporations, commodities, trait templates). +## +## Split out of protocol.gd (not folded in) purely to stay under gdlint's +## max-file-lines — protocol.gd's own static functions (encode_browse_request/ +## browse_response_from_raw/decode_browse_response) delegate here. Every +## caller still goes through Protocol.* — this file is an implementation +## detail, not a second public API surface. +## +## `browse: true` is the mandatory discriminator field, same convention as +## star_map/city_names — decode_inbound's demux is at its documented +## practical ceiling of shape-sniffing probes, so BrowseRequest is ONE new +## shape carrying an internal kind/query split, not six. +## +## Wire shapes (confirmed against Oscar's T-1131 contract, 2026-07-17): +## kind: a BrowseEntityKind bare string ("StarSystem"|"Body"|"Station"| +## "Corporation"|"Commodity"|"TraitTemplate") — all-unit-variant enum, +## same shape as AtlasLayerRequest's up_to: CascadeLayer. +## query: BrowseQuery's two variants both carry named fields (struct +## variants), so each encodes as a single-key map: +## {"Index": {"filter_system_id": null | "GJ-1"}} +## {"Detail": {"id": "GJ1c"}} +## filter_system_id only means anything for kind == "Body" (bodies filter +## by containing SYSTEM, not by another body — renamed from +## filter_body_id on Oscar's side before shipping). +## status: BrowseStatus reuses the exact Ready|NotFound|Error(String) shape +## as AtlasLayerStatus/StarMapStatus/CityNamesStatus, decoded by the same +## bare-string-or-single-key-map rule protocol.gd's _decode_status_field +## already implements for those three — duplicated here as +## _decode_status_field (not shared via a Callable) to keep this file +## genuinely standalone; the shape is a stable, tiny, three-line rule. + + +static func _decode_status_field(status_raw: Variant) -> Dictionary: + if status_raw is String: + return {"status": status_raw, "error": ""} + if status_raw is Dictionary and status_raw.has("Error"): + return {"status": "Error", "error": str(status_raw["Error"])} + return {"status": "", "error": ""} + + +## Encode a BrowseRequest. `mp` is the loaded messagepack.gd module (passed +## in rather than reloaded here — protocol.gd's _mp() already owns that +## load()). kind is a BrowseEntityKind bare string. query_kind is "Index" or +## "Detail"; filter_value is the containing system_id (Index, Body only) or +## the entity's own id (Detail). +static func encode_browse_request( + mp, kind: String, query_kind: String, filter_value: String = "" +) -> PackedByteArray: + var query: Dictionary + if query_kind == "Detail": + query = {"Detail": {"id": filter_value}} + else: + query = { + "Index": {"filter_system_id": filter_value if not filter_value.is_empty() else null} + } + var msg := {"browse": true, "kind": kind, "query": query} + var result = mp.encode(msg) + if result.status != null: + push_error("BrowseProtocol: encode_browse_request failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## Build a BrowseResponse from an already-decoded raw value. Returns null +## unless it carries "kind" AND "status" AND ("index" or "detail") — "kind" + +## "status" alone doesn't disambiguate from AtlasLayerResponse (also has +## "status", never "kind"). "index"/"detail" pass through as raw decoded +## values — BrowserAdapter (client/ui/implant/apps/browser/browser_adapter.gd) +## owns interpreting their per-kind shape, matching how atlas_response_from_raw's +## layer1/district_grid/etc. fields also just pass through unshaped. +static func browse_response_from_raw(raw: Variant) -> Variant: + if not raw is Dictionary or not raw.has("kind") or not raw.has("status"): + return null + if not raw.has("index") and not raw.has("detail"): + return null + var decoded_status := _decode_status_field(raw.get("status")) + return { + "kind": str(raw.get("kind", "")), + "status": decoded_status["status"], + "error": decoded_status["error"], + "index": raw.get("index"), + "detail": raw.get("detail"), + } diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 8702fb8eb..1daf962cd 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -14,6 +14,17 @@ static func _mp(): return load("res://addons/messagepack/messagepack.gd") +## BrowseRequest/BrowseResponse codec (T-1131/T-1133) — factored into its own +## file to stay under gdlint's max-file-lines, load()'d here (not referenced +## as a bare class_name) per the autoload parse-order rule (CLAUDE.md): +## Protocol is an autoload, and autoload scripts compile before global +## class_name scripts are registered — a top-level class_name reference would +## fail to parse. By the time any caller actually runs (always post-boot), +## load() returns the already-cached resource with no reload cost. +static func _bp(): + return load("res://scripts/protocol/browse_protocol.gd") + + # -- Decode: bytes from server → GDScript types -------------------------------- @@ -898,14 +909,36 @@ static func decode_city_names_response(bytes: PackedByteArray) -> Variant: return city_names_response_from_raw(decode_raw(bytes)) +## Encode a BrowseRequest (T-1131/T-1133, D-254 §4) for the six-entity data +## browser proxy. Delegates to browse_protocol.gd — kept out of this file to +## stay under gdlint's max-file-lines; see that file for the full wire-shape +## rationale (discriminator field, kind/query split, filter_system_id). +static func encode_browse_request( + kind: String, query_kind: String, filter_value: String = "" +) -> PackedByteArray: + return _bp().encode_browse_request(_mp(), kind, query_kind, filter_value) + + +## Build a BrowseResponse from an already-decoded raw value. See +## browse_protocol.gd for the full shape/disambiguation rationale. +static func browse_response_from_raw(raw: Variant) -> Variant: + return _bp().browse_response_from_raw(raw) + + +## Decode a BrowseResponse from MessagePack bytes. See browse_response_from_raw. +static func decode_browse_response(bytes: PackedByteArray) -> Variant: + return browse_response_from_raw(decode_raw(bytes)) + + ## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/ -## citynames). Returns {kind, value} with kind "snapshot" | "atlas" | -## "starmap" | "citynames" | "unknown" — all four response kinds are msgpack -## maps, so they are told apart by field. Checked most-specific-first: -## StarMapResponse is the only kind with "data" and no "body_id"; -## CityNamesResponse is the only kind with "cities"; anything else carrying -## "status" is AtlasLayerResponse. Lets receive_bytes decode the frame ONCE -## and branch, instead of double-decoding the 20 Hz snapshot path. +## citynames; T-1131/T-1133 adds browse). Returns {kind, value}, kind one of +## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"unknown" — all msgpack +## maps, told apart by field, most-specific-first: StarMapResponse is the +## only kind with "data" and no "body_id"; CityNamesResponse the only one +## with "cities"; BrowseResponse the only one with its OWN "kind" field +## alongside "status"; anything else carrying "status" is AtlasLayerResponse. +## Lets receive_bytes decode the frame ONCE instead of double-decoding the +## 20 Hz snapshot path. static func decode_inbound(bytes: PackedByteArray) -> Dictionary: var raw = decode_raw(bytes) if not raw is Dictionary: @@ -916,6 +949,8 @@ static func decode_inbound(bytes: PackedByteArray) -> Dictionary: return {"kind": "starmap", "value": star_map_response_from_raw(raw)} if raw.has("cities"): return {"kind": "citynames", "value": city_names_response_from_raw(raw)} + if raw.has("kind") and raw.has("status"): + return {"kind": "browse", "value": browse_response_from_raw(raw)} if raw.has("status"): return {"kind": "atlas", "value": atlas_response_from_raw(raw)} return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)} diff --git a/client/tests/test_browser_adapter.gd b/client/tests/test_browser_adapter.gd new file mode 100644 index 000000000..2d37ed4c9 --- /dev/null +++ b/client/tests/test_browser_adapter.gd @@ -0,0 +1,502 @@ +class_name TestBrowserAdapter +extends GdUnitTestSuite +## Unit tests for browser_adapter.gd (T-1133, D-254 §4). +## +## Scope: the pure translation-layer logic between Oscar's T-1131 +## BrowseRequest/BrowseResponse wire contract and the browser screens' view- +## models — kind metadata, index row extraction/filtering, and detail +## payload unwrap + per-kind field mapping. All exercised with plain +## Dictionary literals standing in for decoded msgpack (no live server, no +## SimBridge, no protocol.gd round-trip needed to cover this logic). +## +## Deliberately NOT covered here: the actual wire encode/decode round-trip +## (that is protocol.gd's own concern — encode_browse_request/ +## browse_response_from_raw), and the screens' rendering of the adapter's +## output into ImplantPanel components (covered by the live make atlas +## verification per the ticket's tiering — the screens are thin renderers +## over what this adapter already validates). +const SCRIPT_PATH := "res://ui/implant/apps/browser/browser_adapter.gd" + + +func _adapter(): + return load(SCRIPT_PATH) + + +# ============================================================================= +# Kind metadata +# ============================================================================= + + +func test_kind_order_has_six_entries() -> void: + var a = _adapter() + assert_int(a.KIND_ORDER.size()).is_equal(6) + + +func test_kind_order_matches_d254_v1_list_order() -> void: + # D-254 §4's v1 entity scope list order: star systems, bodies, stations, + # corporations, commodities, trait catalog. + var a = _adapter() + assert_array(a.KIND_ORDER).is_equal( + [ + a.KIND_STAR_SYSTEM, + a.KIND_BODY, + a.KIND_STATION, + a.KIND_CORPORATION, + a.KIND_COMMODITY, + a.KIND_TRAIT_TEMPLATE, + ] + ) + + +func test_is_valid_kind_accepts_all_six() -> void: + var a = _adapter() + for kind: String in a.KIND_ORDER: + assert_bool(a.is_valid_kind(kind)).override_failure_message( + "%s should be a valid kind" % kind + ).is_true() + + +func test_is_valid_kind_rejects_unknown_string() -> void: + var a = _adapter() + assert_bool(a.is_valid_kind("Spaceship")).is_false() + assert_bool(a.is_valid_kind("")).is_false() + + +func test_kind_label_every_kind_has_a_non_empty_label() -> void: + var a = _adapter() + for kind: String in a.KIND_ORDER: + assert_str(a.kind_label(kind)).override_failure_message( + "%s must have a display label" % kind + ).is_not_empty() + + +func test_kind_label_unknown_kind_falls_back_to_the_kind_string() -> void: + var a = _adapter() + assert_str(a.kind_label("Nonsense")).is_equal("Nonsense") + + +# ============================================================================= +# index_rows — Ready vs. non-Ready responses +# ============================================================================= + + +func test_index_rows_ready_status_extracts_rows() -> void: + var a = _adapter() + var response := { + "kind": "StarSystem", + "status": "Ready", + "index": [ + {"id": "GJ-1", "primary": "GJ-1", "secondary": "K-type"}, + {"id": "GJ-2", "primary": "GJ-2", "secondary": "M-type"}, + ], + } + var rows: Array = a.index_rows(response) + assert_int(rows.size()).is_equal(2) + assert_str(rows[0]["id"]).is_equal("GJ-1") + assert_str(rows[0]["primary"]).is_equal("GJ-1") + assert_str(rows[0]["secondary"]).is_equal("K-type") + + +func test_index_rows_missing_secondary_defaults_to_empty_string_not_null() -> void: + var a = _adapter() + var response := { + "kind": "TraitTemplate", + "status": "Ready", + "index": [{"id": "tag_1", "primary": "Some Trait"}], + } + var rows: Array = a.index_rows(response) + assert_str(rows[0]["secondary"]).is_equal("") + + +func test_index_rows_error_status_returns_empty_array() -> void: + var a = _adapter() + var response := {"kind": "Body", "status": "Error", "index": null} + assert_array(a.index_rows(response)).is_empty() + + +func test_index_rows_not_found_status_returns_empty_array() -> void: + # D-254/Oscar's contract: Index requests never produce NotFound in + # practice, but the adapter must not crash or misbehave if one somehow + # arrives — treat any non-Ready status uniformly as "no rows". + var a = _adapter() + var response := {"kind": "Body", "status": "NotFound", "index": null} + assert_array(a.index_rows(response)).is_empty() + + +func test_index_rows_ready_but_index_field_not_an_array_returns_empty() -> void: + var a = _adapter() + var response := {"kind": "Body", "status": "Ready", "index": null} + assert_array(a.index_rows(response)).is_empty() + + +func test_index_rows_ready_with_genuinely_empty_list_returns_empty_array() -> void: + # The "unknown body -> Ready with empty list" convention (Oscar's + # message) must round-trip cleanly, not be conflated with an error. + var a = _adapter() + var response := {"kind": "Body", "status": "Ready", "index": []} + assert_array(a.index_rows(response)).is_empty() + + +# ============================================================================= +# filter_rows — live search box +# ============================================================================= + + +func _sample_rows() -> Array: + return [ + {"id": "gate-corporation", "primary": "Gate Corporation", "secondary": "corporation"}, + {"id": "vethara", "primary": "Vethara", "secondary": "combine"}, + {"id": "free-traders", "primary": "Free Traders Syndic", "secondary": "syndic"}, + ] + + +func test_filter_rows_empty_query_returns_all_rows_unchanged() -> void: + var a = _adapter() + var rows := _sample_rows() + assert_array(a.filter_rows(rows, "")).is_equal(rows) + + +func test_filter_rows_matches_primary_case_insensitively() -> void: + var a = _adapter() + var filtered: Array = a.filter_rows(_sample_rows(), "vethara") + assert_int(filtered.size()).is_equal(1) + assert_str(filtered[0]["id"]).is_equal("vethara") + + +func test_filter_rows_matches_secondary_column() -> void: + var a = _adapter() + var filtered: Array = a.filter_rows(_sample_rows(), "syndic") + assert_int(filtered.size()).is_equal(1) + assert_str(filtered[0]["id"]).is_equal("free-traders") + + +func test_filter_rows_matches_id() -> void: + var a = _adapter() + var filtered: Array = a.filter_rows(_sample_rows(), "gate-corp") + assert_int(filtered.size()).is_equal(1) + assert_str(filtered[0]["id"]).is_equal("gate-corporation") + + +func test_filter_rows_no_match_returns_empty_array() -> void: + var a = _adapter() + assert_array(a.filter_rows(_sample_rows(), "nonexistent-zzz")).is_empty() + + +func test_filter_rows_partial_word_matches_across_multiple_rows() -> void: + var a = _adapter() + # "corp" is a substring of both "Gate Corporation" (primary) and + # "corporation" (secondary) — same row matches twice, must not duplicate. + var filtered: Array = a.filter_rows(_sample_rows(), "corp") + assert_int(filtered.size()).is_equal(1) + + +# ============================================================================= +# response_status / response_error +# ============================================================================= + + +func test_response_status_ready() -> void: + var a = _adapter() + assert_str(a.response_status({"status": "Ready"})).is_equal("Ready") + + +func test_response_status_not_a_dictionary_returns_empty_string() -> void: + var a = _adapter() + assert_str(a.response_status(null)).is_equal("") + assert_str(a.response_status("not a dict")).is_equal("") + + +func test_response_error_returns_message_only_for_error_status() -> void: + var a = _adapter() + var response := {"status": "Error", "error": "db unavailable"} + assert_str(a.response_error(response)).is_equal("db unavailable") + + +func test_response_error_empty_for_ready_status() -> void: + var a = _adapter() + var response := {"status": "Ready", "error": ""} + assert_str(a.response_error(response)).is_equal("") + + +func test_response_error_empty_for_not_found_status() -> void: + var a = _adapter() + var response := {"status": "NotFound", "error": ""} + assert_str(a.response_error(response)).is_equal("") + + +# ============================================================================= +# detail_payload — BrowseDetail enum-variant unwrap +# ============================================================================= + + +func test_detail_payload_unwraps_kind_keyed_variant() -> void: + var a = _adapter() + var response := { + "kind": "Commodity", + "status": "Ready", + "detail": {"Commodity": {"commodity_id": "fusion_fuel", "name": "FUSION FUEL"}}, + } + var payload: Dictionary = a.detail_payload(response) + assert_str(payload.get("commodity_id", "")).is_equal("fusion_fuel") + assert_str(payload.get("name", "")).is_equal("FUSION FUEL") + + +func test_detail_payload_non_ready_status_returns_empty_dict() -> void: + var a = _adapter() + var response := {"kind": "Commodity", "status": "NotFound", "detail": null} + assert_that(a.detail_payload(response)).is_equal({}) + + +func test_detail_payload_detail_field_not_a_dictionary_returns_empty_dict() -> void: + var a = _adapter() + var response := {"kind": "Commodity", "status": "Ready", "detail": null} + assert_that(a.detail_payload(response)).is_equal({}) + + +func test_detail_payload_falls_back_to_single_key_map_when_kind_key_missing() -> void: + # Defensive path: some enum-variant encodings may not key exactly on the + # response's own "kind" string — a single-key map should still unwrap. + var a = _adapter() + var response := { + "kind": "TraitTemplate", + "status": "Ready", + "detail": {"SomeOtherVariantName": {"tag": "spice_market"}}, + } + var payload: Dictionary = a.detail_payload(response) + assert_str(payload.get("tag", "")).is_equal("spice_market") + + +# ============================================================================= +# detail_view — per-kind dispatch + field mapping +# ============================================================================= + + +func test_detail_view_star_system_maps_expected_fields() -> void: + var a = _adapter() + var payload := { + "system_id": "GJ-1", + "proper_name": "Xin Chengdu", + "star_type": "K", + "population": 1200000, + } + var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload) + assert_str(view["name"]).is_equal("Xin Chengdu") + assert_str(view["subtitle"]).is_equal("STAR SYSTEM") + var rows: Array = view["rows"] + assert_bool(rows.size() > 0).is_true() + # Spot-check one row is present with the right value. + var found := false + for row: Dictionary in rows: + if row["label"] == "star type": + assert_str(row["value"]).is_equal("K") + found = true + assert_bool(found).override_failure_message("expected a 'star type' row").is_true() + + +func test_detail_view_star_system_falls_back_to_system_id_when_unnamed() -> void: + var a = _adapter() + var payload := {"system_id": "GJ-999"} + var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload) + assert_str(view["name"]).is_equal("GJ-999") + + +## Regression test for a bug found via live verification (2026-07-17): a +## real Body row (an unnamed asteroid belt, GJ0-belt) has proper_name as a +## PRESENT key with a NULL value (msgpack encodes a SQL NULL column that +## way; Dictionary.get(key, fallback)'s fallback only fires when the key is +## ABSENT, not when it's present-and-null) — the naive +## str(p.get("proper_name", p.get("body_id", "—"))) rendered the literal +## string "" instead of falling back to body_id. Distinct from +## test_detail_view_star_system_falls_back_to_system_id_when_unnamed above, +## which only covers the ABSENT-key case (that test alone did not catch this +## bug — it needs its own present-but-null case). +func test_detail_view_falls_back_correctly_when_name_field_is_present_but_null() -> void: + var a = _adapter() + var payload := {"body_id": "GJ0-belt", "proper_name": null, "body_type": "asteroid_belt"} + var view: Dictionary = a.detail_view(a.KIND_BODY, payload) + assert_str(view["name"]).is_equal("GJ0-belt") + + +## Same present-but-null regression, for the "distance" row's unit-suffix +## special case (str(p.get("dist_ly", "—")) + " ly" would render " ly" +## for Sol's own row, where dist_ly is a genuine NULL — distance from Earth +## to itself is meaningless, not zero). +func test_detail_view_star_system_distance_row_handles_null_dist_ly() -> void: + var a = _adapter() + var payload := {"system_id": "GJ 0", "proper_name": "Sol", "dist_ly": null} + var view: Dictionary = a.detail_view(a.KIND_STAR_SYSTEM, payload) + var distance_value := "" + for row: Dictionary in view["rows"]: + if row["label"] == "distance": + distance_value = row["value"] + assert_str(distance_value).is_equal("—") + + +## Same present-but-null regression, for the two array-summary text-block +## builders (_presence_summary/_production_chains_summary) — a null entry +## field must not render as "" inside the summary line. +func test_detail_view_corporation_presence_entry_with_null_operation_omits_it_cleanly() -> void: + var a = _adapter() + var payload := { + "corp_id": "gate-corporation", + "presence": [ + {"location_id": "GJ1b-S1", "location_type": "station", "primary_operation": null} + ], + } + var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload) + assert_str(view["text"]).contains("GJ1b-S1 (station)") + assert_str(view["text"]).not_contains("") + + +func test_detail_view_body_maps_expected_fields() -> void: + var a = _adapter() + var payload := { + "body_id": "GJ1c", + "proper_name": "Chengdu Prime", + "body_type": "planet", + "inhabited": true, + } + var view: Dictionary = a.detail_view(a.KIND_BODY, payload) + assert_str(view["name"]).is_equal("Chengdu Prime") + assert_str(view["subtitle"]).is_equal("CELESTIAL BODY") + var inhabited_value := "" + for row: Dictionary in view["rows"]: + if row["label"] == "inhabited": + inhabited_value = row["value"] + assert_str(inhabited_value).is_equal("yes") + + +func test_detail_view_station_maps_expected_fields() -> void: + var a = _adapter() + var payload := { + "station_id": "GJ1b-S1", + "proper_name": "Horizon Station", + "station_type": "horizon", + } + var view: Dictionary = a.detail_view(a.KIND_STATION, payload) + assert_str(view["name"]).is_equal("Horizon Station") + assert_str(view["subtitle"]).is_equal("STATION") + + +func test_detail_view_corporation_maps_expected_fields() -> void: + var a = _adapter() + var payload := { + "corp_id": "gate-corporation", + "proper_name": "Gate Corporation", + "corp_type": "corporation", + "shadow_economy_access": false, + } + var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload) + assert_str(view["name"]).is_equal("Gate Corporation") + assert_str(view["subtitle"]).is_equal("CORPORATION") + var shadow_value := "" + for row: Dictionary in view["rows"]: + if row["label"] == "shadow economy access": + shadow_value = row["value"] + assert_str(shadow_value).is_equal("no") + + +func test_detail_view_corporation_with_no_presence_yields_empty_text() -> void: + var a = _adapter() + var payload := {"corp_id": "gate-corporation", "presence": []} + var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload) + assert_str(view["text"]).is_equal("") + + +func test_detail_view_corporation_presence_array_summarized_into_text() -> void: + # CorporationDetail.presence: Vec (Oscar's T-1131 + # message, 2026-07-17) — {location_id, location_type, primary_operation}. + var a = _adapter() + var payload := { + "corp_id": "gate-corporation", + "presence": [ + { + "location_id": "GJ1b-S1", + "location_type": "station", + "primary_operation": "refining", + }, + {"location_id": "GJ-2", "location_type": "system", "primary_operation": ""}, + ], + } + var view: Dictionary = a.detail_view(a.KIND_CORPORATION, payload) + assert_str(view["text"]).contains("GJ1b-S1 (station) — refining") + assert_str(view["text"]).contains("GJ-2 (system)") + + +func test_detail_view_commodity_maps_expected_fields_and_description_text() -> void: + var a = _adapter() + var payload := { + "commodity_id": "fusion_fuel", + "name": "FUSION FUEL", + "tier": "intermediate", + "description": "Refined isotopic fuel for gate-rated drives.", + } + var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload) + assert_str(view["name"]).is_equal("FUSION FUEL") + assert_str(view["subtitle"]).is_equal("COMMODITY") + assert_str(view["text"]).is_equal("Refined isotopic fuel for gate-rated drives.") + + +func test_detail_view_commodity_with_no_description_or_chains_yields_empty_text() -> void: + var a = _adapter() + var payload := {"commodity_id": "fusion_fuel", "description": "", "produced_by": []} + var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload) + assert_str(view["text"]).is_equal("") + + +func test_detail_view_commodity_produced_by_chains_summarized_into_text() -> void: + # CommodityDetail.produced_by: Vec with nested + # inputs: Vec (Oscar's T-1131 message, 2026-07-17). + var a = _adapter() + var payload := { + "commodity_id": "fusion_fuel", + "description": "Refined isotopic fuel.", + "produced_by": [ + { + "chain_id": "fusion_refining", + "output_quantity": 1.0, + "inputs": [ + {"input_commodity_id": "raw_hydrogen", "quantity": 3.0}, + {"input_commodity_id": "catalyst", "quantity": 0.5}, + ], + } + ], + } + var view: Dictionary = a.detail_view(a.KIND_COMMODITY, payload) + # str() on a float keeps its decimal form (str(1.0) == "1.0", not "1") — + # asserting against that honest formatting rather than a rounded guess. + assert_str(view["text"]).contains("Refined isotopic fuel.") + assert_str(view["text"]).contains("fusion_refining (qty 1.0)") + assert_str(view["text"]).contains("3.0x raw_hydrogen") + assert_str(view["text"]).contains("0.5x catalyst") + + +func test_detail_view_trait_template_maps_expected_fields_and_cultural_description() -> void: + var a = _adapter() + var payload := { + "tag": "spice_market", + "label": "Spice Market", + "corridor_pool": "heritage", + "cultural_description": "A dense stall market smelling of scorched cardamom.", + } + var view: Dictionary = a.detail_view(a.KIND_TRAIT_TEMPLATE, payload) + assert_str(view["name"]).is_equal("Spice Market") + assert_str(view["subtitle"]).is_equal("TRAIT TEMPLATE") + assert_str(view["text"]).is_equal("A dense stall market smelling of scorched cardamom.") + + +func test_detail_view_unknown_kind_returns_empty_shape_not_a_crash() -> void: + var a = _adapter() + var view: Dictionary = a.detail_view("Nonsense", {"some": "payload"}) + assert_str(view["name"]).is_equal("") + assert_array(view["rows"]).is_empty() + + +func test_detail_view_missing_field_renders_as_em_dash_placeholder() -> void: + var a = _adapter() + var view: Dictionary = a.detail_view(a.KIND_STATION, {"station_id": "GJ1b-S1"}) + var docking_value := "" + for row: Dictionary in view["rows"]: + if row["label"] == "docking class": + docking_value = row["value"] + assert_str(docking_value).is_equal("—") diff --git a/client/ui/implant/apps/browser/app.tres b/client/ui/implant/apps/browser/app.tres new file mode 100644 index 000000000..8e6fb0313 --- /dev/null +++ b/client/ui/implant/apps/browser/app.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="ImplantAppManifest" load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/implant_app_manifest.gd" id="1"] + +[resource] +script = ExtResource("1") +schema_version = 1 +app_path = "implant/browser" +scene_path = "res://ui/implant/apps/browser/browser_app.tscn" +default_mode = "fullscreen" +default_key = 66 +preserves_state = true diff --git a/client/ui/implant/apps/browser/browser_adapter.gd b/client/ui/implant/apps/browser/browser_adapter.gd new file mode 100644 index 000000000..1437d01a0 --- /dev/null +++ b/client/ui/implant/apps/browser/browser_adapter.gd @@ -0,0 +1,438 @@ +class_name BrowserAdapter +## Pure translation layer between the wire BrowseRequest/BrowseResponse +## contract (T-1131, Oscar, D-254 §4) and the view-models the browser's +## screens render (T-1133). +## +## WHY THIS EXISTS: Oscar's contract names its own field-naming churn risk +## up front (Body's filter field renaming mid-implementation; per-kind +## detail field names "TBD" at contract-send time). Every other browser file +## reads through this adapter's accessor functions instead of touching a +## raw response Dictionary's keys directly — so a field rename on the wire +## is a one-line change here, never a screen rewrite. This is the ONE file +## that is allowed to know the literal wire key names. +## +## Kept as a static-function-only class (no state, no scene tree) so it is +## gdUnit-testable with plain Dictionary literals standing in for decoded +## msgpack responses — no live server, no SimBridge, no protocol.gd +## round-trip required to exercise the mapping logic. + +## The six entity kinds, in the exact order the kind-menu screen displays +## them (D-254 §4's v1 list order). Wire values are the bare-string unit +## variant names BrowseEntityKind serializes to (Protocol._decode_status_field +## doc: unit variants are bare strings on the wire). +const KIND_STAR_SYSTEM: String = "StarSystem" +const KIND_BODY: String = "Body" +const KIND_STATION: String = "Station" +const KIND_CORPORATION: String = "Corporation" +const KIND_COMMODITY: String = "Commodity" +const KIND_TRAIT_TEMPLATE: String = "TraitTemplate" + +const KIND_ORDER: Array[String] = [ + KIND_STAR_SYSTEM, + KIND_BODY, + KIND_STATION, + KIND_CORPORATION, + KIND_COMMODITY, + KIND_TRAIT_TEMPLATE, +] + +## Display label per kind, for the kind-menu screen and detail/index headers. +const KIND_LABELS: Dictionary = { + KIND_STAR_SYSTEM: "STAR SYSTEMS", + KIND_BODY: "BODIES", + KIND_STATION: "STATIONS", + KIND_CORPORATION: "CORPORATIONS", + KIND_COMMODITY: "COMMODITIES", + KIND_TRAIT_TEMPLATE: "TRAIT CATALOG", +} + + +## True iff kind is one of the six v1 entity kinds (defends against a typo'd +## kind string reaching a request encode — better to fail loud client-side +## than send a request the server's kind enum can't decode). +static func is_valid_kind(kind: String) -> bool: + return kind in KIND_ORDER + + +static func kind_label(kind: String) -> String: + return KIND_LABELS.get(kind, kind) + + +# ============================================================================= +# Index rows +# ============================================================================= + + +## One row-view-model: {id, primary, secondary}. secondary is "" (not null) +## when the wire response carried no secondary column, so callers can always +## treat it as a plain String without a null check. +static func _row_from_raw(raw: Dictionary) -> Dictionary: + return { + "id": str(raw.get("id", "")), + "primary": str(raw.get("primary", "")), + "secondary": str(raw.get("secondary", "")) if raw.get("secondary") != null else "", + } + + +## Extract the index row list from a decoded BrowseResponse. Returns [] for +## any non-Ready status or a response with no "index" field (e.g. a Detail +## response) rather than erroring — callers check response status themselves +## via response_status()/response_error() before trusting an empty list as +## "genuinely no rows". +static func index_rows(response: Dictionary) -> Array: + if response.get("status", "") != "Ready": + return [] + var raw_rows: Variant = response.get("index") + if not raw_rows is Array: + return [] + var rows: Array = [] + for raw: Dictionary in raw_rows: + rows.append(_row_from_raw(raw)) + return rows + + +## Case-insensitive substring filter over primary/secondary/id — the index +## screen's search box. Empty query returns rows unchanged (identity, not a +## copy — callers must not mutate the result in place). +static func filter_rows(rows: Array, query: String) -> Array: + if query.is_empty(): + return rows + var needle := query.to_lower() + var out: Array = [] + for row: Dictionary in rows: + var haystack: String = ( + str(row.get("primary", "")) + " " + str(row.get("secondary", "")) + " " + + str(row.get("id", "")) + ).to_lower() + if haystack.contains(needle): + out.append(row) + return out + + +# ============================================================================= +# Detail — per-kind field extraction +# ============================================================================= + + +## Status of a decoded BrowseResponse: "Ready" | "NotFound" | "Error" | "". +## Empty string means the Dictionary isn't a recognizable BrowseResponse at +## all (e.g. null, or a decode failure upstream). +static func response_status(response: Variant) -> String: + if not response is Dictionary: + return "" + return str(response.get("status", "")) + + +## Error message for an Error-status response; "" otherwise (including for +## Ready/NotFound, where there is nothing to show). +static func response_error(response: Dictionary) -> String: + if response.get("status", "") != "Error": + return "" + return str(response.get("error", "")) + + +## The BrowseDetail payload for a Ready Detail response, or {} if the +## response is any other shape. This is the ONE enum-variant-as-single-key-map +## unwrap in the adapter — BrowseDetail is an externally-tagged Rust enum +## (StarSystem(...)|Body(...)|...), so the decoded map has exactly one key +## matching the response's own "kind" field, and the value under that key is +## the per-kind detail Dictionary every _detail_rows_for_* function reads. +static func detail_payload(response: Dictionary) -> Dictionary: + if response.get("status", "") != "Ready": + return {} + var raw_detail: Variant = response.get("detail") + if not raw_detail is Dictionary: + return {} + var kind: String = str(response.get("kind", "")) + if raw_detail.has(kind) and raw_detail[kind] is Dictionary: + return raw_detail[kind] + # Defensive fallback: some server-side encodings of a single-variant enum + # collapse to the inner map directly rather than wrapping it under the + # variant name (observed with other unit-vs-struct enums in this + # codebase's wire contracts, e.g. AtlasLayerStatus's Error(String) case). + # If the "kind"-keyed lookup above misses, and the map has no OTHER + # single-key wrapper shape either, assume it's already the unwrapped + # detail and return it as-is rather than silently rendering a blank + # detail screen. + if raw_detail.size() == 1 and not raw_detail.has(kind): + return raw_detail.values()[0] + return raw_detail + + +## header (name, subtitle) + Array[{label, value}] rows for a detail +## payload, dispatched by kind. Kept as one dispatcher rather than one method +## per kind on the caller side, so DetailScreen only ever calls one function +## regardless of which of the six kinds it's showing. +static func detail_view(kind: String, payload: Dictionary) -> Dictionary: + match kind: + BrowserAdapter.KIND_STAR_SYSTEM: + return _detail_star_system(payload) + BrowserAdapter.KIND_BODY: + return _detail_body(payload) + BrowserAdapter.KIND_STATION: + return _detail_station(payload) + BrowserAdapter.KIND_CORPORATION: + return _detail_corporation(payload) + BrowserAdapter.KIND_COMMODITY: + return _detail_commodity(payload) + BrowserAdapter.KIND_TRAIT_TEMPLATE: + return _detail_trait_template(payload) + _: + return {"name": "", "subtitle": "", "rows": []} + + +static func _row(label: String, value: Variant) -> Dictionary: + return {"label": label, "value": str(value) if value != null else "—"} + + +static func _bool_str(v: Variant) -> String: + return "yes" if bool(v) else "no" + + +## Dictionary.get(key, fallback)'s fallback only fires when key is ABSENT — +## a present key with a NULL value (a real, common shape here: msgpack +## encodes a SQL NULL column as null, and rusqlite's Option => None keeps +## the key in the map) returns that null unchanged, and str(null) renders as +## the literal string "" — a real bug that surfaced live (a Body row +## for an unnamed asteroid belt: proper_name is a present NULL column, so +## the name line rendered "" instead of falling back to body_id). +## Every _detail_*'s "name" line goes through this instead of a bare +## p.get(primary_key, p.get(fallback_key, "—")) for exactly this reason — +## the per-field row values are already safe via _row()'s own null check +## above; this is the ONE other place a display string is built directly +## from a wire value without going through _row(). +static func _display_or(p: Dictionary, key: String, fallback: String) -> String: + var v: Variant = p.get(key) + if v == null: + return fallback + return str(v) + + +## "distance" is the one row that appends a unit suffix instead of a bare +## _row(label, value) call (str(p.get("dist_ly", "—")) + " ly" would render +## " ly" for a NULL column via the same bug _display_or() fixes above, +## since concatenating a suffix happens before _row()'s own null guard would +## ever see the value) — a dedicated helper keeps the "— ly" placeholder +## intact for a genuinely-NULL dist_ly (Sol's own row: distance from Earth to +## itself is meaningless, not zero, so a NULL/omitted dist_ly is expected +## real data here, not an error condition). +static func _distance_ly_str(dist_ly: Variant) -> String: + if dist_ly == null: + return "—" + return str(dist_ly) + " ly" + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — +## StarSystemDetail folds system_economy/system_factions/system_culture. +static func _detail_star_system(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "proper_name", _display_or(p, "system_id", "—")) + var rows: Array = [ + _row("system id", p.get("system_id", "—")), + _row("star type", p.get("star_type", "—")), + _row("spectral class", p.get("spectral_class", "—")), + _row("distance", _distance_ly_str(p.get("dist_ly"))), + _row("sector", p.get("geographic_sector", "—")), + _row("geographic band", p.get("geographic_band", "—")), + _row("political zone", p.get("political_zone", "—")), + _row("habitable planets", p.get("habitable_planet_count", "—")), + _row("inhabited planets", p.get("inhabited_planet_count", "—")), + _row("asteroid belt", _bool_str(p.get("asteroid_belt", false))), + _row("gas giant", _bool_str(p.get("gas_giant", false))), + _row("habitability profile", p.get("habitability_profile", "—")), + _row("earth alignment", p.get("earth_alignment", "—")), + _row("earth proximity", p.get("earth_proximity", "—")), + _row("earth tension", p.get("earth_tension", "—")), + _row("stability index", p.get("stability_index", "—")), + _row("volatility", p.get("system_volatility", "—")), + _row("cultural corridor", p.get("cultural_corridor", "—")), + _row("currency zone", p.get("currency_zone", "—")), + _row("economic tier", p.get("economic_tier", "—")), + _row("population", p.get("population", "—")), + _row("economic base", p.get("economic_base_primary", "—")), + _row("economic base (secondary)", p.get("economic_base_secondary", "—")), + _row("governance", p.get("governance_type", "—")), + _row("dominant faction", p.get("dominant_faction", "—")), + _row("cultural register", p.get("cultural_register", "—")), + _row("atmospheric tone", p.get("atmospheric_tone", "—")), + _row("primary archetype", p.get("primary_archetype", "—")), + ] + return {"name": name, "subtitle": "STAR SYSTEM", "rows": rows} + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare +## `bodies` row, no folded child tables. +static func _detail_body(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "proper_name", _display_or(p, "body_id", "—")) + var rows: Array = [ + _row("body id", p.get("body_id", "—")), + _row("system", p.get("system_id", "—")), + _row("parent body", p.get("parent_body_id", "—")), + _row("type", p.get("body_type", "—")), + _row("orbit index", p.get("orbit_index", "—")), + _row("mass class", p.get("mass_class", "—")), + _row("atmosphere", p.get("atmosphere", "—")), + _row("surface gravity (g)", p.get("surface_gravity", "—")), + _row("orbital period (days)", p.get("orbital_period_days", "—")), + _row("rotation period (hrs)", p.get("rotation_period_hours", "—")), + _row("planet class", p.get("planet_class", "—")), + _row("hydrosphere", p.get("hydrosphere", "—")), + _row("biosphere class", p.get("biosphere_class", "—")), + _row("inhabited", _bool_str(p.get("inhabited", false))), + _row("population", p.get("population", "—")), + _row("economic role", p.get("economic_role", "—")), + _row("founding age (years)", p.get("founding_age_years", "—")), + _row("settlement pattern", p.get("settlement_pattern", "—")), + _row("cultural corridor", p.get("cultural_corridor", "—")), + _row("industrial corridor", p.get("industrial_corridor", "—")), + _row("radius (km)", p.get("body_radius_km", "—")), + _row("axial tilt (deg)", p.get("axial_tilt_deg", "—")), + ] + return {"name": name, "subtitle": "CELESTIAL BODY", "rows": rows} + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare +## `stations` row, no folded child tables. +static func _detail_station(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "proper_name", _display_or(p, "station_id", "—")) + var rows: Array = [ + _row("station id", p.get("station_id", "—")), + _row("system", p.get("system_id", "—")), + _row("orbits", p.get("orbits_body_id", "—")), + _row("type", p.get("station_type", "—")), + _row("population", p.get("population", "—")), + _row("economic role", p.get("economic_role", "—")), + _row("governance", p.get("governance_type", "—")), + _row("docking class", p.get("docking_class", "—")), + _row("gate infrastructure", _bool_str(p.get("has_gate_infrastructure", false))), + _row("districts", p.get("district_count", "—")), + ] + return {"name": name, "subtitle": "STATION", "rows": rows} + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — +## CorporationDetail folds corp_presence (as a presence array) + corp_financial_state. +static func _detail_corporation(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "proper_name", _display_or(p, "corp_id", "—")) + var rows: Array = [ + _row("corp id", p.get("corp_id", "—")), + _row("type", p.get("corp_type", "—")), + _row("scope", p.get("scope", "—")), + _row("hq system", p.get("headquarters_system", "—")), + _row("hq body", p.get("headquarters_body", "—")), + _row("specialization", p.get("specialization", "—")), + _row("parent corp", p.get("parent_corp", "—")), + _row("notes", p.get("notes", "—")), + _row("behavioral archetype", p.get("behavioral_archetype", "—")), + _row("supply chain role", p.get("supply_chain_role", "—")), + _row("shadow economy access", _bool_str(p.get("shadow_economy_access", false))), + _row("corp specialization", p.get("corp_specialization", "—")), + _row("hq placement", p.get("hq_placement", "—")), + _row("health metric", p.get("health_metric", "—")), + ] + return { + "name": name, + "subtitle": "CORPORATION", + "rows": rows, + "text": _presence_summary(p.get("presence", [])), + } + + +## presence: Array[{location_id, location_type, primary_operation}] -> +## one line per entry for the detail screen's ImplantTextBlock. Empty array +## (no presence rows) yields "" so the caller's has-text check stays a plain +## is_empty() rather than needing a special "no presence" sentinel. +static func _presence_summary(presence: Variant) -> String: + if not presence is Array or presence.is_empty(): + return "" + var lines: Array = [] + for entry: Dictionary in presence: + var loc: String = _display_or(entry, "location_id", "—") + var loc_type: String = _display_or(entry, "location_type", "") + var op: String = _display_or(entry, "primary_operation", "") + var line: String = loc + if not loc_type.is_empty(): + line += " (" + loc_type + ")" + if not op.is_empty(): + line += " — " + op + lines.append(line) + return "PRESENCE\n" + "\n".join(lines) + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — +## CommodityDetail folds production_chains + chain_inputs (as produced_by). +static func _detail_commodity(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "name", _display_or(p, "commodity_id", "—")) + var rows: Array = [ + _row("commodity id", p.get("commodity_id", "—")), + _row("tier", p.get("tier", "—")), + _row("elasticity", p.get("elasticity", "—")), + _row("base price", p.get("base_price", "—")), + _row("bulk class", p.get("bulk_class", "—")), + _row("unit", p.get("unit", "—")), + _row("production ubiquity", p.get("production_ubiquity", "—")), + _row("demand model", p.get("demand_model", "—")), + _row("commission certifiable", _bool_str(p.get("commission_certifiable", false))), + _row("compact contested", _bool_str(p.get("compact_contested", false))), + _row("shadow viable", _bool_str(p.get("shadow_viable", false))), + _row("panic threshold (weeks)", p.get("panic_threshold_weeks", "—")), + ] + var text_parts: Array = [] + var description: String = _display_or(p, "description", "") + if not description.is_empty(): + text_parts.append(description) + var chains_summary: String = _production_chains_summary(p.get("produced_by", [])) + if not chains_summary.is_empty(): + text_parts.append(chains_summary) + return {"name": name, "subtitle": "COMMODITY", "rows": rows, "text": "\n\n".join(text_parts)} + + +## produced_by: Array[{chain_id, output_quantity, location_bound, description, +## inputs: [{input_commodity_id, quantity}]}] -> one summary line per chain, +## with its Leontief inputs inlined as "qty x input_id" pairs. Empty array -> +## "" (same no-sentinel convention as _presence_summary). +static func _production_chains_summary(chains: Variant) -> String: + if not chains is Array or chains.is_empty(): + return "" + var lines: Array = [] + for chain: Dictionary in chains: + var chain_id: String = _display_or(chain, "chain_id", "—") + var qty: String = _display_or(chain, "output_quantity", "—") + var raw_inputs: Variant = chain.get("inputs") + var inputs: Array = raw_inputs if raw_inputs is Array else [] + var input_strs: Array = [] + for entry: Dictionary in inputs: + var input_qty: String = _display_or(entry, "quantity", "—") + var input_id: String = _display_or(entry, "input_commodity_id", "—") + input_strs.append(input_qty + "x " + input_id) + var line: String = "%s (qty %s)" % [chain_id, qty] + if not input_strs.is_empty(): + line += " <- " + ", ".join(input_strs) + lines.append(line) + return "PRODUCTION CHAINS\n" + "\n".join(lines) + + +## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare +## `trait_templates` row. weight_mods/zone_affinity/allow_tags/block_tags/ +## visual_bundle are JSON-text columns the server does NOT parse (Oscar's +## contract: "opaque strings, not parsed server-side") — shown here as raw +## JSON text rows rather than silently dropped; a data browser's job is to +## expose the registry as it actually is, not to pretty-print it. +static func _detail_trait_template(p: Dictionary) -> Dictionary: + var name: String = _display_or(p, "label", _display_or(p, "tag", "—")) + var rows: Array = [ + _row("tag", p.get("tag", "—")), + _row("corridor pool", p.get("corridor_pool", "—")), + _row("geographic sector", p.get("geographic_sector", "—")), + _row("bulk class gate", p.get("bulk_class_gate", "—")), + _row("production ubiquity gate", p.get("production_ubiquity_gate", "—")), + _row("min prosperity (bps)", p.get("min_prosperity_bps", "—")), + _row("base weight (bps)", p.get("base_weight", "—")), + _row("weight mods", p.get("weight_mods", "—")), + _row("zone affinity", p.get("zone_affinity", "—")), + _row("allow tags", p.get("allow_tags", "—")), + _row("block tags", p.get("block_tags", "—")), + _row("era scope", p.get("era_scope", "—")), + _row("visual bundle", p.get("visual_bundle", "—")), + ] + var text_block: String = _display_or(p, "cultural_description", "") + return {"name": name, "subtitle": "TRAIT TEMPLATE", "rows": rows, "text": text_block} diff --git a/client/ui/implant/apps/browser/browser_app.gd b/client/ui/implant/apps/browser/browser_app.gd new file mode 100644 index 000000000..c81f93e0b --- /dev/null +++ b/client/ui/implant/apps/browser/browser_app.gd @@ -0,0 +1,159 @@ +class_name BrowserApp +extends ImplantApp +## Data browser implant app (T-1133, D-254 §4). A SEPARATE app from the Atlas +## (implant/map) — the six registry entities do not share the Atlas's +## geographic drill-down (D-254 SS4, Jeroen's IA ruling). Registered as +## "implant/browser" in FULLSCREEN mode, key B. +## +## Nav: kind menu (root) -> index (per kind, scrollable + searchable) -> +## detail (per entity). Same push/pop convention the Atlas app uses. +## +## Composed entirely from D-169 implant components via the three screen +## classes below (BrowserKindMenuScreen/BrowserIndexScreen/BrowserDetailScreen) +## — no new UI primitives. + +var _kind_menu_screen = null # BrowserKindMenuScreen +var _index_screen = null # BrowserIndexScreen +var _detail_screen = null # BrowserDetailScreen + + +func _ready() -> void: + manifest = load("res://ui/implant/apps/browser/app.tres") + super._ready() + + +func on_install() -> void: + var implant_theme = load("res://ui/implant/default_implant.tres") + + _kind_menu_screen = BrowserKindMenuScreen.new() + _kind_menu_screen.setup(implant_theme) + _kind_menu_screen.kind_selected.connect(_on_kind_selected) + register_screen("kind_menu", _kind_menu_screen) + + _index_screen = BrowserIndexScreen.new() + _index_screen.setup(implant_theme) + _index_screen.row_selected.connect(_on_row_selected) + register_screen("index", _index_screen) + + _detail_screen = BrowserDetailScreen.new() + _detail_screen.setup(implant_theme) + register_screen("detail", _detail_screen) + + SimBridge.browse_response_received.connect(_on_browse_response_received) + + nav.set_default("kind_menu") + + +func _unhandled_key_input(event: InputEvent) -> void: + if not event is InputEventKey: + return + if manifest == null or not HudGroups.is_app_active(manifest.app_path): + return + if not event.is_pressed() or event.is_echo(): + return + _handle_key(event as InputEventKey) + get_viewport().set_input_as_handled() + + +func _handle_key(event: InputEventKey) -> void: + # Search-mode: on the index screen, printable keys type into the live + # filter instead of being interpreted as navigation. Split into its own + # function (not inlined here) — it is a genuinely separate input mode + # from the top-level app navigation below, and keeping it here would + # push this function's branch/return count well past what one function + # should hold. + if current_screen_id() == "index" and _index_screen: + _handle_index_search_key(event) + return + + match event.physical_keycode: + KEY_B: + HudGroups.close_app() + KEY_ESCAPE: + _close_or_pop() + KEY_ENTER, KEY_KP_ENTER: + _handle_enter() + KEY_UP: + if current_screen_id() == "kind_menu" and _kind_menu_screen: + _kind_menu_screen.navigate(-1) + KEY_DOWN: + if current_screen_id() == "kind_menu" and _kind_menu_screen: + _kind_menu_screen.navigate(1) + + +## Key handling while the index screen is current. Arrows/enter/escape/ +## backspace are the always-active control keys; everything else in the +## printable range (D-254 §4: "filterable/searchable by name") appends to +## the live search buffer. This mirrors the implant's existing +## keyboard-as-input-surface convention (no OS textbox anywhere in this +## component library) — there is no separate "focus the search box" step. +func _handle_index_search_key(event: InputEventKey) -> void: + match event.physical_keycode: + KEY_UP: + _index_screen.navigate(-1) + return + KEY_DOWN: + _index_screen.navigate(1) + return + KEY_ENTER, KEY_KP_ENTER: + _index_screen.trigger_enter() + return + KEY_ESCAPE: + if _index_screen.has_search_text(): + _index_screen.clear_search() + else: + _close_or_pop() + return + KEY_BACKSPACE: + _index_screen.backspace_search() + return + + # event.unicode carries the actual typed character (shift/caps already + # resolved by the platform), NOT the physical keycode — String.chr() is + # GDScript's codepoint-to-one-char-String conversion. Printable range + # only (32 = space .. 126 = ~): control characters (arrows, tab, etc. + # already handled above by physical_keycode) report unicode == 0 or a + # non-printable codepoint and must not leak into the search buffer. + if event.unicode >= 32 and event.unicode < 127: + _index_screen.append_search_char(String.chr(event.unicode)) + + +func _close_or_pop() -> void: + if current_screen_id() == "kind_menu": + HudGroups.close_app() + else: + nav.pop() + + +func _handle_enter() -> void: + match current_screen_id(): + "kind_menu": + if _kind_menu_screen: + _kind_menu_screen.trigger_enter() + + +# ============================================================================= +# Signal handlers +# ============================================================================= + + +func _on_kind_selected(kind: String) -> void: + nav.push("index", {"kind": kind, "filter_system_id": ""}) + + +func _on_row_selected(entity_id: String) -> void: + var kind: String = _index_screen.current_kind() if _index_screen else "" + nav.push("detail", {"kind": kind, "entity_id": entity_id}) + + +func _on_browse_response_received(response: Dictionary) -> void: + if response == null: + return + # Fan out to whichever screen is currently waiting on a response for this + # kind — both screens no-op via their own kind-match guard if the + # response isn't theirs (a stale one from a prior navigation, or one + # meant for the other screen type). + if _index_screen: + _index_screen.receive_response(response) + if _detail_screen: + _detail_screen.receive_response(response) diff --git a/client/ui/implant/apps/browser/browser_app.tscn b/client/ui/implant/apps/browser/browser_app.tscn new file mode 100644 index 000000000..f8c952cea --- /dev/null +++ b/client/ui/implant/apps/browser/browser_app.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/implant/apps/browser/browser_app.gd" id="1_browser_app"] + +; T-1133: Data browser implant app — six-entity registry index/detail viewer +; (star systems, bodies, stations, corporations, commodities, trait catalog). +; FULLSCREEN app (z=20) at implant/browser per D-170. A SEPARATE app from the +; Atlas (D-254 SS4) — managed via the same ImplantApp/ImplantNavStack pattern. +; Toggle with B key (manifest.default_key). Data from the wire BrowseRequest/ +; BrowseResponse proxy (T-1131). + +[node name="BrowserApp" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_browser_app") diff --git a/client/ui/implant/apps/browser/screens/detail_screen.gd b/client/ui/implant/apps/browser/screens/detail_screen.gd new file mode 100644 index 000000000..808deeca8 --- /dev/null +++ b/client/ui/implant/apps/browser/screens/detail_screen.gd @@ -0,0 +1,152 @@ +class_name BrowserDetailScreen +extends Control +## Detail screen for one browser entity (T-1133, D-254 §4). An ImplantPanel of +## ImplantDataRows (every column the wire response carries for that entity) +## plus an ImplantTextBlock for free-text description/cultural_description +## fields where the kind has one. One instance handles all six kinds — +## parameterized by set_entity(), not six near-duplicate screens. + +signal back_requested + +const COLOR_BG: Color = Color("#0d1117") +const PANEL_WIDTH: float = 420.0 +const PANEL_MARGIN: float = 16.0 + +var _kind: String = "" +var _entity_id: String = "" +var _view: Dictionary = {} # BrowserAdapter.detail_view() result +var _loading: bool = false +var _error: String = "" +var _status: String = "" + +var _panel = null # ImplantPanel +var _implant_theme = null # ImplantTheme +var _pending: ImplantPending = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + + +func setup(implant_theme) -> void: + _implant_theme = implant_theme + _build_panel() + _build_pending(implant_theme) + + +func set_entity(kind: String, entity_id: String) -> void: + _kind = kind + _entity_id = entity_id + _view = {} + _loading = true + _error = "" + _status = "" + _rebuild_panel() + if _pending: + _pending.start("QUERYING " + BrowserAdapter.kind_label(kind)) + SimBridge.request_browse_detail(kind, entity_id) + + +## Feed a decoded BrowseResponse into this screen. Ignored if the response +## doesn't match this screen's current kind (stale response after the player +## navigated elsewhere). +func receive_response(response: Dictionary) -> void: + if response.get("kind", "") != _kind: + return + _loading = false + if _pending: + _pending.stop() + _status = BrowserAdapter.response_status(response) + if _status == "Error": + _error = BrowserAdapter.response_error(response) + _view = {} + elif _status == "NotFound": + _error = "" + _view = {} + else: + _error = "" + var payload: Dictionary = BrowserAdapter.detail_payload(response) + _view = BrowserAdapter.detail_view(_kind, payload) + _rebuild_panel() + + +func enter(payload: Dictionary) -> void: + var kind: String = payload.get("kind", "") + var entity_id: String = payload.get("entity_id", "") + if kind != _kind or entity_id != _entity_id: + set_entity(kind, entity_id) + else: + _rebuild_panel() + + +func leave() -> void: + pass + + +# ============================================================================= +# Panel +# ============================================================================= + + +func _build_panel() -> void: + _panel = ImplantPanel.new() + _panel.name = "DetailPanel" + _panel.theme_resource = _implant_theme + _panel.custom_minimum_size.x = PANEL_WIDTH + _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) + add_child(_panel) + + +func _build_pending(implant_theme) -> void: + _pending = ImplantPending.new() + _pending.apply_implant_theme(implant_theme) + _pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN) + add_child(_pending) + + +func _rebuild_panel() -> void: + if not _panel: + return + _panel.clear() + + if _loading: + _panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id)) + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new("querying registry…")) + elif not _error.is_empty(): + _panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id)) + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new("[ERROR] " + _error)) + elif _status == "NotFound": + _panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), _entity_id)) + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new("entry not found: " + _entity_id)) + else: + _rebuild_ready_panel() + + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new("esc back")) + + +func _rebuild_ready_panel() -> void: + var name_str: String = str(_view.get("name", _entity_id)) + var subtitle: String = str(_view.get("subtitle", BrowserAdapter.kind_label(_kind))) + _panel.add_component(ImplantHeader.new(name_str, subtitle)) + _panel.add_component(ImplantSeparator.new()) + + var rows: Array = _view.get("rows", []) + for row: Dictionary in rows: + var label: String = str(row.get("label", "")) + var value: String = str(row.get("value", "—")) + _panel.add_component(ImplantDataRow.new("%-22s %s" % [label, value])) + + var text_block: String = str(_view.get("text", "")) + if not text_block.is_empty(): + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new(text_block)) diff --git a/client/ui/implant/apps/browser/screens/index_screen.gd b/client/ui/implant/apps/browser/screens/index_screen.gd new file mode 100644 index 000000000..becd6f3ec --- /dev/null +++ b/client/ui/implant/apps/browser/screens/index_screen.gd @@ -0,0 +1,253 @@ +class_name BrowserIndexScreen +extends Control +## Index screen for one entity kind in the data browser (T-1133, D-254 §4). +## A scrollable ImplantDataRow list (primary + secondary summary column), +## filterable by a live-typed search string. One instance handles all six +## kinds — parameterized by set_kind(), not six near-duplicate screens. +## +## Filtering (D-254 §4: "filterable/searchable by name"): typed characters +## append to the search buffer and re-filter live; Backspace removes the +## last character; the implant has no OS textbox, so this is the same +## "keyboard IS the input surface" convention the Atlas/Economics apps +## already use for navigation. + +signal row_selected(entity_id: String) +signal back_requested + +const COLOR_BG: Color = Color("#0d1117") +const PANEL_WIDTH: float = 460.0 +const PANEL_MARGIN: float = 16.0 +const LIST_MAX_HEIGHT: float = 520.0 + +var _kind: String = "" +var _all_rows: Array = [] # BrowserAdapter row view-models, unfiltered +var _filtered_rows: Array = [] +var _selected_idx: int = 0 +var _search: String = "" +var _loading: bool = false +var _error: String = "" + +var _panel = null # ImplantPanel +var _scroll: ScrollContainer = null +var _list_box: VBoxContainer = null +var _implant_theme = null # ImplantTheme +var _row_labels: Array = [] # Array[ImplantDataRow], one per filtered row +var _pending: ImplantPending = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + + +func setup(implant_theme) -> void: + _implant_theme = implant_theme + _build_panel() + _build_pending(implant_theme) + + +## Set the entity kind this screen shows and (re)issue the index request. +## filter_system_id is forwarded to SimBridge.request_browse_index — only +## meaningful for kind == BrowserAdapter.KIND_BODY. +func set_kind(kind: String, filter_system_id: String = "") -> void: + _kind = kind + _all_rows = [] + _filtered_rows = [] + _selected_idx = 0 + _search = "" + _error = "" + _loading = true + _rebuild_list() + if _pending: + _pending.start("QUERYING " + BrowserAdapter.kind_label(kind)) + SimBridge.request_browse_index(kind, filter_system_id) + + +## Feed a decoded BrowseResponse (SimBridge.browse_response_received handler, +## owned by BrowserApp) into this screen. Ignored if the response's kind +## doesn't match what this screen currently shows (a stale response arriving +## after the player already navigated elsewhere). +func receive_response(response: Dictionary) -> void: + if response.get("kind", "") != _kind: + return + _loading = false + if _pending: + _pending.stop() + var status: String = BrowserAdapter.response_status(response) + if status == "Error": + _error = BrowserAdapter.response_error(response) + _all_rows = [] + else: + _error = "" + _all_rows = BrowserAdapter.index_rows(response) + _apply_filter() + + +func enter(payload: Dictionary) -> void: + var kind: String = payload.get("kind", "") + var filter_system_id: String = payload.get("filter_system_id", "") + if kind != _kind or _all_rows.is_empty(): + set_kind(kind, filter_system_id) + else: + _rebuild_list() + + +func leave() -> void: + pass + + +func current_kind() -> String: + return _kind + + +func has_selection() -> bool: + return not _filtered_rows.is_empty() + + +func selected_entity_id() -> String: + if _filtered_rows.is_empty(): + return "" + _selected_idx = clampi(_selected_idx, 0, _filtered_rows.size() - 1) + return str(_filtered_rows[_selected_idx].get("id", "")) + + +func trigger_enter() -> void: + if has_selection(): + row_selected.emit(selected_entity_id()) + + +func navigate(delta: int) -> void: + if _filtered_rows.is_empty(): + return + _selected_idx = wrapi(_selected_idx + delta, 0, _filtered_rows.size()) + _rebuild_list() + + +## Append a character to the live search filter. Called from BrowserApp's +## key handler for printable keys while this screen is current. +func append_search_char(ch: String) -> void: + _search += ch + _apply_filter() + + +func backspace_search() -> void: + if _search.is_empty(): + return + _search = _search.substr(0, _search.length() - 1) + _apply_filter() + + +func clear_search() -> void: + if _search.is_empty(): + return + _search = "" + _apply_filter() + + +func has_search_text() -> bool: + return not _search.is_empty() + + +func _apply_filter() -> void: + _filtered_rows = BrowserAdapter.filter_rows(_all_rows, _search) + _selected_idx = 0 + _rebuild_list() + + +# ============================================================================= +# Panel / list +# ============================================================================= + + +func _build_panel() -> void: + _panel = ImplantPanel.new() + _panel.name = "IndexPanel" + _panel.theme_resource = _implant_theme + _panel.custom_minimum_size.x = PANEL_WIDTH + _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) + add_child(_panel) + + +func _build_pending(implant_theme) -> void: + _pending = ImplantPending.new() + _pending.apply_implant_theme(implant_theme) + _pending.position = Vector2(PANEL_MARGIN + PANEL_WIDTH + 24.0, PANEL_MARGIN) + add_child(_pending) + + +func _rebuild_list() -> void: + if not _panel: + return + _panel.clear() + _row_labels.clear() + + var subtitle: String = "%d / %d" % [_filtered_rows.size(), _all_rows.size()] + _panel.add_component(ImplantHeader.new(BrowserAdapter.kind_label(_kind), subtitle)) + _panel.add_component(ImplantSeparator.new()) + + var search_line: String = "search " + (_search if not _search.is_empty() else "—") + _panel.add_component(ImplantDataRow.new(search_line)) + _panel.add_component(ImplantSeparator.new()) + + if _loading: + _panel.add_component(ImplantTextBlock.new("querying registry…")) + elif not _error.is_empty(): + _panel.add_component(ImplantTextBlock.new("[ERROR] " + _error)) + elif _filtered_rows.is_empty(): + var msg: String = "no matches" if not _search.is_empty() else "no entries" + _panel.add_component(ImplantTextBlock.new(msg)) + else: + _build_scroll_list() + + _panel.add_component(ImplantSeparator.new()) + _panel.add_component( + ImplantTextBlock.new("↑ ↓ select enter open type search esc back") + ) + + +func _build_scroll_list() -> void: + _scroll = ScrollContainer.new() + _scroll.custom_minimum_size = Vector2(PANEL_WIDTH, minf(LIST_MAX_HEIGHT, _list_natural_height())) + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _panel.add_component(_scroll) + + _list_box = VBoxContainer.new() + _list_box.add_theme_constant_override("separation", 0) + _list_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _scroll.add_child(_list_box) + + for i: int in range(_filtered_rows.size()): + var row: Dictionary = _filtered_rows[i] + var primary: String = str(row.get("primary", "")) + var secondary: String = str(row.get("secondary", "")) + var text: String = primary + if not secondary.is_empty(): + text += " · " + secondary + var prefix: String = "▸ " if i == _selected_idx else " " + var label := ImplantDataRow.new(prefix + text) + if _implant_theme: + label.apply_implant_theme(_implant_theme) + _list_box.add_child(label) + _row_labels.append(label) + + _scroll_to_selected() + + +func _list_natural_height() -> float: + var line_h: float = float(_implant_theme.line_height) if _implant_theme else 18.0 + return float(_filtered_rows.size()) * line_h + + +func _scroll_to_selected() -> void: + if not _scroll or _row_labels.is_empty(): + return + _selected_idx = clampi(_selected_idx, 0, _row_labels.size() - 1) + var target: Control = _row_labels[_selected_idx] + # ensure_control_visible needs one layout pass to have valid rects on a + # freshly-built list — defer so the ScrollContainer has sized itself. + _scroll.ensure_control_visible.call_deferred(target) diff --git a/client/ui/implant/apps/browser/screens/kind_menu_screen.gd b/client/ui/implant/apps/browser/screens/kind_menu_screen.gd new file mode 100644 index 000000000..b1a509163 --- /dev/null +++ b/client/ui/implant/apps/browser/screens/kind_menu_screen.gd @@ -0,0 +1,87 @@ +class_name BrowserKindMenuScreen +extends Control +## Root screen for the data browser app (T-1133, D-254 §4). +## Six-entity kind picker — the browser's "reach" screen equivalent. Emits +## kind_selected when the player commits to one of the six v1 entity kinds. + +signal kind_selected(kind: String) + +const COLOR_BG: Color = Color("#0d1117") +const PANEL_WIDTH: float = 340.0 +const PANEL_MARGIN: float = 16.0 + +var _selected_idx: int = 0 +var _panel = null # ImplantPanel +var _rows: Array = [] # Array[ImplantDataRow], one per BrowserAdapter.KIND_ORDER entry + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + + +func setup(implant_theme) -> void: + _build_panel(implant_theme) + + +func enter(_payload: Dictionary) -> void: + _rebuild_panel() + + +func leave() -> void: + pass + + +func navigate(delta: int) -> void: + var count: int = BrowserAdapter.KIND_ORDER.size() + _selected_idx = wrapi(_selected_idx + delta, 0, count) + _rebuild_panel() + + +func trigger_enter() -> void: + kind_selected.emit(BrowserAdapter.KIND_ORDER[_selected_idx]) + + +func current_kind() -> String: + return BrowserAdapter.KIND_ORDER[_selected_idx] + + +# ============================================================================= +# Panel +# ============================================================================= + + +func _build_panel(implant_theme) -> void: + _panel = ImplantPanel.new() + _panel.name = "KindMenuPanel" + _panel.theme_resource = implant_theme + _panel.custom_minimum_size.x = PANEL_WIDTH + _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel.position = Vector2(PANEL_MARGIN, PANEL_MARGIN) + add_child(_panel) + _rebuild_panel() + + +func _rebuild_panel() -> void: + if not _panel: + return + _panel.clear() + _rows.clear() + + _panel.add_component(ImplantHeader.new("DATA BROWSER", "REGISTRY INDEX")) + _panel.add_component(ImplantSeparator.new()) + + for i: int in range(BrowserAdapter.KIND_ORDER.size()): + var kind: String = BrowserAdapter.KIND_ORDER[i] + var label: String = BrowserAdapter.kind_label(kind) + var prefix: String = "▸ " if i == _selected_idx else " " + var row := ImplantDataRow.new(prefix + label) + _panel.add_component(row) + _rows.append(row) + + _panel.add_component(ImplantSeparator.new()) + _panel.add_component(ImplantTextBlock.new("↑ ↓ select enter open esc close")) -- 2.54.0 From 2509624577d39b62d170472468baec74795bb812 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 10:37:15 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(engine):=20resolve=20systems.db=20exe-a?= =?UTF-8?q?nchored,=20not=20cwd-relative=20=E2=80=94=20spawn-mode=20DB=20a?= =?UTF-8?q?ccess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PathBuf::from("data/systems.db") was cwd-relative, and a spawned server inherits GODOT's cwd — which is client/ (Godot's --path client chdirs), so CultureResolver, CityContextReader, BodySourceResolver and BrowseReader all silently failed in every make atlas / make game spawn. Wave 1 masked it: the star map serves from star_map_data.json, so 'renders 301 systems' never touched the DB; T-1133's browser was the first DB-backed consumer to hit it live (Stig, /proc/PID/cwd). resolve_systems_db_path(): exe-anchored /../../data/systems.db first (cwd-independent — the actual fix), then cwd-relative data/systems.db (cd server && cargo run), then server/data/systems.db (repo-root invocations); first existing candidate wins, info-logged. Pure systems_db_candidates() split out as the testable seam (5 unit tests — incl. pinning Path::parent() of a bare filename = Some(""), not None). world_root now derives from the resolved ABSOLUTE path so its ancestors().nth(3) arithmetic holds unconditionally instead of silently falling back to ".." when canonicalize failed. Verified all three launch styles; make atlas spawn shows 5 reader fds open on server/data/systems.db via /proc/PID/fd. Co-Authored-By: Claude Fable 5 --- server/src/main.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 263 insertions(+), 2 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index f27158c64..db4a5ee7c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -180,7 +180,7 @@ fn main() { // Initialize culture resolver (#679, D-128). // systems.db is shipped read-only alongside the binary. - let systems_db_path = std::path::PathBuf::from("data/systems.db"); + let systems_db_path = resolve_systems_db_path(); match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) { Ok(resolver) => { tracing::info!("Culture resolver opened: {:?}", systems_db_path); @@ -198,7 +198,16 @@ fn main() { // Mod-first body source resolver for the atlas layer proxy (#969, D-225). // terrain_reference is repo-root-relative; the repo root is systems.db's - // 3rd ancestor (/server/data/systems.db). + // 3rd ancestor (/server/data/systems.db). This ancestor arithmetic + // is only correct against an ABSOLUTE path — `.canonicalize()` resolves a + // *relative* path against the CURRENT CWD, so if `systems_db_path` were + // still cwd-relative (as it was before `resolve_systems_db_path()`, T-1131 + // follow-up), a wrong-cwd launch (e.g. the D-254 companion spawning from + // the repo root) would make `.canonicalize()` fail outright, falling back + // to the nonsense `".."` default below. `resolve_systems_db_path()` + // already verified `systems_db_path` exists before returning it, so + // `.canonicalize()` here always succeeds and `nth(3)` is genuinely correct + // — not "pretends to work by accident when cwd happens to be server/". let world_root = systems_db_path .canonicalize() .ok() @@ -484,6 +493,258 @@ fn main() { tracing::info!("Simulation server shutting down"); } +/// Candidate `systems.db` paths, in try-order, for a given executable path +/// (T-1131 follow-up). Pure/no I/O — the ONLY thing that makes this +/// deterministic and unit-testable given `exe_path` (unlike +/// [`resolve_systems_db_path`], which additionally calls +/// `std::env::current_exe()` and stats the filesystem). Kept separate +/// specifically so the candidate ORDER and SHAPE can be tested without a +/// process-spawning harness — see `tests::` below. +/// +/// 1. Exe-anchored `/../../data/systems.db` (unjoined — the caller +/// canonicalizes and existence-checks; this function never touches disk). +/// Only present if `exe_path` has a parent directory. +/// 2. Cwd-relative `data/systems.db` (today's pre-fix behavior — +/// `cd server && cargo run` leaves cwd at `server/`). +/// 3. Cwd-relative `server/data/systems.db` (repo-root invocations). +fn systems_db_candidates(exe_path: Option<&std::path::Path>) -> Vec { + let mut candidates = Vec::with_capacity(3); + if let Some(exe_dir) = exe_path.and_then(std::path::Path::parent) { + candidates.push(exe_dir.join("../../data/systems.db")); + } + candidates.push(std::path::PathBuf::from("data/systems.db")); + candidates.push(std::path::PathBuf::from("server/data/systems.db")); + candidates +} + +#[cfg(test)] +mod tests { + use super::*; + + /// T-1131 follow-up: the exe-anchored candidate must resolve to + /// `server/data/systems.db` from the DEV BUILD LAYOUT exe path + /// (`server/target/debug/settled-reach-server`) — this is the whole + /// point of the fix, so pin the exact join shape, not just "some path + /// containing systems.db". + #[test] + fn exe_anchored_candidate_targets_server_data_from_dev_build_layout() { + let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); + let candidates = systems_db_candidates(Some(exe)); + + assert_eq!( + candidates.len(), + 3, + "exe with a parent dir must produce all three candidates" + ); + assert_eq!( + candidates[0], + std::path::PathBuf::from("/repo/server/target/debug/../../data/systems.db"), + "exe-anchored candidate must be unjoined (caller canonicalizes) \ + but built from exe_dir/../../data/systems.db" + ); + + // The whole point: once normalized (what canonicalize() does at + // runtime against a real filesystem), this lands on + // /repo/server/data/systems.db — the actual DB location — not + // /repo/data/systems.db (the pre-fix cwd-relative bug's target). + let normalized = normalize_lexically(&candidates[0]); + assert_eq!( + normalized, + std::path::PathBuf::from("/repo/server/data/systems.db") + ); + } + + /// The two cwd-relative fallback candidates are present regardless of + /// whether an exe path resolved, in the documented order: `data/systems.db` + /// before `server/data/systems.db` (today's pre-fix behavior stays the + /// first fallback, not silently reordered behind the new repo-root case). + #[test] + fn cwd_relative_candidates_present_and_ordered_when_exe_path_is_some() { + let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!(candidates[1], std::path::PathBuf::from("data/systems.db")); + assert_eq!( + candidates[2], + std::path::PathBuf::from("server/data/systems.db") + ); + } + + /// `current_exe()` can fail (documented caveat, e.g. sandboxed + /// environments) — `None` must degrade to exactly the two cwd-relative + /// candidates, not panic or produce a malformed exe-anchored entry. + #[test] + fn no_exe_path_yields_only_the_two_cwd_relative_candidates() { + let candidates = systems_db_candidates(None); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); + assert_eq!( + candidates[1], + std::path::PathBuf::from("server/data/systems.db") + ); + } + + /// An exe path that IS genuinely parentless (`Path::parent()` returns + /// `None` only for the empty path or filesystem root — confirmed against + /// the standard library, not assumed) must not panic and must degrade + /// the same as `exe_path: None`. + #[test] + fn genuinely_parentless_exe_path_degrades_like_no_exe_path() { + let exe = std::path::Path::new(""); + assert!( + exe.parent().is_none(), + "test premise: Path::new(\"\").parent() must be None" + ); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); + } + + /// A bare relative filename with no directory separator (e.g. the exe + /// path Godot's `OS.create_process` might report on some platform/launch + /// combination) is NOT the parentless case above — `Path::parent()` + /// returns `Some("")` for it (an empty-but-present parent), a real + /// standard-library quirk worth pinning explicitly since it's easy to + /// assume `.parent()` is `None` whenever there's "no directory in the + /// string". The exe-anchored candidate still gets produced (joined onto + /// the empty parent), just degenerately — `../../data/systems.db` + /// relative to cwd, which is harmless: it'll fail existence-checks + /// exactly like any other wrong candidate and fall through the loop. + #[test] + fn bare_filename_exe_path_has_an_empty_but_present_parent() { + let exe = std::path::Path::new("settled-reach-server"); + assert_eq!( + exe.parent(), + Some(std::path::Path::new("")), + "Path::parent() of a bare filename is Some(\"\"), not None — \ + pinning this stdlib behavior since it's the reason a bare \ + filename still produces 3 candidates, not 2" + ); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!( + candidates.len(), + 3, + "a present-but-empty parent still yields an exe-anchored candidate" + ); + assert_eq!( + candidates[0], + std::path::PathBuf::from("../../data/systems.db"), + "joined onto an empty parent, the exe-anchored candidate is bare \ + ../../data/systems.db (cwd-relative in practice, but still a \ + DISTINCT candidate from candidates[1]'s exact data/systems.db)" + ); + } + + /// Lexical `..`/`.` normalization for test assertions ONLY — a stand-in + /// for `Path::canonicalize()` (which needs a real filesystem + cwd, + /// which unit tests must not depend on per the coordinator's "don't + /// build a process-spawning/filesystem harness for this" guidance). + /// `resolve_systems_db_path` itself still uses the real + /// `canonicalize()` at runtime — this helper exists only so + /// `exe_anchored_candidate_targets_server_data_from_dev_build_layout` + /// can assert the join shape actually lands on the right final path + /// without touching disk. + fn normalize_lexically(path: &std::path::Path) -> std::path::PathBuf { + let mut out = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out + } +} + +/// Resolve `systems.db`'s path, ANCHORED TO THE EXECUTABLE rather than the +/// current working directory (T-1131 follow-up). +/// +/// **The bug this fixes:** `PathBuf::from("data/systems.db")` is cwd-relative. +/// `make game` (`cd server && cargo run`) happens to leave cwd at `server/`, +/// so that path resolves — but the D-254 companion app spawns this binary via +/// Godot's `OS.create_process`/`OS.execute_with_pipe` (`server_process.gd`), +/// neither of which sets a working directory: the child inherits GODOT's cwd, +/// which for `make atlas`/`make game` is the REPO ROOT (the Makefile has no +/// `cd` before launching Godot itself — only before `cargo run`). From the +/// repo root, `data/systems.db` doesn't exist (it's `server/data/systems.db`), +/// so every DB-backed reader (`CultureResolver`, `CityContextReader`, and now +/// `BrowseReader`) silently fails to open in every spawned-server context. +/// This went unnoticed through T-1130's wave 1 because the star map is served +/// from `star_map_data.json` via `world_root` (itself derived from +/// `systems_db_path`, so ALSO broken — but `.exists()`-checked with a `warn`, +/// not a hard dependency any single-connection smoke test would surface) — +/// "renders 301 systems" never actually touched `systems.db`. +/// +/// **The fix:** resolve relative to `std::env::current_exe()` first — in the +/// dev build layout the binary is `server/target/debug/settled-reach-server`, +/// so `exe_dir/../../data/systems.db` is `server/data/systems.db` regardless +/// of cwd. Falls through to the two cwd-relative candidates (today's +/// behavior, and the repo-root equivalent) so `cd server && cargo run` and a +/// repo-root-relative invocation both keep working without needing the +/// exe-anchoring to succeed (e.g. `current_exe()` can fail in exotic +/// sandboxed environments per its own documented caveats). +/// +/// Candidate order/shape lives in [`systems_db_candidates`] (pure, +/// unit-tested); this function adds the I/O layer: canonicalize + existence +/// check per candidate, first EXISTING one wins, with an `info` log +/// recording which candidate resolved (so a future "browse reader +/// unavailable" report is diagnosable from the startup log alone). +/// +/// If none exist, returns the cwd-relative `data/systems.db` default — +/// today's pre-fix behavior — so every downstream `Reader::open()` call +/// still gets a path to fail on and log its own existing +/// `warn`-and-degrade message. This function does not invent a new failure +/// mode, it just tries harder before giving up. +fn resolve_systems_db_path() -> std::path::PathBuf { + let exe_path = std::env::current_exe().ok(); + let candidates = systems_db_candidates(exe_path.as_deref()); + + for candidate in &candidates { + let canonical = candidate.canonicalize(); + if let Ok(ref resolved) = canonical { + if resolved.exists() { + tracing::info!( + "systems.db resolved: {:?} (candidate: {:?}, exe: {:?})", + resolved, + candidate, + exe_path + ); + return resolved.clone(); + } + } else if candidate.exists() { + // canonicalize() can fail even when the path exists (e.g. a + // component permission error) — exists() is the true signal; + // canonicalize() is just how we get an absolute path for + // world_root's ancestor arithmetic to work correctly. + tracing::info!( + "systems.db resolved (uncanonicalized): {:?} (exe: {:?})", + candidate, + exe_path + ); + return candidate.clone(); + } + } + + // None of the candidates exist. Fall back to the cwd-relative + // `data/systems.db` default — today's pre-fix behavior — NOT the + // exe-anchored candidate (which, per systems_db_candidates' doc, is + // unjoined/uncanonicalized and only meaningful once verified to exist; + // returning it here unverified would be a worse default than the plain + // relative path every downstream Reader::open() already knows how to + // fail on cleanly). + let fallback = std::path::PathBuf::from("data/systems.db"); + tracing::warn!( + "systems.db not found via any of {:?} (exe: {:?}) — falling back to {:?} \ + (every DB-backed reader will report unavailable and degrade)", + candidates, + exe_path, + fallback + ); + fallback +} + /// Best-effort: send a final SimError snapshot to the client on panic (#85). /// /// Builds a minimal ObserverSnapshot with the panic error and sends it -- 2.54.0 From 7111cea5492ff90c6b38f0500519618c64fc34f2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 10:42:06 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(engine):=20clippy=20items=5Fafter=5Ftes?= =?UTF-8?q?t=5Fmodule=20=E2=80=94=20tests=20mod=20to=20end=20of=20main.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- server/src/main.rs | 282 ++++++++++++++++++++++----------------------- 1 file changed, 141 insertions(+), 141 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index db4a5ee7c..891f0deac 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -517,147 +517,6 @@ fn systems_db_candidates(exe_path: Option<&std::path::Path>) -> Vec std::path::PathBuf { - let mut out = std::path::PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::ParentDir => { - out.pop(); - } - std::path::Component::CurDir => {} - other => out.push(other.as_os_str()), - } - } - out - } -} - /// Resolve `systems.db`'s path, ANCHORED TO THE EXECUTABLE rather than the /// current working directory (T-1131 follow-up). /// @@ -1071,3 +930,144 @@ fn setup_proof_room(app: &mut App, world_seed: u64) { app.insert_resource(registry); } + +#[cfg(test)] +mod tests { + use super::*; + + /// T-1131 follow-up: the exe-anchored candidate must resolve to + /// `server/data/systems.db` from the DEV BUILD LAYOUT exe path + /// (`server/target/debug/settled-reach-server`) — this is the whole + /// point of the fix, so pin the exact join shape, not just "some path + /// containing systems.db". + #[test] + fn exe_anchored_candidate_targets_server_data_from_dev_build_layout() { + let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); + let candidates = systems_db_candidates(Some(exe)); + + assert_eq!( + candidates.len(), + 3, + "exe with a parent dir must produce all three candidates" + ); + assert_eq!( + candidates[0], + std::path::PathBuf::from("/repo/server/target/debug/../../data/systems.db"), + "exe-anchored candidate must be unjoined (caller canonicalizes) \ + but built from exe_dir/../../data/systems.db" + ); + + // The whole point: once normalized (what canonicalize() does at + // runtime against a real filesystem), this lands on + // /repo/server/data/systems.db — the actual DB location — not + // /repo/data/systems.db (the pre-fix cwd-relative bug's target). + let normalized = normalize_lexically(&candidates[0]); + assert_eq!( + normalized, + std::path::PathBuf::from("/repo/server/data/systems.db") + ); + } + + /// The two cwd-relative fallback candidates are present regardless of + /// whether an exe path resolved, in the documented order: `data/systems.db` + /// before `server/data/systems.db` (today's pre-fix behavior stays the + /// first fallback, not silently reordered behind the new repo-root case). + #[test] + fn cwd_relative_candidates_present_and_ordered_when_exe_path_is_some() { + let exe = std::path::Path::new("/repo/server/target/debug/settled-reach-server"); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!(candidates[1], std::path::PathBuf::from("data/systems.db")); + assert_eq!( + candidates[2], + std::path::PathBuf::from("server/data/systems.db") + ); + } + + /// `current_exe()` can fail (documented caveat, e.g. sandboxed + /// environments) — `None` must degrade to exactly the two cwd-relative + /// candidates, not panic or produce a malformed exe-anchored entry. + #[test] + fn no_exe_path_yields_only_the_two_cwd_relative_candidates() { + let candidates = systems_db_candidates(None); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); + assert_eq!( + candidates[1], + std::path::PathBuf::from("server/data/systems.db") + ); + } + + /// An exe path that IS genuinely parentless (`Path::parent()` returns + /// `None` only for the empty path or filesystem root — confirmed against + /// the standard library, not assumed) must not panic and must degrade + /// the same as `exe_path: None`. + #[test] + fn genuinely_parentless_exe_path_degrades_like_no_exe_path() { + let exe = std::path::Path::new(""); + assert!( + exe.parent().is_none(), + "test premise: Path::new(\"\").parent() must be None" + ); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], std::path::PathBuf::from("data/systems.db")); + } + + /// A bare relative filename with no directory separator (e.g. the exe + /// path Godot's `OS.create_process` might report on some platform/launch + /// combination) is NOT the parentless case above — `Path::parent()` + /// returns `Some("")` for it (an empty-but-present parent), a real + /// standard-library quirk worth pinning explicitly since it's easy to + /// assume `.parent()` is `None` whenever there's "no directory in the + /// string". The exe-anchored candidate still gets produced (joined onto + /// the empty parent), just degenerately — `../../data/systems.db` + /// relative to cwd, which is harmless: it'll fail existence-checks + /// exactly like any other wrong candidate and fall through the loop. + #[test] + fn bare_filename_exe_path_has_an_empty_but_present_parent() { + let exe = std::path::Path::new("settled-reach-server"); + assert_eq!( + exe.parent(), + Some(std::path::Path::new("")), + "Path::parent() of a bare filename is Some(\"\"), not None — \ + pinning this stdlib behavior since it's the reason a bare \ + filename still produces 3 candidates, not 2" + ); + let candidates = systems_db_candidates(Some(exe)); + assert_eq!( + candidates.len(), + 3, + "a present-but-empty parent still yields an exe-anchored candidate" + ); + assert_eq!( + candidates[0], + std::path::PathBuf::from("../../data/systems.db"), + "joined onto an empty parent, the exe-anchored candidate is bare \ + ../../data/systems.db (cwd-relative in practice, but still a \ + DISTINCT candidate from candidates[1]'s exact data/systems.db)" + ); + } + + /// Lexical `..`/`.` normalization for test assertions ONLY — a stand-in + /// for `Path::canonicalize()` (which needs a real filesystem + cwd, + /// which unit tests must not depend on per the coordinator's "don't + /// build a process-spawning/filesystem harness for this" guidance). + /// `resolve_systems_db_path` itself still uses the real + /// `canonicalize()` at runtime — this helper exists only so + /// `exe_anchored_candidate_targets_server_data_from_dev_build_layout` + /// can assert the join shape actually lands on the right final path + /// without touching disk. + fn normalize_lexically(path: &std::path::Path) -> std::path::PathBuf { + let mut out = std::path::PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out + } +} -- 2.54.0 From 132915a4e5b9df9bfa721cd06c5da2a0e7b8359b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 17 Jul 2026 10:53:36 +0200 Subject: [PATCH 5/5] test(engine): browse-inclusive union-frame rejection cases (PR #184 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe's finding: the H1 union-rejection mechanism correctly counts the new browse discriminator, but ambiguous_union_frame_is_rejected was carried over from PR #176 without a browse-inclusive case — a future demux/ShapeProbe refactor could drop the fifth shape from the union check with nothing to catch it. Adds browse+star_map and browse+city_names union frames, both asserting rejection. Co-Authored-By: Claude Fable 5 --- server/src/bridge/mod.rs | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 6a17eabd9..e9d276944 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1316,5 +1316,51 @@ mod inbound_tests { decode_inbound(&frame).is_err(), "atlas+star_map union frame must be rejected" ); + + // T-1131 (PR #184 review): the FIFTH shape's discriminator (`browse`) + // must participate in the same union rejection — a well-formed + // BrowseRequest smuggling another shape's discriminator alongside it + // is rejected, not routed to whichever probe wins. + #[derive(serde::Serialize)] + struct BrowseAndStar { + browse: bool, + kind: crate::atlas::browse_proxy::BrowseEntityKind, + query: crate::atlas::browse_proxy::BrowseQuery, + star_map: bool, + } + let frame = rmp_serde::to_vec_named(&BrowseAndStar { + browse: true, + kind: crate::atlas::browse_proxy::BrowseEntityKind::StarSystem, + query: crate::atlas::browse_proxy::BrowseQuery::Index { + filter_system_id: None, + }, + star_map: true, + }) + .unwrap(); + assert!( + decode_inbound(&frame).is_err(), + "browse+star_map union frame must be rejected" + ); + + #[derive(serde::Serialize)] + struct BrowseAndCity { + browse: bool, + kind: crate::atlas::browse_proxy::BrowseEntityKind, + query: crate::atlas::browse_proxy::BrowseQuery, + city_names: bool, + body_id: String, + } + let frame = rmp_serde::to_vec_named(&BrowseAndCity { + browse: true, + kind: crate::atlas::browse_proxy::BrowseEntityKind::Body, + query: crate::atlas::browse_proxy::BrowseQuery::Detail { id: "GJ1c".into() }, + city_names: true, + body_id: "GJ1c".into(), + }) + .unwrap(); + assert!( + decode_inbound(&frame).is_err(), + "browse+city_names union frame must be rejected" + ); } } -- 2.54.0