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 <noreply@anthropic.com>
1049 lines
42 KiB
Rust
1049 lines
42 KiB
Rust
//! 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<String> },
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<Vec<BrowseIndexRow>>,
|
|
/// Populated iff the request's `query` was `Detail` and `status ==
|
|
/// Ready`.
|
|
pub detail: Option<BrowseDetail>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<String>,
|
|
pub system_name: Option<String>,
|
|
pub star_type: Option<String>,
|
|
pub spectral_class: Option<String>,
|
|
pub dist_ly: Option<f64>,
|
|
pub geographic_sector: Option<String>,
|
|
pub geographic_band: Option<String>,
|
|
pub political_zone: Option<String>,
|
|
pub habitable_planet_count: Option<i64>,
|
|
pub inhabited_planet_count: Option<i64>,
|
|
pub asteroid_belt: Option<bool>,
|
|
pub gas_giant: Option<bool>,
|
|
pub habitability_profile: Option<String>,
|
|
pub earth_alignment: Option<String>,
|
|
pub earth_proximity: Option<String>,
|
|
pub earth_tension: Option<String>,
|
|
pub stability_index: Option<i64>,
|
|
pub system_volatility: Option<String>,
|
|
pub cultural_corridor: Option<String>,
|
|
pub currency_zone: Option<String>,
|
|
pub economic_tier: Option<i64>,
|
|
pub population: Option<i64>,
|
|
pub economic_base_primary: Option<String>,
|
|
pub economic_base_secondary: Option<String>,
|
|
pub governance_type: Option<String>,
|
|
pub dominant_faction: Option<String>,
|
|
pub cultural_register: Option<String>,
|
|
pub atmospheric_tone: Option<String>,
|
|
pub primary_archetype: Option<String>,
|
|
}
|
|
|
|
impl From<StarSystemDetailRow> 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<String>,
|
|
pub body_type: String,
|
|
pub orbit_index: Option<i64>,
|
|
pub proper_name: Option<String>,
|
|
pub mass_class: Option<String>,
|
|
pub atmosphere: Option<String>,
|
|
pub surface_gravity: Option<f64>,
|
|
pub orbital_period_days: Option<f64>,
|
|
pub rotation_period_hours: Option<f64>,
|
|
pub planet_class: Option<String>,
|
|
pub hydrosphere: Option<String>,
|
|
pub biosphere_class: Option<String>,
|
|
pub inhabited: bool,
|
|
pub population: Option<i64>,
|
|
pub economic_role: Option<String>,
|
|
pub founding_age_years: Option<i64>,
|
|
pub settlement_pattern: Option<String>,
|
|
pub cultural_corridor: Option<String>,
|
|
pub industrial_corridor: Option<String>,
|
|
pub body_radius_km: Option<f64>,
|
|
pub axial_tilt_deg: Option<f64>,
|
|
}
|
|
|
|
impl From<BodyDetailRow> 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<String>,
|
|
pub station_type: String,
|
|
pub proper_name: Option<String>,
|
|
pub population: Option<i64>,
|
|
pub economic_role: Option<String>,
|
|
pub governance_type: Option<String>,
|
|
pub docking_class: Option<String>,
|
|
pub has_gate_infrastructure: bool,
|
|
pub district_count: Option<i64>,
|
|
}
|
|
|
|
impl From<StationDetailRow> 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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct CorporationDetail {
|
|
pub corp_id: String,
|
|
pub proper_name: String,
|
|
pub corp_type: String,
|
|
pub scope: Option<String>,
|
|
pub headquarters_system: Option<String>,
|
|
pub headquarters_body: Option<String>,
|
|
pub specialization: Option<String>,
|
|
pub parent_corp: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub behavioral_archetype: Option<String>,
|
|
pub supply_chain_role: Option<String>,
|
|
pub shadow_economy_access: bool,
|
|
pub corp_specialization: Option<String>,
|
|
pub hq_placement: Option<String>,
|
|
pub health_metric: Option<f64>,
|
|
pub presence: Vec<CorpPresenceEntry>,
|
|
}
|
|
|
|
impl From<CorporationDetailRow> 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<String>,
|
|
pub inputs: Vec<ChainInputEntry>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub unit: Option<String>,
|
|
pub production_ubiquity: Option<String>,
|
|
pub demand_model: Option<String>,
|
|
pub commission_certifiable: bool,
|
|
pub compact_contested: bool,
|
|
pub shadow_viable: bool,
|
|
pub panic_threshold_weeks: Option<i64>,
|
|
pub description: Option<String>,
|
|
pub produced_by: Vec<ProductionChainEntry>,
|
|
}
|
|
|
|
impl From<CommodityDetailRow> 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<String>,
|
|
pub corridor_pool: String,
|
|
pub geographic_sector: Option<String>,
|
|
pub bulk_class_gate: Option<String>,
|
|
pub production_ubiquity_gate: Option<String>,
|
|
pub min_prosperity_bps: i64,
|
|
pub base_weight: i64,
|
|
pub weight_mods: Option<String>,
|
|
pub zone_affinity: Option<String>,
|
|
pub allow_tags: Option<String>,
|
|
pub block_tags: Option<String>,
|
|
pub era_scope: Option<String>,
|
|
pub visual_bundle: Option<String>,
|
|
}
|
|
|
|
impl From<TraitTemplateDetailRow> 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<String>) -> 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::<crate::atlas::atlas_data_proxy::CityNamesRequest>(&frame)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
rmp_serde::from_slice::<crate::atlas::layer_proxy::AtlasLayerRequest>(&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:?}"),
|
|
}
|
|
}
|
|
}
|