Files
settled-reach/server/src/bridge/mod.rs
T
jpmschweitzerandClaude Fable 5 cd2f342421 feat(simulation): step-canvas serving — tagged envelope (D-255, T-1181)
The D-225-discharging wire migration per D-255(c): a new
server/src/atlas/step_canvas.rs subsystem (six-rung StepCanvasRung
ladder — Global variable-extent rung 0 through Chunk 64m — the
StepCanvasRequest/StepCanvasResponse tagged envelope extending the
proven ShapeProbe discriminated-shape pattern, PNG-per-field dense +
MessagePack-native sparse encoding per the T-1179 measured table, and
both server cache tiers: the structurally keep-always GlobalTierCache
and the dual-axis StepCanvasCache with storage TTLs per rung and
SIM_STATE_TTL clock-bucket staleness per the D-227 amendment (1)
formula). Wired through bridge/{mod,tcp,local}.rs (sixth demux shape,
send_step_canvas_response mirroring the five existing senders),
gen_queue.rs (DeriveStepCanvas work item with per-connection-per-rung
coalescing, reusing the shared TerrainAnalysisCache), and plugin.rs
(serve/complete systems, lazy D-206 rung-0 population).

Acceptance gate (mandatory per D-227 amendment (3)):
tests/step_canvas_acceptance_gate.rs — cache-hit == cache-miss
byte-identical for every rung, lossless encode round-trips, cache
round-trips vs fresh derive, distinct-center sanity. 5/5 pass.

Station-spacing cap ADOPTED: course stations floored to District
spacing (2,048 m) at finer rungs — the S2-measured +38-87% chunk/block
course cost had zero display benefit at the same station density
(COURSE_STATION_SPACING_FLOOR_M).

Documented honest gaps, not shortcuts: settlement_id is a proximity
approximation (no footprint polygons exist yet); glaciation/flooded_q
sim-state planes are wire-shape-ready D-253 stubs; rung-0 uses signed
equator-anchored rows (the canonical D-256 core convention — T-1186's
wrong-latitude behavior applies unchanged and unfixed here, by
instruction).

Legacy district_window carrier byte-unchanged: window_derivation_golden
6/6 byte-identical, all district_window suites pass unmodified. Full
suite at implementation time: 2127 passed across 45 binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 03:13:08 +02:00

1464 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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::atlas::step_canvas::{StepCanvasRequest, StepCanvasResponse};
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, DISCHARGED by D-255(c)):** [`BrowseRequest`]
/// (T-1131) was the FIFTH map shape and, per the ceiling this doc used to
/// call at four, the last one this hand-rolled scheme was meant to carry.
/// [`StepCanvasRequest`] (T-1181) is that predicted SIXTH shape — the
/// step-canvas payload is genuinely new (D-255(c): 21×-563× the legacy
/// `district_window` carrier's ~30 KB reference, a cell-count gap no
/// encoding closes), so per this doc's own prior instruction it extends the
/// SAME tagged-marker pattern one more time (`step_canvas: bool`) rather
/// than inventing a parallel envelope format — this IS the tagged-envelope
/// migration D-225's 2026-06-12 amendment deferred, now executed. The
/// pattern generalizes cleanly because every one of the five prior shapes
/// already carries (or, for `AtlasLayerRequest`, is disambiguated against)
/// exactly this kind of required marker field — there is no natural ceiling
/// on the DEMUX mechanism itself, only a discipline reminder that a new
/// shape should justify why it can't ride an existing one (as
/// `StepCanvasRequest` does, D-255(c)).
#[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),
/// A D-255(a) step-canvas data-canvas request (T-1181, the D-225
/// tagged-envelope migration, executed).
StepCanvasRequest(StepCanvasRequest),
}
/// 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>,
step_canvas: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131,
/// T-1181). 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) →
/// `StepCanvasRequest` (map, `step_canvas` 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 six 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 step_canvas = probe.step_canvas.is_some();
let shapes = usize::from(atlas)
+ usize::from(star_map)
+ usize::from(city_names)
+ usize::from(browse)
+ usize::from(step_canvas);
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={}, step_canvas={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
browse,
step_canvas,
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));
}
if let Ok(req) = rmp_serde::from_slice::<BrowseRequest>(payload) {
return Ok(Inbound::BrowseRequest(req));
}
match rmp_serde::from_slice::<StepCanvasRequest>(payload) {
Ok(req) => Ok(Inbound::StepCanvasRequest(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>;
/// Send a step-canvas response to the client (T-1181, D-255(c)).
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> 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(())
}
}
}
/// Send a step-canvas response to exactly the connection that requested
/// it (T-1181 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names/browse).
pub fn send_step_canvas_response_to(
&self,
id: ConnectionId,
resp: &StepCanvasResponse,
) -> Result<(), BridgeError> {
match self.connection(id) {
Some(c) => c.bridge.send_step_canvas_response(resp),
None => {
tracing::debug!(
"step canvas response for {} dropped — connection {:?} no longer present",
resp.body_id,
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>,
mut step_canvas_requests: ResMut<StepCanvasRequestBuffer>,
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));
}
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_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(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_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
);
}
}
}
/// Inbound step-canvas requests routed off the bridge (T-1181), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct StepCanvasRequestBuffer(pub Vec<(ConnectionId, StepCanvasRequest)>);
/// Outbound step-canvas responses, filled by the proxy serve system and
/// flushed to the client in `PostSnapshot` (T-1181). Connection-tagged
/// (D-254 §2).
#[derive(Resource, Default)]
pub struct StepCanvasResponseBuffer(pub Vec<(ConnectionId, StepCanvasResponse)>);
/// Flush buffered step-canvas responses to their requesting connections
/// (T-1181 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names/browse). A failed send is logged but not fatal.
pub fn send_step_canvas_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<StepCanvasResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for (id, resp) in buffer.0.drain(..) {
if let Err(e) = bridge.send_step_canvas_response_to(id, &resp) {
tracing::warn!(
"failed to send step canvas response for {} to {:?}: {}",
resp.body_id,
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::<StepCanvasRequestBuffer>()
.init_resource::<StepCanvasResponseBuffer>()
.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),
)
.add_systems(
Update,
send_step_canvas_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,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 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,
window_granularity: 0,
window_granularity_v2: None,
window_min_wl_m: 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"
);
}
}