feat(engine): T-1131 browse data proxy — six entity kinds, index+detail, one wire envelope (D-254 SS4)
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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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<BrowseRequestBuffer>,
|
||||
mut responses: ResMut<BrowseResponseBuffer>,
|
||||
browse_reader: Option<Res<BrowseReaderResource>>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+98
-11
@@ -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<serde::de::IgnoredAny>,
|
||||
star_map: Option<serde::de::IgnoredAny>,
|
||||
city_names: Option<serde::de::IgnoredAny>,
|
||||
browse: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949). Tries,
|
||||
/// in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
|
||||
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131).
|
||||
/// Tries, in order: `Vec<PlayerInput>` (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<Inbound, BridgeError> {
|
||||
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
|
||||
return Ok(Inbound::Inputs(inputs));
|
||||
@@ -114,15 +126,20 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
|
||||
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<Inbound, BridgeError> {
|
||||
if let Ok(req) = rmp_serde::from_slice::<StarMapRequest>(payload) {
|
||||
return Ok(Inbound::StarMapRequest(req));
|
||||
}
|
||||
match rmp_serde::from_slice::<CityNamesRequest>(payload) {
|
||||
Ok(req) => Ok(Inbound::CityNamesRequest(req)),
|
||||
if let Ok(req) = rmp_serde::from_slice::<CityNamesRequest>(payload) {
|
||||
return Ok(Inbound::CityNamesRequest(req));
|
||||
}
|
||||
match rmp_serde::from_slice::<BrowseRequest>(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<AtlasRequestBuffer>,
|
||||
mut star_map_requests: ResMut<StarMapRequestBuffer>,
|
||||
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
|
||||
mut browse_requests: ResMut<BrowseRequestBuffer>,
|
||||
time: Option<Res<crate::simulation::time::SimulationTime>>,
|
||||
) {
|
||||
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<Res<BridgeResource>>,
|
||||
mut buffer: ResMut<BrowseResponseBuffer>,
|
||||
) {
|
||||
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::<StarMapResponseBuffer>()
|
||||
.init_resource::<CityNamesRequestBuffer>()
|
||||
.init_resource::<CityNamesResponseBuffer>()
|
||||
.init_resource::<BrowseRequestBuffer>()
|
||||
.init_resource::<BrowseResponseBuffer>()
|
||||
.init_resource::<ConnectionListener>()
|
||||
.init_resource::<PendingConnections>()
|
||||
// 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user