feat(engine): T-1130 reader connections — role-gated multi-connection bridge (D-254 SS1/SS2)

ConnectionRole (Player|Reader, serde-default Player for wire back-compat;
shaped for a future TradingReader) on StartupMessage. BridgeResource
rewritten as 0-1 Player + 0-N Readers with ConnectionId; per-tick
accept_new_connections loop replaces the single blocking accept (the
listener is cloned non-blocking into ConnectionListener). First
connection installs per startup.role — spawn-mode Readers are often the
only connection a server gets.

Permitted-message matrix: readers may handshake and issue
Atlas/StarMap/CityNames requests (responses connection-tagged, own
requests only); PlayerInput from a reader is dropped with a strike
(disconnect at 3); ObserverSnapshot has no reader-facing path at all.
Shutdown scope: only a PLAYER send failure flips ServerRunning —
reader-only servers idle with the snapshot queued; reader disconnects
never kill the session.

Deliberately out of scope, documented at the call site: character spawn
stays in monolithic world-setup (reader-first servers carry an inert
unpiloted PlayerCharacter); no total-reader-count cap (per-reader frame
cap only, loopback-only scope).

6 new bridge_tcp integration tests; layer3 subprocess test exercises the
accept-loop end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:30:56 +02:00
co-authored by Claude Fable 5
parent d370ca6044
commit ddc3d39d09
7 changed files with 1549 additions and 159 deletions
+73 -2
View File
@@ -11,7 +11,9 @@ use bevy_app::prelude::*;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, HandshakeState, ServerRunning};
use settled_reach_server::bridge::{
BridgePlugin, BridgeResource, ConnectionListener, ConnectionRole, HandshakeState, ServerRunning,
};
use settled_reach_server::simulation::SimulationPlugin;
fn main() {
@@ -117,6 +119,25 @@ fn main() {
}
tracing::info!("Waiting for client connection on port {}", actual_port);
// D-254 §2/T-1130: the FIRST connection is still accepted here, exactly
// as before — one blocking listener.accept() call, byte-identical to
// pre-D-254 behavior when nobody else ever connects. What changes is
// AFTER: the listener is set non-blocking and handed to
// ConnectionListener (inserted below) so accept_new_connections can
// keep accepting additional connections once the tick loop starts,
// instead of the original bug where a second accept() call never
// happened at all and a second client hung forever.
//
// accept_on() consumes the listener; clone it first so both the first
// accept AND the later non-blocking accept-loop have a working handle
// on the same underlying socket (TcpListener::try_clone shares the fd,
// not a new listener — connections queued on either handle are visible
// to both, same as TcpStream::try_clone is already used for read/write
// halves throughout this bridge).
let listener_for_loop = listener.try_clone().unwrap_or_else(|e| {
tracing::error!("Failed to clone listener for accept-loop: {}", e);
std::process::exit(1);
});
let bridge = TcpBridge::accept_on(listener).unwrap_or_else(|e| {
tracing::error!("Failed to accept: {}", e);
std::process::exit(1);
@@ -291,9 +312,42 @@ fn main() {
}
}
app.insert_resource(BridgeResource::new(bridge));
// D-254 §2/T-1130: the FIRST connection's role, exactly as it does for
// every later accept-loop connection (main.rs's accept-loop handles
// connections 2+; this handles the honest first-connection case a
// spawn-mode Reader server actually needs — D-254 §1's spawn-mode
// Atlas companion connects as the ONLY connection to a freshly-spawned
// server, so "first connection" and "Reader" are not mutually
// exclusive). `BridgeResource::default()` + explicit insert_player/
// insert_reader replaces the old unconditional `BridgeResource::new`
// (which always meant "install as Player" — there was no other role
// before this ticket).
let mut bridge_resource = BridgeResource::default();
match startup.role {
ConnectionRole::Player => {
bridge_resource.insert_player(bridge);
}
ConnectionRole::Reader => {
tracing::info!(
"first connection is a Reader (D-254 §1 spawn-mode) — no character will be spawned for it"
);
bridge_resource.insert_reader(bridge);
}
}
app.insert_resource(bridge_resource);
app.insert_resource(HandshakeState::Complete);
// D-254 §2/T-1130: wire the cloned listener non-blocking so
// accept_new_connections (BridgePlugin, PreInput) can accept additional
// connections every tick without ever blocking the tick loop. This is
// the actual fix for the original starvation bug — before this, there
// was exactly one listener.accept() call in the whole process lifetime.
listener_for_loop.set_nonblocking(true).unwrap_or_else(|e| {
tracing::error!("Failed to set accept-loop listener non-blocking: {}", e);
std::process::exit(1);
});
app.insert_resource(ConnectionListener(Some(listener_for_loop)));
// SimulationPlugin { seed } already inserts SimRng with the correct seed
// during plugin build. We re-insert here as a defensive override for one
// specific ordering risk: any future plugin that registers *before*
@@ -316,6 +370,23 @@ fn main() {
);
// Gauntlet test world for --test-mode, proof room for normal mode.
//
// D-254 §2/T-1130 scope note: this call is NOT gated on the first
// connection's role, even though it spawns a `PlayerCharacter` entity
// unconditionally. A Reader-only spawned server (D-254 §1 spawn-mode)
// therefore has an inert, unpiloted `PlayerCharacter` entity sitting in
// its ECS world — nothing drives it (no Player connection exists to
// send it inputs), and the Reader never learns it exists: `send_
// bridge_snapshot` routes `ObserverSnapshot` to the Player connection
// ONLY and is a documented no-op with no Player installed (see
// `bridge::send_bridge_snapshot`), so this entity's existence has no
// observable effect on a Reader-only session. Splitting character-spawn
// out of `setup_proof_room`/`setup_gauntlet` (both of which also wire
// NPCs, the walkability map, and the relationship graph — genuinely
// "whole world setup", not just "spawn the player") into an optional
// step is real refactoring work, correctly out of scope for this
// gating ticket; tracked as follow-up, not required for the D-010
// information-boundary guarantee this ticket exists to establish.
if test_mode {
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);