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:
+73
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user