AtlasLayerRequest gains window_center/window_n (serde-default, absent = whole-body, wire back-compat; demux untouched — up_to stays the discriminator). DistrictWindowLayer echoes center/n + six parallel arrays (morphology, elev_q, temp_dc i16 with the region sentinel, moisture_q, vegetation incl Marine=6, glaciation). Serving per the amendment's binding model: NEVER inline — GenWorkItem::DeriveWindow rides the Rayon queue, completion drain caches into DistrictWindowCache (bounded FIFO 256; no staleness by D-227 purity, capacity bound only), serve_district_window polls the cache and returns Pending-shaped None until derived. Per-connection coalescing: submit_window supersedes a still-pending item for the same (ConnectionId, body) — the surviving item is the newer one, proven by dedicated tests. TerrainAnalysis decision (option b, numbers in ticket/PR): re-derive via run_layer1 in the DeriveWindow branch rather than caching ~1.5MB x 50 LRU slots (~100MB permanent, the exact D-203 bloat T-1044's own text guarded against); ~45ms one-time on the Rayon path, invisible to the tick thread. T-1044 confirmed within-cascade-only (cascade.rs:358 still drops the analysis before BodyWorldState) — the fork was open. Window derive loop promoted from aliveness_probe::render_window_panels; determinism promoted from probe-only proof to a real test (two passes byte-identical). New fixture atlas_response_ready_with_window exercises all six arrays incl. the airless sentinel and Marine; three existing fixtures gain district_window: None. Server clamps window_n to 1..=DISTRICT_WINDOW_MAX_N=64 (never trust the wire). 1774/1774 lib + 19/19 bridge_tcp green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1371 lines
60 KiB
Rust
1371 lines
60 KiB
Rust
// Bridge module - Client-server communication
|
|
// Implements D-020 subprocess/IPC architecture
|
|
// MessagePack serialization for Rust<->Godot communication
|
|
|
|
use bevy_app::prelude::*;
|
|
use bevy_ecs::prelude::*;
|
|
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;
|
|
|
|
pub mod debug;
|
|
pub mod framing;
|
|
pub mod local;
|
|
pub mod tcp;
|
|
pub mod text_renderer;
|
|
pub mod types;
|
|
pub use types::*;
|
|
|
|
/// Error type for bridge operations
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum BridgeError {
|
|
#[error("serialization error: {0}")]
|
|
Serialization(#[from] rmp_serde::encode::Error),
|
|
#[error("deserialization error: {0}")]
|
|
Deserialization(#[from] rmp_serde::decode::Error),
|
|
#[error("deserialization error (raw bytes logged): {0}")]
|
|
DeserializationWithDump(String),
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("transport error: {0}")]
|
|
Transport(String),
|
|
#[error("client disconnected")]
|
|
Disconnected,
|
|
#[error("internal mutex poisoned: {0}")]
|
|
MutexPoisoned(String),
|
|
}
|
|
|
|
/// One decoded inbound message. The client→server stream is a single demuxed
|
|
/// channel (D-225): a `Vec<PlayerInput>` frame is a MessagePack *array* and
|
|
/// every request type below is a *map*, so array vs. map alone separates
|
|
/// inputs from everything else without a wire-level type tag (existing frames
|
|
/// are byte-unchanged — additive).
|
|
///
|
|
/// **Disambiguating the three map shapes (D-225 extension, T-949):**
|
|
/// `AtlasLayerRequest{body_id, up_to}` was the only map shape until T-949
|
|
/// added `StarMapRequest`/`CityNamesRequest` alongside it. serde's derived
|
|
/// `Deserialize` silently ignores unknown fields by default, so "does this
|
|
/// struct parse at all" is not a safe discriminator once more than one map
|
|
/// shape can share a field name (`CityNamesRequest` and `AtlasLayerRequest`
|
|
/// both key on `body_id`) — a payload carrying every field either shape wants
|
|
/// would ambiguously satisfy both. Rather than retrofit
|
|
/// `#[serde(deny_unknown_fields)]` onto the existing `AtlasLayerRequest` (risking
|
|
/// breakage if any already-deployed client encoder harmlessly sends extra
|
|
/// fields), the two *new* map shapes each carry a mandatory boolean
|
|
/// discriminator field the others don't have at all (`star_map` /
|
|
/// `city_names`): a missing required field is a hard deserialize failure, not
|
|
/// a silent ignore, so no *minimal well-formed* instance of one shape
|
|
/// satisfies another — and [`decode_inbound`] additionally REJECTS union
|
|
/// 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):** [`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).
|
|
Inputs(Vec<PlayerInput>),
|
|
/// An atlas layer-stream request (#969, D-225).
|
|
AtlasRequest(AtlasLayerRequest),
|
|
/// A star-map dataset request (T-949a).
|
|
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
|
|
/// [`decode_inbound`]: `Option<IgnoredAny>` records whether a key exists
|
|
/// without caring about its value or type, so a union frame is detected even
|
|
/// when the individual values wouldn't parse as their target types.
|
|
#[derive(serde::Deserialize)]
|
|
struct ShapeProbe {
|
|
body_id: Option<serde::de::IgnoredAny>,
|
|
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, 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`) →
|
|
/// `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
|
|
/// defensive pre-check rejects any map frame carrying the discriminators of
|
|
/// 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 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));
|
|
}
|
|
// Defensive multi-shape rejection: serde ignores unknown fields, so a
|
|
// union frame would otherwise route silently by try-order. Unreachable
|
|
// from the shipped client encoders (each sends one minimal shape) — this
|
|
// guards buggy or adversarial frames.
|
|
if let Ok(probe) = rmp_serde::from_slice::<ShapeProbe>(payload) {
|
|
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 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={}, browse={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
|
|
shapes,
|
|
atlas,
|
|
star_map,
|
|
city_names,
|
|
browse,
|
|
dump_len,
|
|
payload.len(),
|
|
&payload[..dump_len]
|
|
);
|
|
return Err(BridgeError::DeserializationWithDump(format!(
|
|
"ambiguous inbound frame matches {shapes} request shapes (payload {} bytes)",
|
|
payload.len()
|
|
)));
|
|
}
|
|
}
|
|
if let Ok(req) = rmp_serde::from_slice::<AtlasLayerRequest>(payload) {
|
|
return Ok(Inbound::AtlasRequest(req));
|
|
}
|
|
if let Ok(req) = rmp_serde::from_slice::<StarMapRequest>(payload) {
|
|
return Ok(Inbound::StarMapRequest(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!(
|
|
"inbound decode failed (matches no known frame shape): {}. Raw ({} of {} bytes): {:02x?}",
|
|
e,
|
|
dump_len,
|
|
payload.len(),
|
|
&payload[..dump_len]
|
|
);
|
|
Err(BridgeError::DeserializationWithDump(format!(
|
|
"{e} (payload {} bytes)",
|
|
payload.len()
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Abstracts transport layer (D-020)
|
|
/// Implemented by LocalBridge (stdio) and future NetworkBridge
|
|
pub trait SimBridge: Send + Sync {
|
|
/// Send the protocol handshake as the first framed message (#555).
|
|
/// Must be called exactly once, immediately after connection, before
|
|
/// any ObserverSnapshot is sent.
|
|
fn send_handshake(&self) -> Result<(), BridgeError>;
|
|
|
|
/// Receive the client's startup message containing the world seed (#175).
|
|
/// Called exactly once, after send_handshake(), before entering the tick loop.
|
|
/// Blocks until the client sends the message.
|
|
fn receive_startup(&self) -> Result<StartupMessage, BridgeError>;
|
|
|
|
/// Send an observer snapshot to the client
|
|
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
|
|
|
|
/// Receive one inbound message, or `None` if no complete frame is ready.
|
|
/// The single client→server stream is demuxed by frame shape (D-225).
|
|
/// `receive_bridge_inputs` loops this until `None` (T-1045), so the
|
|
/// transport behind `BridgeResource` must not block when no frame is
|
|
/// buffered (TcpBridge is non-blocking; LocalBridge blocks — test-only).
|
|
fn receive(&self) -> Result<Option<Inbound>, BridgeError>;
|
|
|
|
/// Send an atlas layer-stream response to the client (#969, D-225).
|
|
fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError>;
|
|
|
|
/// Send a star-map response to the client (T-949a).
|
|
fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError>;
|
|
|
|
/// 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
|
|
/// (D-254 §2, T-1130). Assigned at accept time by `BridgeResource`; never
|
|
/// reused within a server process lifetime (monotonic counter), so a stale
|
|
/// id from a disconnected connection can never collide with a live one.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub struct ConnectionId(pub u64);
|
|
|
|
/// One live connection: its transport, role, and (for readers) a violation
|
|
/// strike counter (D-254 §2 — "log + drop on first offense, disconnect on
|
|
/// repeated violations").
|
|
struct Connection {
|
|
id: ConnectionId,
|
|
bridge: Box<dyn SimBridge>,
|
|
/// Not read today — `player`/`readers` (which list a `Connection` lives
|
|
/// in) already fully determines role-gated behavior in this ticket's
|
|
/// scope. Kept because D-254 §6's future `TradingReader` widening wants
|
|
/// a role-keyed per-verb match (`Player | Reader | TradingReader`) on
|
|
/// exactly this field rather than a third top-level `Vec` — carrying it
|
|
/// now avoids a struct-shape change alongside that later widening.
|
|
#[allow(dead_code)]
|
|
role: ConnectionRole,
|
|
/// Count of role-violating frames seen from this connection (e.g. a
|
|
/// Reader sending `Vec<PlayerInput>`). Always 0 for `Player` — the
|
|
/// violation path only applies to non-Player roles.
|
|
violation_strikes: u32,
|
|
}
|
|
|
|
/// Strikes tolerated before a violating reader is disconnected (D-254 §2).
|
|
/// First offense logs + drops the frame; this is the ceiling before the
|
|
/// connection itself is torn down. Deliberately small — a well-behaved
|
|
/// reader client sends zero disallowed frames ever, so any nonzero count is
|
|
/// already a bug or hostile probe, not normal traffic.
|
|
const READER_VIOLATION_DISCONNECT_THRESHOLD: u32 = 3;
|
|
|
|
/// Per-reader per-tick inbound frame cap (D-254 §2) — lower than the Player
|
|
/// cap (`MAX_INBOUND_FRAMES_PER_TICK`, 64): a reader has no legitimate reason
|
|
/// to send that volume of atlas/star-map/city-names requests in one 50ms
|
|
/// tick. Cheap insurance against a runaway or misbehaving companion client;
|
|
/// does not affect determinism either way (reader frames never reach
|
|
/// InputQueue/SimRng regardless of how many are drained).
|
|
const MAX_READER_INBOUND_FRAMES_PER_TICK: usize = 8;
|
|
|
|
/// BridgeResource: Bevy Resource holding the server's connection set
|
|
/// (D-254 §2, T-1130).
|
|
///
|
|
/// Scoped honestly as **0-1 Player + 0-N Readers** — not general N-player
|
|
/// (D-009's separate, larger, out-of-scope ambition). `player` is the
|
|
/// original single connection this resource used to wrap directly;
|
|
/// `readers` is new. `pending` holds accepted-but-not-yet-handshaken
|
|
/// connections (see `tcp::PendingConnection`) — polled non-blockingly each
|
|
/// tick by `main.rs`'s accept-loop system until they either promote into
|
|
/// `readers` (or, in the anomalous case of a second Player attempt, get
|
|
/// cleanly rejected — see `main.rs`) or fail and are dropped.
|
|
#[derive(Resource, Default)]
|
|
pub struct BridgeResource {
|
|
player: Option<Connection>,
|
|
readers: Vec<Connection>,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl BridgeResource {
|
|
/// Construct a `BridgeResource` with `bridge` installed as the Player
|
|
/// connection. This is the pre-D-254 constructor signature, preserved
|
|
/// byte-for-byte so every existing call site (`main.rs`, and the
|
|
/// single-connection test suites in `server/tests/`) needs no change —
|
|
/// "install this bridge" always meant "install the Player" before
|
|
/// readers existed, and still does when called this way.
|
|
pub fn new(bridge: impl SimBridge + 'static) -> Self {
|
|
let mut resource = Self::default();
|
|
resource.insert_player(bridge);
|
|
resource
|
|
}
|
|
|
|
/// Install `bridge` as the Player connection, assigning it the next
|
|
/// `ConnectionId`. Overwrites any existing Player connection (there is
|
|
/// never more than one — `main.rs`'s accept-loop rejects a second Player
|
|
/// attempt before calling this).
|
|
pub fn insert_player(&mut self, bridge: impl SimBridge + 'static) -> ConnectionId {
|
|
let id = ConnectionId(self.next_id);
|
|
self.next_id += 1;
|
|
self.player = Some(Connection {
|
|
id,
|
|
bridge: Box::new(bridge),
|
|
role: ConnectionRole::Player,
|
|
violation_strikes: 0,
|
|
});
|
|
id
|
|
}
|
|
|
|
/// Install `bridge` as a new Reader connection, assigning it the next
|
|
/// `ConnectionId`.
|
|
pub fn insert_reader(&mut self, bridge: impl SimBridge + 'static) -> ConnectionId {
|
|
let id = ConnectionId(self.next_id);
|
|
self.next_id += 1;
|
|
self.readers.push(Connection {
|
|
id,
|
|
bridge: Box::new(bridge),
|
|
role: ConnectionRole::Reader,
|
|
violation_strikes: 0,
|
|
});
|
|
id
|
|
}
|
|
|
|
/// True if a Player connection is currently installed (D-254 §2: used by
|
|
/// the accept-loop to cleanly reject a second Player attempt instead of
|
|
/// silently starving it the way the pre-D-254 single-`accept()` did).
|
|
pub fn has_player(&self) -> bool {
|
|
self.player.is_some()
|
|
}
|
|
|
|
/// Number of currently-installed Reader connections (test/observability
|
|
/// helper, D-254 §2 — the 0-N reader count this ticket's scope is built
|
|
/// around).
|
|
pub fn reader_count(&self) -> usize {
|
|
self.readers.len()
|
|
}
|
|
|
|
/// `ConnectionId`s of every currently-installed reader, in insertion
|
|
/// order (test/observability helper, D-254 §2).
|
|
pub fn reader_ids(&self) -> Vec<ConnectionId> {
|
|
self.readers.iter().map(|c| c.id).collect()
|
|
}
|
|
|
|
/// Remove and drop the Player connection (its `TcpBridge`/socket is
|
|
/// dropped, closing the TCP connection).
|
|
pub fn remove_player(&mut self) {
|
|
self.player = None;
|
|
}
|
|
|
|
/// Remove and drop the reader with the given id, if present. A no-op if
|
|
/// the id doesn't match any current reader (already removed, or was
|
|
/// never a reader — e.g. the Player's own id).
|
|
pub fn remove_reader(&mut self, id: ConnectionId) {
|
|
self.readers.retain(|c| c.id != id);
|
|
}
|
|
|
|
/// Send the protocol handshake on the Player connection, if any.
|
|
/// Used only by `main.rs`'s original single-connection startup path —
|
|
/// reader connections send their own handshake as part of
|
|
/// `tcp::PendingConnection`'s state machine, not through this resource.
|
|
pub fn send_handshake(&self) -> Result<(), BridgeError> {
|
|
match &self.player {
|
|
Some(c) => c.bridge.send_handshake(),
|
|
None => Err(BridgeError::Transport("no player connection".into())),
|
|
}
|
|
}
|
|
|
|
/// Receive the Player's startup message. Used only by `main.rs`'s
|
|
/// original single-connection startup path (the first accept, before
|
|
/// the tick loop and its non-blocking accept-loop begin).
|
|
pub fn receive_startup(&self) -> Result<StartupMessage, BridgeError> {
|
|
match &self.player {
|
|
Some(c) => c.bridge.receive_startup(),
|
|
None => Err(BridgeError::Transport("no player connection".into())),
|
|
}
|
|
}
|
|
|
|
/// Send an `ObserverSnapshot` to the Player connection ONLY (D-254 §2 —
|
|
/// structural enforcement: this method has no reader-facing counterpart
|
|
/// at all, so a future edit cannot accidentally start broadcasting
|
|
/// snapshots to readers by forgetting a role check — there is no code
|
|
/// path here that could reach a reader's `bridge.send_snapshot`).
|
|
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
|
match &self.player {
|
|
Some(c) => c.bridge.send_snapshot(snapshot),
|
|
None => Err(BridgeError::Disconnected),
|
|
}
|
|
}
|
|
|
|
/// Look up the connection (Player or Reader) matching `id`, for
|
|
/// per-connection response routing (D-254 §2: atlas/star-map/city-names
|
|
/// responses must go back to only the connection that asked).
|
|
fn connection(&self, id: ConnectionId) -> Option<&Connection> {
|
|
if let Some(p) = &self.player {
|
|
if p.id == id {
|
|
return Some(p);
|
|
}
|
|
}
|
|
self.readers.iter().find(|c| c.id == id)
|
|
}
|
|
|
|
/// Send an atlas layer-stream response to exactly the connection that
|
|
/// requested it (#969, D-225 original; D-254 §2 adds the routing — the
|
|
/// connection may be the Player or any Reader). `Ok(())` with a debug
|
|
/// log if the connection has since disconnected (the response is simply
|
|
/// dropped — not an error condition, the requester is gone).
|
|
pub fn send_atlas_response_to(
|
|
&self,
|
|
id: ConnectionId,
|
|
resp: &AtlasLayerResponse,
|
|
) -> Result<(), BridgeError> {
|
|
match self.connection(id) {
|
|
Some(c) => c.bridge.send_atlas_response(resp),
|
|
None => {
|
|
tracing::debug!(
|
|
"atlas response for {:?} dropped — connection {:?} no longer present",
|
|
resp.body_id,
|
|
id
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a star-map response to exactly the connection that requested it
|
|
/// (T-949a original; D-254 §2 adds the routing).
|
|
pub fn send_star_map_response_to(
|
|
&self,
|
|
id: ConnectionId,
|
|
resp: &StarMapResponse,
|
|
) -> Result<(), BridgeError> {
|
|
match self.connection(id) {
|
|
Some(c) => c.bridge.send_star_map_response(resp),
|
|
None => {
|
|
tracing::debug!(
|
|
"star map response dropped — connection {:?} no longer present",
|
|
id
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a city-names response to exactly the connection that requested
|
|
/// it (T-949b original; D-254 §2 adds the routing).
|
|
pub fn send_city_names_response_to(
|
|
&self,
|
|
id: ConnectionId,
|
|
resp: &CityNamesResponse,
|
|
) -> Result<(), BridgeError> {
|
|
match self.connection(id) {
|
|
Some(c) => c.bridge.send_city_names_response(resp),
|
|
None => {
|
|
tracing::debug!(
|
|
"city names response for {:?} dropped — connection {:?} no longer present",
|
|
resp.body_id,
|
|
id
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
/// Inserted by BridgePlugin as Pending. Set to Complete in main.rs after
|
|
/// `send_handshake()` succeeds. `receive_bridge_inputs` logs a warning
|
|
/// if inputs arrive while still Pending.
|
|
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum HandshakeState {
|
|
/// Handshake not yet sent. Inputs arriving in this state trigger a warning.
|
|
#[default]
|
|
Pending,
|
|
/// Handshake sent. Normal operation.
|
|
Complete,
|
|
}
|
|
|
|
/// Per-tick cap on drained inbound frames (T-1045) — a safety valve so a
|
|
/// client flooding the stream cannot starve the simulation tick. Generous:
|
|
/// normal traffic is one input batch plus the occasional atlas request.
|
|
const MAX_INBOUND_FRAMES_PER_TICK: usize = 64;
|
|
|
|
/// Receive inputs/requests from every connection and route by role (D-254
|
|
/// §2, T-1130). Drains every complete frame buffered this tick per
|
|
/// connection (T-1045) — a single receive() per connection per tick would
|
|
/// backlog mixed input/atlas traffic at one frame per 50 ms. Relies on
|
|
/// receive() being non-blocking (Ok(None) = no frame).
|
|
///
|
|
/// Role gate (D-254 §2 permitted-message matrix): `Vec<PlayerInput>` is only
|
|
/// ever pushed to `InputQueue` from the Player connection. A Reader sending
|
|
/// inputs is syntactically valid (the D-225 demux parses it fine) but
|
|
/// role-disallowed — logged, dropped, and struck; `READER_VIOLATION_
|
|
/// DISCONNECT_THRESHOLD` repeated violations disconnect that reader (never
|
|
/// the Player, never other readers). Atlas/star-map/city-names requests are
|
|
/// accepted from ANY connection and tagged with the sender's `ConnectionId`
|
|
/// so `send_*_responses` can route the reply back to only that connection.
|
|
///
|
|
/// THE CRITICAL FIX (D-254 §2): only the Player connection's disconnect
|
|
/// flips `ServerRunning` — a reader disconnecting (or never having
|
|
/// connected) must never affect a running player session. Reader
|
|
/// disconnects just remove that reader from `BridgeResource` and continue.
|
|
///
|
|
/// Protocol errors (malformed input) are recoverable: the frame is skipped
|
|
/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85).
|
|
pub fn receive_bridge_inputs(
|
|
bridge: Option<ResMut<BridgeResource>>,
|
|
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
|
mut running: ResMut<ServerRunning>,
|
|
handshake: Res<HandshakeState>,
|
|
mut error_buffer: ResMut<SimErrorBuffer>,
|
|
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 };
|
|
let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
|
|
|
|
// -- Player connection ------------------------------------------------
|
|
// Unchanged behavior from before D-254: the Player's Inputs go to
|
|
// InputQueue, its atlas/etc. requests get tagged with its ConnectionId,
|
|
// and ITS disconnect (and only its disconnect) shuts the server down.
|
|
if let Some(player) = bridge.player.as_mut() {
|
|
let player_id = player.id;
|
|
let mut player_disconnected = false;
|
|
|
|
for _ in 0..MAX_INBOUND_FRAMES_PER_TICK {
|
|
match player.bridge.receive() {
|
|
Ok(Some(Inbound::Inputs(inputs))) => {
|
|
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
|
|
tracing::warn!(
|
|
"Received {} input(s) before handshake completed — processing anyway (forward-compatible)",
|
|
inputs.len()
|
|
);
|
|
}
|
|
for input in &inputs {
|
|
tracing::trace!(
|
|
"Received input: tick={} action={:?}",
|
|
input.tick,
|
|
input.action
|
|
);
|
|
}
|
|
for input in inputs {
|
|
input_queue.push(input);
|
|
}
|
|
}
|
|
Ok(Some(Inbound::AtlasRequest(req))) => {
|
|
atlas_requests.0.push((player_id, req));
|
|
}
|
|
Ok(Some(Inbound::StarMapRequest(req))) => {
|
|
star_map_requests.0.push((player_id, req));
|
|
}
|
|
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) => {
|
|
tracing::info!("Player disconnected, shutting down");
|
|
running.0 = false;
|
|
player_disconnected = true;
|
|
break;
|
|
}
|
|
Err(BridgeError::Io(ref e))
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe
|
|
|| e.kind() == std::io::ErrorKind::ConnectionReset =>
|
|
{
|
|
tracing::info!("Player pipe broken, shutting down cleanly");
|
|
running.0 = false;
|
|
player_disconnected = true;
|
|
break;
|
|
}
|
|
Err(BridgeError::MutexPoisoned(ref msg)) => {
|
|
tracing::error!("Player bridge mutex poisoned: {}. Shutting down.", msg);
|
|
running.0 = false;
|
|
player_disconnected = true;
|
|
break;
|
|
}
|
|
Err(BridgeError::DeserializationWithDump(ref msg)) => {
|
|
// Recoverable: skip this frame's input, report to client (#85),
|
|
// keep draining — the frame was consumed, later ones may be fine.
|
|
tracing::error!("Skipping malformed input frame: {}", msg);
|
|
error_buffer.push(SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: format!("Malformed input frame: {}", msg),
|
|
tick: current_tick,
|
|
});
|
|
}
|
|
Err(ref e @ BridgeError::Deserialization(_)) => {
|
|
// Recoverable deserialization error without dump
|
|
tracing::error!("Skipping malformed input: {}", e);
|
|
error_buffer.push(SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: format!("Deserialization error: {}", e),
|
|
tick: current_tick,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
// Unknown error: log once per tick instead of hammering a
|
|
// persistently failing stream within one tick. A permanently
|
|
// corrupt stream (e.g. the oversized-prefix poison state)
|
|
// therefore logs every tick without escalation — follow-up
|
|
// ticket covers shutdown-after-N-consecutive-errors.
|
|
tracing::error!("Bridge receive error: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if player_disconnected {
|
|
bridge.player = None;
|
|
}
|
|
}
|
|
|
|
// -- Reader connections -------------------------------------------------
|
|
// Role-gated: Inputs are never forwarded to InputQueue from a reader —
|
|
// logged, dropped, and struck instead. Atlas/star-map/city-names
|
|
// requests ARE forwarded, tagged with the reader's own ConnectionId.
|
|
// A reader's disconnect only removes that one reader — ServerRunning is
|
|
// untouched, and other connections (Player, other readers) are unaffected.
|
|
let mut disconnected_readers: Vec<ConnectionId> = Vec::new();
|
|
let mut to_disconnect_for_violations: Vec<ConnectionId> = Vec::new();
|
|
|
|
for reader in bridge.readers.iter_mut() {
|
|
let reader_id = reader.id;
|
|
for _ in 0..MAX_READER_INBOUND_FRAMES_PER_TICK {
|
|
match reader.bridge.receive() {
|
|
Ok(Some(Inbound::Inputs(inputs))) => {
|
|
// D-254 §2 permitted-message matrix: Reader -> Inputs is
|
|
// disallowed. Syntactically valid, role-forbidden — log,
|
|
// drop the frame (never reaches InputQueue/SimRng, so
|
|
// determinism is unaffected by construction), and strike.
|
|
reader.violation_strikes += 1;
|
|
tracing::warn!(
|
|
"Reader connection {:?} sent {} disallowed PlayerInput(s) — dropped (strike {}/{})",
|
|
reader_id,
|
|
inputs.len(),
|
|
reader.violation_strikes,
|
|
READER_VIOLATION_DISCONNECT_THRESHOLD
|
|
);
|
|
error_buffer.push(SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: format!(
|
|
"Reader connection {:?} sent disallowed PlayerInput (role violation)",
|
|
reader_id
|
|
),
|
|
tick: current_tick,
|
|
});
|
|
if reader.violation_strikes >= READER_VIOLATION_DISCONNECT_THRESHOLD {
|
|
tracing::warn!(
|
|
"Reader connection {:?} exceeded violation threshold — disconnecting",
|
|
reader_id
|
|
);
|
|
to_disconnect_for_violations.push(reader_id);
|
|
break;
|
|
}
|
|
}
|
|
Ok(Some(Inbound::AtlasRequest(req))) => {
|
|
atlas_requests.0.push((reader_id, req));
|
|
}
|
|
Ok(Some(Inbound::StarMapRequest(req))) => {
|
|
star_map_requests.0.push((reader_id, req));
|
|
}
|
|
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);
|
|
disconnected_readers.push(reader_id);
|
|
break;
|
|
}
|
|
Err(BridgeError::Io(ref e))
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe
|
|
|| e.kind() == std::io::ErrorKind::ConnectionReset =>
|
|
{
|
|
tracing::info!("Reader connection {:?} pipe broken", reader_id);
|
|
disconnected_readers.push(reader_id);
|
|
break;
|
|
}
|
|
Err(BridgeError::MutexPoisoned(ref msg)) => {
|
|
tracing::error!(
|
|
"Reader connection {:?} bridge mutex poisoned: {}",
|
|
reader_id,
|
|
msg
|
|
);
|
|
disconnected_readers.push(reader_id);
|
|
break;
|
|
}
|
|
Err(BridgeError::DeserializationWithDump(ref msg)) => {
|
|
tracing::error!(
|
|
"Reader connection {:?}: skipping malformed frame: {}",
|
|
reader_id,
|
|
msg
|
|
);
|
|
error_buffer.push(SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: format!("Malformed reader frame: {}", msg),
|
|
tick: current_tick,
|
|
});
|
|
}
|
|
Err(ref e @ BridgeError::Deserialization(_)) => {
|
|
tracing::error!(
|
|
"Reader connection {:?}: skipping malformed frame: {}",
|
|
reader_id,
|
|
e
|
|
);
|
|
error_buffer.push(SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: format!("Deserialization error: {}", e),
|
|
tick: current_tick,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Reader connection {:?} receive error: {}", reader_id, e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for id in disconnected_readers
|
|
.into_iter()
|
|
.chain(to_disconnect_for_violations)
|
|
{
|
|
bridge.remove_reader(id);
|
|
}
|
|
}
|
|
|
|
/// Send snapshot from buffer to the Player connection ONLY (D-254 §2 — a
|
|
/// Reader receives no `ObserverSnapshot` at all, not even filtered; this
|
|
/// function never touches `bridge.readers`, structurally).
|
|
///
|
|
/// A send error on an EXISTING Player connection is fatal for that
|
|
/// connection — the client cannot proceed without snapshots — and shuts the
|
|
/// server down (THE CRITICAL FIX: this is the only way this function ever
|
|
/// touches `ServerRunning`, and it is scoped correctly, because a reader was
|
|
/// never a snapshot recipient to begin with).
|
|
///
|
|
/// Having NO Player connection at all is a DIFFERENT, valid case (D-254 §1:
|
|
/// a spawn-mode server whose sole connection is a Reader) — `bridge.player`
|
|
/// being `None` here is not an error and must never shut the server down;
|
|
/// the snapshot is simply not delivered anywhere (there is currently no
|
|
/// character-controlling connection to deliver it to) and stays queued in
|
|
/// `buffer` for whenever a Player does connect, if ever.
|
|
pub fn send_bridge_snapshot(
|
|
bridge: Option<ResMut<BridgeResource>>,
|
|
mut buffer: ResMut<SnapshotBuffer>,
|
|
mut running: ResMut<ServerRunning>,
|
|
) {
|
|
let Some(mut bridge) = bridge else {
|
|
tracing::error!("send_bridge_snapshot: no BridgeResource");
|
|
return;
|
|
};
|
|
if !bridge.has_player() {
|
|
// Reader-only server (D-254 §1 spawn-mode) or a Player that hasn't
|
|
// finished its accept-loop handshake yet — neither is an error.
|
|
// Leave the snapshot queued; it is simply not deliverable this tick.
|
|
return;
|
|
}
|
|
if let Some(snapshot) = buffer.snapshot.take() {
|
|
if let Err(e) = bridge.send_snapshot(&snapshot) {
|
|
match &e {
|
|
BridgeError::Disconnected => {
|
|
tracing::info!("Player disconnected during send, shutting down");
|
|
}
|
|
BridgeError::MutexPoisoned(msg) => {
|
|
tracing::error!("Player bridge mutex poisoned during send: {}", msg);
|
|
}
|
|
_ => {
|
|
tracing::error!("Player bridge send error: {}", e);
|
|
}
|
|
}
|
|
running.0 = false;
|
|
bridge.player = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Server running flag resource
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct ServerRunning(pub bool);
|
|
|
|
impl Default for ServerRunning {
|
|
fn default() -> Self {
|
|
Self(true)
|
|
}
|
|
}
|
|
|
|
/// Inbound atlas layer requests routed off the bridge (#969, D-225), drained by
|
|
/// the proxy serve system in `PreInput`. Each entry is tagged with the
|
|
/// requesting connection's id (D-254 §2) so the matching response — pushed
|
|
/// 1:1 and in order by `serve_atlas_requests` in `atlas/plugin.rs` — routes
|
|
/// back to only that connection, never a broadcast.
|
|
#[derive(Resource, Default)]
|
|
pub struct AtlasRequestBuffer(pub Vec<(ConnectionId, AtlasLayerRequest)>);
|
|
|
|
/// Outbound atlas layer responses, filled by the proxy serve system and flushed
|
|
/// to the client in `PostSnapshot` (#969, D-225). Connection-tagged (D-254 §2).
|
|
#[derive(Resource, Default)]
|
|
pub struct AtlasResponseBuffer(pub Vec<(ConnectionId, AtlasLayerResponse)>);
|
|
|
|
/// Flush buffered atlas responses to their requesting connections (#969,
|
|
/// D-225 original; D-254 §2 adds per-connection routing). A failed send is
|
|
/// logged but not fatal — an atlas response is not load-bearing like a
|
|
/// snapshot, and a stale/disconnected recipient is not an error (see
|
|
/// `BridgeResource::send_atlas_response_to`).
|
|
pub fn send_atlas_responses(
|
|
bridge: Option<Res<BridgeResource>>,
|
|
mut buffer: ResMut<AtlasResponseBuffer>,
|
|
) {
|
|
let Some(bridge) = bridge else { return };
|
|
for (id, resp) in buffer.0.drain(..) {
|
|
if let Err(e) = bridge.send_atlas_response_to(id, &resp) {
|
|
tracing::warn!(
|
|
"failed to send atlas response for {} to {:?}: {}",
|
|
resp.body_id,
|
|
id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inbound star-map requests routed off the bridge (T-949a), drained by the
|
|
/// proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
|
|
#[derive(Resource, Default)]
|
|
pub struct StarMapRequestBuffer(pub Vec<(ConnectionId, StarMapRequest)>);
|
|
|
|
/// Outbound star-map responses, filled by the proxy serve system and flushed
|
|
/// to the client in `PostSnapshot` (T-949a). Connection-tagged (D-254 §2).
|
|
#[derive(Resource, Default)]
|
|
pub struct StarMapResponseBuffer(pub Vec<(ConnectionId, StarMapResponse)>);
|
|
|
|
/// Flush buffered star-map responses to their requesting connections
|
|
/// (T-949a original; D-254 §2 adds per-connection routing). A failed send
|
|
/// is logged but not fatal.
|
|
pub fn send_star_map_responses(
|
|
bridge: Option<Res<BridgeResource>>,
|
|
mut buffer: ResMut<StarMapResponseBuffer>,
|
|
) {
|
|
let Some(bridge) = bridge else { return };
|
|
for (id, resp) in buffer.0.drain(..) {
|
|
if let Err(e) = bridge.send_star_map_response_to(id, &resp) {
|
|
tracing::warn!("failed to send star map response to {:?}: {}", id, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inbound city-names requests routed off the bridge (T-949b), drained by the
|
|
/// proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
|
|
#[derive(Resource, Default)]
|
|
pub struct CityNamesRequestBuffer(pub Vec<(ConnectionId, CityNamesRequest)>);
|
|
|
|
/// Outbound city-names responses, filled by the proxy serve system and
|
|
/// flushed to the client in `PostSnapshot` (T-949b). Connection-tagged
|
|
/// (D-254 §2).
|
|
#[derive(Resource, Default)]
|
|
pub struct CityNamesResponseBuffer(pub Vec<(ConnectionId, CityNamesResponse)>);
|
|
|
|
/// Flush buffered city-names responses to their requesting connections
|
|
/// (T-949b original; D-254 §2 adds per-connection routing). A failed send
|
|
/// is logged but not fatal.
|
|
pub fn send_city_names_responses(
|
|
bridge: Option<Res<BridgeResource>>,
|
|
mut buffer: ResMut<CityNamesResponseBuffer>,
|
|
) {
|
|
let Some(bridge) = bridge else { return };
|
|
for (id, resp) in buffer.0.drain(..) {
|
|
if let Err(e) = bridge.send_city_names_response_to(id, &resp) {
|
|
tracing::warn!(
|
|
"failed to send city names response for {} to {:?}: {}",
|
|
resp.body_id,
|
|
id,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// The first connection is still accepted by `main.rs`'s original
|
|
/// blocking `listener.accept()` before the tick loop begins (unchanged —
|
|
/// see `main.rs`), matching a normal game launch exactly byte-for-byte
|
|
/// when nobody else ever connects. This resource wraps the SAME listener
|
|
/// (moved into it after that first accept) so `accept_new_connections` can
|
|
/// keep accepting *additional* connections once the tick loop is running —
|
|
/// this is what fixes the original starvation bug (a second client used to
|
|
/// hang forever waiting for an `accept()` call that would never come).
|
|
///
|
|
/// `None` when no listener is wired (e.g. most existing unit/integration
|
|
/// tests that construct a `BridgeResource` directly and never spawn a real
|
|
/// listener) — `accept_new_connections` is a no-op in that case, so it is
|
|
/// always safe to add to any `App`/`World` without also wiring a listener.
|
|
#[derive(Resource, Default)]
|
|
pub struct ConnectionListener(pub Option<std::net::TcpListener>);
|
|
|
|
/// Connections that have been TCP-accepted but have not yet completed their
|
|
/// handshake/startup exchange (D-254 §2, T-1130). Polled non-blockingly
|
|
/// every tick by `accept_new_connections` — see `tcp::PendingConnection`'s
|
|
/// doc for why this can never stall the tick loop.
|
|
#[derive(Resource, Default)]
|
|
pub struct PendingConnections(pub Vec<crate::bridge::tcp::PendingConnection>);
|
|
|
|
/// Accept new TCP connections and advance in-progress handshakes, without
|
|
/// ever blocking the tick loop (D-254 §2, T-1130).
|
|
///
|
|
/// Two independent, non-blocking steps each tick:
|
|
/// 1. Try to accept any newly-arrived TCP connection on `ConnectionListener`
|
|
/// (the listener itself is non-blocking — `main.rs` sets this before
|
|
/// wrapping it in the resource). A `WouldBlock`/no-pending-connection
|
|
/// result is the overwhelmingly common case (no new client this tick)
|
|
/// and is silently ignored, not logged.
|
|
/// 2. Poll every connection in `PendingConnections`. `PendingPoll::Waiting`
|
|
/// connections stay queued for next tick. `PendingPoll::Ready`
|
|
/// connections are promoted based on `startup.role`:
|
|
/// - `Player`, and `BridgeResource` has no Player yet → installed as the
|
|
/// Player connection.
|
|
/// - `Player`, and a Player already exists → THE SECOND-PLAYER CASE
|
|
/// (D-254 §2/T-1130 scope: "a second Player attempt gets a clean
|
|
/// rejection, not a hang"). The connection is dropped immediately
|
|
/// after the handshake completes — no silent starvation (the original
|
|
/// bug), and no impact on the existing Player's session (its
|
|
/// connection is never touched). A client attempting to connect as a
|
|
/// second Player sees a clean disconnect right after startup, which
|
|
/// is a well-defined, discoverable failure — the correct behavior for
|
|
/// an out-of-scope case (general N-player is explicitly D-009's
|
|
/// separate ambition, not this ticket's).
|
|
/// - `Reader` → installed as a new Reader connection, always (0-N
|
|
/// readers is the whole point of this ticket).
|
|
///
|
|
/// `PendingPoll::Failed` connections (EOF before completing handshake,
|
|
/// malformed startup, etc.) are simply dropped — never logged as errors
|
|
/// at more than `warn` level, since an incomplete handshake from a
|
|
/// probing/misbehaving client is an expected occurrence, not a bug.
|
|
pub fn accept_new_connections(
|
|
listener: Option<Res<ConnectionListener>>,
|
|
mut pending: Option<ResMut<PendingConnections>>,
|
|
bridge: Option<ResMut<BridgeResource>>,
|
|
) {
|
|
let (Some(listener), Some(pending), Some(mut bridge)) = (listener, pending.as_mut(), bridge)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
// Step 1: accept any newly-arrived connection (non-blocking listener).
|
|
if let Some(l) = listener.0.as_ref() {
|
|
match l.accept() {
|
|
Ok((stream, peer_addr)) => {
|
|
tracing::info!("accepted new connection from {}", peer_addr);
|
|
match crate::bridge::tcp::PendingConnection::new(stream) {
|
|
Ok(conn) => pending.0.push(conn),
|
|
Err(e) => tracing::warn!("failed to wrap accepted connection: {}", e),
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
// No connection pending — the overwhelmingly common case.
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("accept() failed: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: advance every in-progress handshake by one non-blocking poll.
|
|
let mut still_pending = Vec::with_capacity(pending.0.len());
|
|
for mut conn in pending.0.drain(..) {
|
|
match conn.poll() {
|
|
tcp::PendingPoll::Waiting => still_pending.push(conn),
|
|
tcp::PendingPoll::Ready { stream, startup } => match startup.role {
|
|
ConnectionRole::Player => {
|
|
if bridge.has_player() {
|
|
tracing::warn!(
|
|
"second Player connection attempt rejected (0-1 Player + 0-N Reader scope, D-254 §2) — disconnecting"
|
|
);
|
|
// Dropping `stream` closes the TCP connection — a
|
|
// clean, immediate disconnect, not a hang.
|
|
drop(stream);
|
|
} else {
|
|
match TcpBridge::from_connected_stream(stream) {
|
|
Ok(tcp_bridge) => {
|
|
let id = bridge.insert_player(tcp_bridge);
|
|
tracing::info!("Player connection established: {:?}", id);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"failed to promote pending Player connection: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ConnectionRole::Reader => match TcpBridge::from_connected_stream(stream) {
|
|
Ok(tcp_bridge) => {
|
|
let id = bridge.insert_reader(tcp_bridge);
|
|
tracing::info!("Reader connection established: {:?}", id);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("failed to promote pending Reader connection: {}", e);
|
|
}
|
|
},
|
|
},
|
|
tcp::PendingPoll::Failed => {
|
|
// Handshake never completed (EOF, malformed startup, etc.) —
|
|
// drop silently at info level. Not a server error.
|
|
tracing::info!("pending connection failed to complete handshake");
|
|
}
|
|
}
|
|
}
|
|
pending.0 = still_pending;
|
|
}
|
|
|
|
/// Bridge plugin for client-server communication
|
|
/// Abstracts transport layer (LocalBridge/NetworkBridge)
|
|
pub struct BridgePlugin;
|
|
|
|
impl Plugin for BridgePlugin {
|
|
fn build(&self, app: &mut App) {
|
|
use crate::simulation::time::sim_not_paused;
|
|
use crate::tick_phases::TickPhase;
|
|
|
|
app.init_resource::<SnapshotBuffer>()
|
|
.init_resource::<ServerRunning>()
|
|
.init_resource::<HandshakeState>()
|
|
.init_resource::<SimErrorBuffer>()
|
|
.init_resource::<debug::DebugCommandBuffer>()
|
|
.init_resource::<DebugEnabled>()
|
|
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
|
.init_resource::<AtlasRequestBuffer>()
|
|
.init_resource::<AtlasResponseBuffer>()
|
|
.init_resource::<StarMapRequestBuffer>()
|
|
.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
|
|
// before receive_bridge_inputs so a connection whose handshake
|
|
// completes this tick has its first frame drained the same
|
|
// tick, not next tick.
|
|
.add_systems(
|
|
Update,
|
|
accept_new_connections
|
|
.before(receive_bridge_inputs)
|
|
.in_set(TickPhase::PreInput),
|
|
)
|
|
// Bridge I/O — PreInput (receive) and PostSnapshot (send)
|
|
.add_systems(Update, receive_bridge_inputs.in_set(TickPhase::PreInput))
|
|
.add_systems(Update, send_bridge_snapshot.in_set(TickPhase::PostSnapshot))
|
|
.add_systems(Update, send_atlas_responses.in_set(TickPhase::PostSnapshot))
|
|
.add_systems(
|
|
Update,
|
|
send_star_map_responses.in_set(TickPhase::PostSnapshot),
|
|
)
|
|
.add_systems(
|
|
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,
|
|
debug::handle_debug_commands.in_set(TickPhase::Snapshot),
|
|
)
|
|
// Monologue chain — Simulation phase, strict intra-phase sequence.
|
|
// trigger_event_monologue must run after conversations + sound (also Simulation).
|
|
// T-970: TickPhase::Simulation is not set-gated (see
|
|
// social_plugin.rs's collect_sound_events exemption) — this whole
|
|
// chain is genuine world-advancing dialogue/monologue logic, so
|
|
// it gates safely on its own. trigger_event_monologue's
|
|
// .after(collect_sound_events) still holds while gated:
|
|
// collect_sound_events itself is never gated, and an ordering
|
|
// edge onto a skipped predecessor is trivially satisfied.
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
crate::simulation::monologue::trigger_monologue,
|
|
crate::simulation::monologue::trigger_recognition_monologue
|
|
.after(crate::simulation::monologue::trigger_monologue)
|
|
.after(crate::perception::anomaly::detect_anomalies),
|
|
crate::simulation::monologue::process_sprint_anomaly_monologue
|
|
.after(crate::simulation::monologue::trigger_recognition_monologue),
|
|
crate::simulation::monologue::trigger_event_monologue
|
|
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
|
|
.after(crate::simulation::sound::collect_sound_events)
|
|
.after(crate::simulation::dialogue::process_walk_away),
|
|
crate::simulation::monologue::process_contradiction_monologue
|
|
.after(crate::simulation::monologue::trigger_event_monologue),
|
|
)
|
|
.run_if(sim_not_paused)
|
|
.in_set(TickPhase::Simulation),
|
|
)
|
|
// Observation systems — Simulation phase (reads positions, feeds snapshot).
|
|
// T-970: gates safely on its own (see note above) — these compute
|
|
// "current state" (visibility, nearby interactions) that's valid
|
|
// as long as nothing moved, which holds while paused since
|
|
// Movement is frozen too; unlike SoundEventQueue, nothing here
|
|
// depends on being refreshed on a tick where nothing changed.
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
crate::perception::observer::compute_visibility_geometry,
|
|
crate::simulation::interaction::compute_nearby_interactions,
|
|
)
|
|
.run_if(sim_not_paused)
|
|
.in_set(TickPhase::Simulation),
|
|
)
|
|
// Observer snapshot assembly — Snapshot phase
|
|
.add_systems(
|
|
Update,
|
|
crate::perception::observer::compute_observer_snapshot.in_set(TickPhase::Snapshot),
|
|
)
|
|
// Post-snapshot: emit observation events
|
|
.add_systems(
|
|
Update,
|
|
crate::perception::observation::emit_observation_events
|
|
.in_set(TickPhase::PostSnapshot),
|
|
);
|
|
|
|
tracing::debug!("BridgePlugin initialized");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod inbound_tests {
|
|
use super::*;
|
|
use crate::atlas::cascade::CascadeLayer;
|
|
|
|
#[test]
|
|
fn demux_routes_inputs_and_atlas_requests() {
|
|
// A Vec<PlayerInput> frame (msgpack array) → Inbound::Inputs.
|
|
let inputs: Vec<PlayerInput> = vec![];
|
|
let frame = rmp_serde::to_vec_named(&inputs).unwrap();
|
|
assert!(matches!(decode_inbound(&frame), Ok(Inbound::Inputs(v)) if v.is_empty()));
|
|
|
|
// An AtlasLayerRequest frame (msgpack map) → Inbound::AtlasRequest.
|
|
let req = AtlasLayerRequest {
|
|
body_id: "GJ1c".into(),
|
|
up_to: CascadeLayer::Topography,
|
|
window_center: None,
|
|
window_n: 0,
|
|
};
|
|
let frame = rmp_serde::to_vec_named(&req).unwrap();
|
|
assert!(
|
|
matches!(decode_inbound(&frame), Ok(Inbound::AtlasRequest(r)) if r.body_id == "GJ1c")
|
|
);
|
|
|
|
// Neither shape → a malformed-frame error.
|
|
assert!(decode_inbound(&[0xff, 0xff]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn demux_routes_star_map_requests() {
|
|
let req = StarMapRequest { star_map: true };
|
|
let frame = rmp_serde::to_vec_named(&req).unwrap();
|
|
assert!(matches!(
|
|
decode_inbound(&frame),
|
|
Ok(Inbound::StarMapRequest(r)) if r.star_map
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn demux_routes_city_names_requests() {
|
|
let req = CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
};
|
|
let frame = rmp_serde::to_vec_named(&req).unwrap();
|
|
assert!(matches!(
|
|
decode_inbound(&frame),
|
|
Ok(Inbound::CityNamesRequest(r)) if r.body_id == "GJ1c"
|
|
));
|
|
}
|
|
|
|
/// T-949: the array-vs-map trick (D-225) still separates `Inputs` from
|
|
/// everything else, and the three map shapes' discriminator fields keep
|
|
/// them mutually exclusive — each of the four frame shapes decodes to
|
|
/// exactly its own `Inbound` variant, never a neighbor's.
|
|
#[test]
|
|
fn inbound_disambiguation_is_unambiguous_across_all_four_shapes() {
|
|
let inputs_frame = rmp_serde::to_vec_named(&Vec::<PlayerInput>::new()).unwrap();
|
|
let atlas_frame = rmp_serde::to_vec_named(&AtlasLayerRequest {
|
|
body_id: "GJ1c".into(),
|
|
up_to: CascadeLayer::Topography,
|
|
window_center: None,
|
|
window_n: 0,
|
|
})
|
|
.unwrap();
|
|
let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap();
|
|
let city_names_frame = rmp_serde::to_vec_named(&CityNamesRequest {
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
})
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
decode_inbound(&inputs_frame),
|
|
Ok(Inbound::Inputs(_))
|
|
));
|
|
assert!(matches!(
|
|
decode_inbound(&atlas_frame),
|
|
Ok(Inbound::AtlasRequest(_))
|
|
));
|
|
assert!(matches!(
|
|
decode_inbound(&star_map_frame),
|
|
Ok(Inbound::StarMapRequest(_))
|
|
));
|
|
assert!(matches!(
|
|
decode_inbound(&city_names_frame),
|
|
Ok(Inbound::CityNamesRequest(_))
|
|
));
|
|
|
|
// Cross-check: an AtlasLayerRequest frame must NOT decode as
|
|
// CityNamesRequest even though both key on `body_id` — the missing
|
|
// `city_names` discriminator makes that a hard failure, not a silent
|
|
// "extra field ignored" success either shape could show without it.
|
|
assert!(rmp_serde::from_slice::<CityNamesRequest>(&atlas_frame).is_err());
|
|
// And a CityNamesRequest frame must NOT decode as AtlasLayerRequest —
|
|
// it's missing the required `up_to` field.
|
|
assert!(rmp_serde::from_slice::<AtlasLayerRequest>(&city_names_frame).is_err());
|
|
}
|
|
|
|
/// PR #176 review H1: a union frame carrying more than one shape's
|
|
/// discriminators must be REJECTED, not silently routed to whichever
|
|
/// shape `decode_inbound` happens to try first.
|
|
#[test]
|
|
fn ambiguous_union_frame_is_rejected() {
|
|
#[derive(serde::Serialize)]
|
|
struct StarAndCity {
|
|
star_map: bool,
|
|
city_names: bool,
|
|
body_id: String,
|
|
}
|
|
let frame = rmp_serde::to_vec_named(&StarAndCity {
|
|
star_map: true,
|
|
city_names: true,
|
|
body_id: "GJ1c".into(),
|
|
})
|
|
.unwrap();
|
|
assert!(
|
|
decode_inbound(&frame).is_err(),
|
|
"star_map+city_names union frame must be rejected"
|
|
);
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct AtlasAndStar {
|
|
body_id: String,
|
|
up_to: CascadeLayer,
|
|
star_map: bool,
|
|
}
|
|
let frame = rmp_serde::to_vec_named(&AtlasAndStar {
|
|
body_id: "GJ1c".into(),
|
|
up_to: CascadeLayer::Topography,
|
|
star_map: true,
|
|
})
|
|
.unwrap();
|
|
assert!(
|
|
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"
|
|
);
|
|
}
|
|
}
|