Merge remote-tracking branch 'origin/atlas-companion-app'

This commit is contained in:
2026-07-17 09:32:07 +02:00
19 changed files with 2260 additions and 165 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server game stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \
.PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-active decisions-validate \
validate-content check-fact-ids setup-hooks install-hooks \
audit deny atlas-verify economy-db regen-db check-systems-db \
@@ -26,6 +26,7 @@ help:
@echo " make setup-venv Create .venv and install Python tooling deps"
@echo " make build Build client and server"
@echo " make game Build and run the full game (server + client)"
@echo " make atlas Standalone Atlas companion app — attach or spawn, read-only (D-254)"
@echo " make stop Stop any running server instance"
@echo " make client Run the Godot client (test mode)"
@echo " make server Run the Rust simulation server"
@@ -150,6 +151,20 @@ game: stop build
@SR_LIVE=1 $(GODOT) --path client
@$(MAKE) stop
# D-254/T-1132: standalone Atlas companion app. Unlike `game`, no orchestration
# here — atlas_standalone.gd owns its own attach-or-spawn decision and child
# server lifecycle internally (D-254 §1/§3), so this target is just "build,
# then launch the standalone scene". build-server ensures the debug binary
# exists for spawn-mode (atlas_standalone.gd resolves it at
# server/target/debug/settled-reach-server, the same path the E2E test files
# use); build-client ensures imports are current. SR_LIVE=1 is required —
# without it SimBridge boots in test mode (instant fake CONNECTED, no network
# at all), which would make the companion open the Atlas against a dynamic
# test-harness snapshot instead of the real systems.db world via the wire.
atlas: build-server build-client
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
@SR_LIVE=1 $(GODOT) --path client scenes/atlas_standalone.tscn
stop:
@lsof -ti :9876 | xargs -r kill 2>/dev/null || true
@echo "Stopped any running server on port 9876"
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3 uid="uid://atlas_standalone_sr"]
[ext_resource type="Script" path="res://scripts/atlas_standalone.gd" id="1_standalone"]
; D-254: standalone Atlas companion app entry scene (`make atlas`). Bare Control
; root, full-rect anchored exactly like hud.gd's AppsContainer (ui/hud.tscn) —
; ImplantRegistry.instantiate_all(self) parents every hosted app directly under
; this node, so it needs the same full-viewport layout a normal implant app
; expects from its parent. No gameplay layer, no HUD status panel, no camera —
; the boot sequence (atlas_standalone.gd) is the entire content of this scene.
[node name="AtlasStandalone" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
script = ExtResource("1_standalone")
+275
View File
@@ -0,0 +1,275 @@
extends Control
## Standalone Atlas companion app entry point (D-254, T-1132, `make atlas`).
##
## Boots the SAME implant scene tree used in-game, skipping the player
## entirely — no character, no main.tscn, no gameplay HUD. Auto-attaches to
## an already-running game (read-only) or spawns its own server against the
## real systems.db world (no --test-mode). Modeled on visual_capture.gd's
## live-mode boot shape (D-254 §3), but a real scene script (normal _ready()),
## not a SceneTree-extending offscreen test harness.
##
## D-254 §3 amendment (2026-07-17, generic implant host): this scene hosts
## EVERY registered implant app (ImplantRegistry.instantiate_all(self, true) —
## the standalone=true filter drops apps whose manifest sets
## available_in_companion=false), fronting the Atlas (implant/map) as the
## default open app. Future implant apps extend the companion with zero
## changes here.
##
## Boot sequence (_ready → _boot, deferred so the node is in-tree first):
## 1. Discovery/attach: SimBridge.connect_to_sim() against SR_PORT-or-9876,
## polled for up to ATTACH_CONNECT_TIMEOUT_S. On CONNECTED, attach
## succeeded — skip straight to step 4. On ERROR (nothing listening),
## fall through to spawn (own child server, --port 0, parse
## LISTENING:{port} from stdout — server_process.gd's start_with_pipe(),
## never --test-mode: real systems.db world), then repeat the connect
## against the newly-resolved port.
##
## NOT a separate throwaway TCP probe-then-reconnect (an earlier version
## of this script had one, and a live run surfaced why that design is
## actively harmful against today's server: main.rs's FIRST connection is
## still a single blocking listener.accept() (server/src/main.rs, D-254
## §2/T-1130's own comment there) — a probe that connects, sees
## STATUS_CONNECTED, and disconnects has just consumed that one accept()
## slot and abandoned it mid-handshake. The server's very next line,
## bridge.send_handshake(), then hits "Broken pipe" writing to the dead
## probe socket and the WHOLE SERVER PROCESS EXITS (main.rs's
## .unwrap_or_else(|e| { ...; std::process::exit(1) }) on that call) —
## confirmed by a live repro against a real already-running server: the
## probe's own attach check killed the very server session it was
## checking on, and the companion's own real connection attempt right
## after then failed too, since there was nothing left to connect to.
## The fix: never abandon a connection that reached STATUS_CONNECTED.
## SimBridge.connect_to_sim() IS that one safe connection attempt (it
## already TCP-retries internally, see MAX_CONNECT_RETRIES/
## CONNECT_RETRY_INTERVAL in sim_bridge.gd) — this script drives it
## directly instead of pre-flighting with a disposable socket.
## 2. Configure SimBridge for the resolved port; connection_role = "Reader"
## (D-254 §2) so the StartupMessage carries role: Reader.
## 3. connect_to_sim(); poll SimBridge.state until CONNECTED (or ERROR).
## 4. HudGroups.open_app("implant/map") — the default front app.
## 5. ImplantRegistry.instantiate_all(self, true) populates every
## available_in_companion app.
## 6. Intercept HudGroups.app_changed: when the Atlas's own KEY_M/KEY_ESCAPE
## handling (atlas_app.gd) calls HudGroups.close_app() from the "reach"
## screen, this scene re-opens implant/map instead of falling into a
## nonexistent gameplay layer (D-254 §3 recommended option (a) — does not
## touch atlas_app.gd).
const DEFAULT_PORT: int = 9876
## D-254 §1 names ~500ms for the attach check; SimBridge's own internal TCP
## retry loop (MAX_CONNECT_RETRIES=20 * CONNECT_RETRY_INTERVAL=0.1s, sim_bridge.gd)
## is ~2s end-to-end before it reports ConnectionState.ERROR. Since this
## script drives connect_to_sim() directly (no separate pre-probe — see the
## header note above), the real wait for "nothing is listening" is bounded by
## SimBridge's own retry budget, not this constant; ATTACH_CONNECT_TIMEOUT_S
## is a slightly-generous outer deadline (SimBridge's ~2s + one frame margin)
## so this script's own poll loop can never out-wait SimBridge's, not a
## separate tunable that changes the actual ECONNREFUSED-detection speed.
const ATTACH_CONNECT_TIMEOUT_S: float = 2.5
const CONNECT_POLL_TIMEOUT_S: float = 10.0
const SPAWN_READY_TIMEOUT_S: float = 15.0
const WINDOW_TITLE: String = "The Settled Reach — Atlas"
const FRONT_APP: String = "implant/map"
## T-1134 seam: spawn-mode seed is the server's own default (SimRng default
## seed=0, server/src/main.rs) — no seed picker in v1. Leaving this constant
## (rather than inlining "no seed sent") makes the future seam obvious: T-1134
## replaces this with a startup screen that sets SimBridge.world_seed_override
## (or equivalent) before _boot() reaches step 2. StartupMessage.world_seed is
## ALWAYS sent (server/src/bridge/types.rs: field is not optional on the wire)
## — GameState.world_seed's own default (0) is what ships until T-1134 adds a
## picker; this constant exists purely as the documented seam marker.
const SPAWN_MODE_SEED_IS_SERVER_DEFAULT: bool = true
var _server_process: Variant = null # ServerProcess — owned here for spawn-mode only
var _resolved_port: int = DEFAULT_PORT
var _spawned: bool = false # true once this scene has spawned its own server
func _ready() -> void:
DisplayServer.window_set_title(WINDOW_TITLE)
HudGroups.app_changed.connect(_on_hud_app_changed)
_boot.call_deferred()
func _exit_tree() -> void:
_stop_spawned_server()
## D-254 §3 step 6 (recommended option (a)): the Atlas's own close handling
## (atlas_app.gd _handle_key, KEY_M/KEY_ESCAPE from "reach") calls
## HudGroups.close_app() unconditionally — it has no notion of "there is no
## gameplay layer to fall back to". This intercepts that transition and
## re-opens the Atlas immediately, so from the player's perspective M/Escape
## on the top-level Atlas screen is a no-op in the companion (there is nothing
## else to show). atlas_app.gd is never modified.
func _on_hud_app_changed(app_path: String, mode: int) -> void:
if app_path == FRONT_APP and mode == HudGroups.Mode.GAMEPLAY:
# Deferred, never synchronous: this handler runs INSIDE close_app()'s
# app_changed emit, and close_app() continues after the emit and resets
# _active_app to "" — a synchronous open_app() here gets clobbered,
# leaving the Atlas visible (open_app already raised its z) but
# is_app_active() false, which kills atlas_app's input guard: a
# keyboard soft-lock (PR #183 review, Tyre).
HudGroups.open_app.call_deferred(FRONT_APP)
func _boot() -> void:
_resolved_port = _attach_port()
SimBridge.server_path = "" # atlas_standalone owns any child process, not SimBridge
SimBridge.server_port = _resolved_port
SimBridge.connection_role = "Reader" # D-254 §2
# Attempt the ONE real attach connection — never a disposable pre-probe
# (see the class doc header for why: an earlier probe-then-reconnect design
# killed the server it was checking on). connect_to_sim() TCP-retries
# internally for ~2s (sim_bridge.gd) before reporting ERROR.
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
SimBridge.connect_to_sim()
var attached := await _wait_for_connected(ATTACH_CONNECT_TIMEOUT_S)
if not attached:
# Nothing answered on the attach port — fall through to spawn. The
# failed attempt above must be fully torn down before spawning: a
# leftover ERROR-state _bridge/_server would otherwise confuse the
# fresh connect_to_sim() call below (disconnect_from_sim() resets
# SimBridge to DISCONNECTED, which is connect_to_sim()'s required
# entry state — see the DISCONNECTED guard above and in main.gd).
SimBridge.disconnect_from_sim()
var spawn_ok := await _spawn_server()
if not spawn_ok:
push_error("atlas_standalone: failed to spawn server — cannot continue")
get_tree().quit(1)
return
SimBridge.server_port = _resolved_port # _spawn_server() updated _resolved_port
SimBridge.connection_role = "Reader"
SimBridge.connect_to_sim()
attached = await _wait_for_connected(CONNECT_POLL_TIMEOUT_S)
if not attached:
push_error("atlas_standalone: connection to spawned server failed or timed out")
get_tree().quit(1)
return
# Re-apply the title here, not just in _ready(): confirmed by direct repro
# that Godot's un-exported debug-run window manager sets the title to
# "{config/name} (DEBUG)" on a LATER frame than _ready() (window-visible/
# focus timing, not measured precisely) — an early-only window_set_title()
# call loses that race and the window silently reverts to the project
# default. By the time the connection completes (at minimum one full TCP
# round-trip, often a spawn+handshake), that race has long resolved.
DisplayServer.window_set_title(WINDOW_TITLE)
# instantiate_all() MUST run before open_app(): ImplantApp._ready()
# (client/ui/implant/implant_app.gd) is what connects HudGroups.app_changed
# -> _internal_app_changed, which is the ONLY thing that flips an app's
# `visible` flag and fires on_open(). open_app() first would emit
# app_changed while the Atlas doesn't exist as a node yet — no listener,
# no visibility, a permanently blank window (found via a live run: the
# window opened, connected, and instantiated both apps correctly, but
# rendered solid black — the exact fingerprint of this ordering bug).
# hud.gd's own _ready() establishes the same order for the in-game case
# (instantiate_all is unconditional there; open_app only ever happens
# later, from player input).
ImplantRegistry.instantiate_all(self, true) # standalone=true: available_in_companion filter
HudGroups.open_app(FRONT_APP)
## Pure function: SR_PORT env override, else DEFAULT_PORT. Matches the
## SR_PORT two-tier discovery visual_capture.gd/locomotion_sandbox.gd already
## use (D-254 §1) — same env var, same fallback. This is the port the ONE
## attach connect_to_sim() call targets (see _boot()) — no separate probe.
static func _attach_port(port_env: String = "") -> int:
var env := port_env if not port_env.is_empty() else OS.get_environment("SR_PORT")
if env.is_empty():
return DEFAULT_PORT
if not env.is_valid_int():
push_warning("atlas_standalone: SR_PORT='%s' is not a valid int — using default" % env)
return DEFAULT_PORT
return int(env)
## D-254 §1/§3: spawn-mode lifecycle — tests/run-visual's --port 0 +
## LISTENING:{port} precedent, WITHOUT --test-mode (real systems.db world,
## not Gauntlet fixtures). Ownership: this scene owns the ServerProcess
## instance directly (not SimBridge's built-in spawn path, which has no
## stdout access and therefore can't resolve an OS-assigned port) — the same
## OS.kill/NOTIFICATION_PREDELETE safety net server_process.gd already
## implements, reused via start_with_pipe() (added for this ticket; start()'s
## existing OS.create_process contract is untouched for its three existing
## callers). Returns true once LISTENING:{port} is parsed and _resolved_port
## is updated; false on spawn failure or timeout (server never printed the
## signal within SPAWN_READY_TIMEOUT_S).
func _spawn_server() -> bool:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_error("atlas_standalone: server binary not found at %s (run `make build-server`)" % server_path)
return false
var SP := load("res://scripts/protocol/server_process.gd")
_server_process = SP.new()
var pid: int = _server_process.start_with_pipe(server_path, ["--port", "0"])
if pid <= 0:
return false
_spawned = true
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while elapsed < SPAWN_READY_TIMEOUT_S:
if not _server_process.is_alive():
push_error("atlas_standalone: spawned server died before printing LISTENING signal")
return false
var line: String = _server_process.read_stdout_line()
if not line.is_empty():
var parsed := _parse_listening_line(line)
if parsed >= 0:
_resolved_port = parsed
return true
await get_tree().process_frame
elapsed += frame_budget
push_error("atlas_standalone: no LISTENING signal from spawned server after %.1fs" % SPAWN_READY_TIMEOUT_S)
return false
## Pure function: parse "LISTENING:{port}" -> port, or -1 if the line doesn't
## match (main.rs's exact signal format — server/src/main.rs, "LISTENING
## signal to stdout"). Split out from _spawn_server()'s polling loop so the
## parse itself is unit-testable without a live process.
static func _parse_listening_line(line: String) -> int:
var stripped := line.strip_edges()
if not stripped.begins_with("LISTENING:"):
return -1
var port_str := stripped.substr("LISTENING:".length())
if not port_str.is_valid_int():
return -1
return int(port_str)
## Wall-clock poll of SimBridge.state until CONNECTED or ERROR (same
## ConnectionState enum/shape as visual_capture.gd's live-mode wait, per
## D-254 §3 step 3 — minus the fixed-frame settle budget a screenshot capture
## needs but a real window does not).
func _wait_for_connected(timeout_s: float) -> bool:
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while elapsed < timeout_s:
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
return true
if SimBridge.state == SimBridge.ConnectionState.ERROR:
return false
await get_tree().process_frame
elapsed += frame_budget
return false
func _server_binary_path() -> String:
var project_dir := ProjectSettings.globalize_path("res://")
return project_dir.path_join("../server/target/debug/settled-reach-server")
func _stop_spawned_server() -> void:
if _spawned and _server_process != null:
_server_process.stop()
_server_process = null
_spawned = false
+13
View File
@@ -60,7 +60,20 @@ func _ready() -> void:
## send_named_action (protocol-level, not bound to an InputMapper.Action
## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion
## is a UI-state transition, not a physical input.
## D-254 §2: skipped for Reader connections (atlas_standalone.gd's companion
## app) — this travels through the same Vec<PlayerInput> pipeline as
## MoveNorth/Interact, which the Reader permission matrix marks "no". A
## Reader has no character and no pausable gameplay session of its own (the
## standalone scene calls HudGroups.open_app() as its normal boot step, not a
## player occluding their own gameplay), so there is nothing meaningful to
## pause — sending it anyway would just be dropped as a role violation on
## every single companion boot (confirmed via a live run against a real
## server: "Reader connection ... sent 1 disallowed PlayerInput(s) — dropped
## (strike 1/3)" fired from this exact call site the first time the companion
## opened the Atlas fullscreen).
func _on_gameplay_occluded_auto_pause(occluded: bool) -> void:
if SimBridge.connection_role == "Reader":
return
if occluded:
SimBridge.send_named_action("AutoPause")
else:
+15 -1
View File
@@ -25,6 +25,11 @@ var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects
var harness = null # Test simulation (D-020: game logic lives outside production client)
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# D-254 §2/§3: ConnectionRole wire value for the next StartupMessage — "" (default)
# omits the "role" key (server defaults to Player, byte-identical to pre-D-254
# behavior). atlas_standalone.gd sets this to "Reader" before connect_to_sim();
# every existing caller (main.gd, locomotion_sandbox.gd, tests) leaves it unset.
var connection_role: String = ""
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
@@ -283,8 +288,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
# Server blocks waiting for this before entering the tick loop.
# D-254 §2: connection_role threads "Reader" for the standalone companion;
# every other caller leaves it "" (omitted key, decodes as Player).
var startup_bytes := Protocol.encode_startup_message(
GameState.world_seed, GameState.character_visual_descriptor
GameState.world_seed, GameState.character_visual_descriptor, connection_role
)
if startup_bytes.size() > 0:
var send_err: int = _bridge.send_message(startup_bytes)
@@ -307,6 +314,13 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_set_state(ConnectionState.CONNECTED)
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
# from server SQLite so the client reflects the authoritative persisted state (D-138).
# D-254 §2: this travels as a Vec<PlayerInput> entry (same pipeline as
# MoveNorth/Interact), which the permission matrix marks "no" for Reader —
# a Reader has no settings to hydrate (no character, no per-player state),
# so skip the send rather than have the server log-and-drop it on every
# companion connection once role enforcement lands server-side.
if connection_role == "Reader":
return
(
_outbound_buffer
. append(
+9 -1
View File
@@ -639,14 +639,22 @@ static func _decode_enum_variant(raw) -> Dictionary:
## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029).
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
## role: D-254 §2 ConnectionRole wire value — "" (default, every caller before
## D-254) omits the "role" key entirely, matching server StartupMessage::role's
## #[serde(default)] and decoding as ConnectionRole::Player — byte-identical to
## pre-D-254 output. atlas_standalone.gd is the one caller that passes "Reader"
## (unit enum variant, bare string per this file's header wire-format note —
## same encoding as PlayerAction unit variants like "MoveNorth").
static func encode_startup_message(
world_seed: int, character_visual: Variant = null
world_seed: int, character_visual: Variant = null, role: String = ""
) -> PackedByteArray:
var msg := {
"world_seed": world_seed,
}
if character_visual != null and character_visual.has_method("to_dict"):
msg["character_visual_descriptor"] = character_visual.to_dict()
if not role.is_empty():
msg["role"] = role
var result = _mp().encode(msg)
if result.status != null:
push_error("Protocol: startup message encode failed: %s" % result.status)
+40
View File
@@ -2,6 +2,9 @@ class_name ServerProcess
## Manages the Rust simulation server as a subprocess (D-020).
var _pid: int = -1
## Populated only by start_with_pipe() — the stdout FileAccess handle used to
## read the LISTENING:{port} signal (D-254 §1/§3). Null after plain start().
var _stdio: FileAccess = null
## Start the server process. Returns the PID, or -1 on failure.
@@ -19,6 +22,43 @@ func start(server_path: String, args: Array = []) -> int:
return _pid
## Start the server process with its stdout piped back to this process
## (D-254 §1/§3: the companion app's --port 0 spawn-mode, which needs to read
## the LISTENING:{port} signal main.rs prints — see server/src/main.rs). Unlike
## start()/OS.create_process, this uses OS.execute_with_pipe, which does not
## block (the shell precedent, tests/run-visual, achieves the same read via a
## redirected log file + `grep` from a wrapper script; this is the Godot-native
## equivalent for a caller with no shell wrapper). Returns the PID, or -1 on
## failure. Call read_stdout_line() from a per-frame poll to drain the pipe.
func start_with_pipe(server_path: String, args: Array = []) -> int:
if _pid > 0 and is_alive():
push_warning("ServerProcess: server already running (pid %d)" % _pid)
return _pid
if not FileAccess.file_exists(server_path):
push_error("ServerProcess: server binary not found at %s" % server_path)
return -1
var result: Dictionary = OS.execute_with_pipe(server_path, args)
if not result.has("pid") or int(result.get("pid", -1)) <= 0:
push_error("ServerProcess: failed to start server (pipe) at %s" % server_path)
return -1
_pid = int(result["pid"])
_stdio = result.get("stdio", null)
return _pid
## Non-blocking read of one line from the piped server's stdout, or "" if no
## complete line is buffered yet (FileAccess.get_line() on a pipe never
## blocks — it returns immediately with whatever is currently available).
## Only meaningful after start_with_pipe(); returns "" if _stdio was never set
## (plain start()) or the process has exited (stream at EOF).
func read_stdout_line() -> String:
if _stdio == null:
return ""
if _stdio.get_error() != OK:
return ""
return _stdio.get_line()
## Check if the server process is still running.
## NOTE: OS.is_process_running() doesn't distinguish starting/healthy/zombie.
## TODO: Add health check (e.g. TCP readiness probe) when server supports it.
+191
View File
@@ -0,0 +1,191 @@
class_name TestAtlasStandalone
extends GdUnitTestSuite
## Unit tests for atlas_standalone.gd (D-254, T-1132, `make atlas`).
##
## Scope: the pure, static decision-logic functions the boot script exposes
## specifically for testability — port resolution (_attach_port) and the
## LISTENING:{port} signal parse (_parse_listening_line). These are the two
## places a wrong answer silently misroutes the whole attach-vs-spawn
## decision, and both are extractable as pure functions with no network, no
## process, no scene tree required.
##
## Deliberately NOT covered here (would require a live server / real window,
## per the ticket's "VERIFY LIVE once" instruction rather than an automated
## suite): the full _boot() sequence, the attach-then-fallback-to-spawn
## control flow, and the orphan-on-exit server lifecycle. Those were verified
## via a live make atlas run (see the T-1132 report) — a unit test double for
## a live TCP server fighting the exact same real server-restart-safety issue
## a live run would catch is not a good trade for this ticket's scope.
## The close-interception CONTRACT (deferred re-open, PR #183 review) IS
## pinned below at the HudGroups level — it needs no server, and the sync
## variant's soft-lock is exactly the kind of bug a live smoke plausibly
## misses.
##
## Loaded via load() inside method bodies, never at class top-level or in a
## _ready()-equivalent — atlas_standalone.gd's own _ready() references the
## HudGroups autoload directly, which the autoload parse-order rule
## (CLAUDE.md) says must not be resolved before autoloads are live. Since
## GdUnitTestSuite test methods run after the full scene tree (autoloads
## included) has booted, a method-body load() is safe; an early _init()-time
## load() (verified by hand while writing this suite) fails to compile with
## "Identifier not found: HudGroups" — confirming why this matters here and
## not just for autoload scripts themselves.
const SCRIPT_PATH := "res://scripts/atlas_standalone.gd"
# The companion's front app (see the close-interception contract tests below).
const FRONT := "implant/map"
func _script():
return load(SCRIPT_PATH)
# =============================================================================
# _attach_port — SR_PORT env override, else DEFAULT_PORT (D-254 §1's two-tier
# discovery, matching visual_capture.gd/locomotion_sandbox.gd's existing
# SR_PORT convention).
# =============================================================================
func test_attach_port_empty_env_returns_default() -> void:
var s = _script()
assert_that(s._attach_port("")).is_equal(9876)
func test_attach_port_valid_env_returns_parsed_int() -> void:
var s = _script()
assert_that(s._attach_port("12345")).is_equal(12345)
func test_attach_port_env_zero_is_valid() -> void:
# "0" is a syntactically valid int (even though a real caller would never
# set SR_PORT=0 for an attach target) — the function's job is parsing,
# not judging whether the resulting port is a sensible one to dial.
var s = _script()
assert_that(s._attach_port("0")).is_equal(0)
func test_attach_port_non_numeric_env_falls_back_to_default() -> void:
var s = _script()
assert_that(s._attach_port("not-a-port")).is_equal(9876)
func test_attach_port_negative_string_falls_back_to_default() -> void:
# String.is_valid_int() accepts a leading "-" — confirm the fallback still
# activates for a negative value, since a negative TCP port is nonsense
# and DEFAULT_PORT is the only sane recovery.
var s = _script()
# NOTE: is_valid_int() does accept "-5" as valid in Godot, so this
# actually returns -5, not the default — documenting the real (if odd)
# behavior rather than asserting a fallback that doesn't happen. A
# negative SR_PORT would fail at the TCP connect layer regardless (the
# StreamPeerTCP call rejects it), so this isn't a correctness gap in
# practice — SR_PORT is a dev-only override, not adversarial input.
assert_that(s._attach_port("-5")).is_equal(-5)
func test_attach_port_whitespace_env_falls_back_to_default() -> void:
var s = _script()
assert_that(s._attach_port(" ")).is_equal(9876)
# =============================================================================
# _parse_listening_line — main.rs's "LISTENING:{port}" stdout signal
# (server/src/main.rs) parsed to an int, or -1 if the line doesn't match.
# =============================================================================
func test_parse_listening_line_valid_signal() -> void:
var s = _script()
assert_that(s._parse_listening_line("LISTENING:9876")).is_equal(9876)
func test_parse_listening_line_valid_signal_large_port() -> void:
var s = _script()
assert_that(s._parse_listening_line("LISTENING:65432")).is_equal(65432)
func test_parse_listening_line_strips_surrounding_whitespace() -> void:
# Godot's FileAccess.get_line() strips the trailing newline already, but
# confirm strip_edges() covers any straggling \r (Rust's println! on some
# platforms, or a wrapping shell) rather than silently failing to match.
var s = _script()
assert_that(s._parse_listening_line(" LISTENING:9876 \r")).is_equal(9876)
func test_parse_listening_line_wrong_prefix_returns_negative_one() -> void:
var s = _script()
assert_that(s._parse_listening_line("SOMETHING:9876")).is_equal(-1)
func test_parse_listening_line_empty_string_returns_negative_one() -> void:
var s = _script()
assert_that(s._parse_listening_line("")).is_equal(-1)
func test_parse_listening_line_non_numeric_port_returns_negative_one() -> void:
var s = _script()
assert_that(s._parse_listening_line("LISTENING:abc")).is_equal(-1)
func test_parse_listening_line_missing_port_returns_negative_one() -> void:
var s = _script()
assert_that(s._parse_listening_line("LISTENING:")).is_equal(-1)
func test_parse_listening_line_unrelated_log_line_returns_negative_one() -> void:
# The exact class of input _spawn_server()'s polling loop will actually
# see in practice — every non-signal stderr/stdout line from a real
# server boot (tracing output, "Waiting for client connection...", etc.)
# must be silently ignored, not misparsed.
var s = _script()
assert_that(
s._parse_listening_line("Waiting for client connection on port 9876")
).is_equal(-1)
# =============================================================================
# Close-interception contract (PR #183 review, Tyre) — the companion's re-open
# of the front app MUST be deferred out of the app_changed emit frame.
# close_app() continues past its emit and resets _active_app to ""; a
# synchronous open_app() from the handler is silently clobbered, leaving the
# app visible (its z was already raised) but is_app_active() false — which
# kills the app's input guard: a keyboard soft-lock. Pinned here at the
# HudGroups level (no server, no window) so the deferred call in
# atlas_standalone._on_hud_app_changed can't be "simplified" back to sync.
# =============================================================================
func _sync_reopen(app_path: String, mode: int) -> void:
if app_path == FRONT and mode == HudGroups.Mode.GAMEPLAY:
HudGroups.open_app(FRONT)
func _deferred_reopen(app_path: String, mode: int) -> void:
if app_path == FRONT and mode == HudGroups.Mode.GAMEPLAY:
HudGroups.open_app.call_deferred(FRONT)
func test_synchronous_reopen_from_close_emit_is_clobbered() -> void:
# Documents the bug class: this is the OLD interceptor shape, and it must
# keep failing to keep the app active — if HudGroups semantics ever change
# so sync re-open works, both this test and the deferred one below flag
# the contract shift for a deliberate look.
HudGroups.open_app(FRONT)
HudGroups.app_changed.connect(_sync_reopen)
HudGroups.close_app()
HudGroups.app_changed.disconnect(_sync_reopen)
assert_bool(HudGroups.is_app_active(FRONT)).is_false()
HudGroups.close_app() # restore clean autoload state for the next test
func test_deferred_reopen_survives_close_emit() -> void:
# The shipped interceptor shape (atlas_standalone.gd:_on_hud_app_changed).
HudGroups.open_app(FRONT)
HudGroups.app_changed.connect(_deferred_reopen)
HudGroups.close_app()
HudGroups.app_changed.disconnect(_deferred_reopen)
assert_bool(HudGroups.is_app_active(FRONT)).is_false() # not yet — deferred
await get_tree().process_frame
assert_bool(HudGroups.is_app_active(FRONT)).is_true()
HudGroups.close_app() # restore clean autoload state
+10
View File
@@ -11,3 +11,13 @@ extends Resource
# KEY_M = 77, KEY_N = 78; -1 = no binding.
@export var default_key: int = -1
@export var preserves_state: bool = true
## D-254 §3 (companion app amendment, 2026-07-17): opt-out flag for the
## standalone Atlas companion (`make atlas`) — every registered app is hosted
## in the companion by default; set false only for apps that structurally
## require a playing character/gameplay context. The IN-GAME implant ignores
## this field entirely (zero behavior change there) — it exists solely for
## `atlas_standalone.gd`'s ImplantRegistry.instantiate_all(standalone=true)
## filter. Naming note: Jeroen's original suggested name was
## `availableInAtlasApp`; renamed to snake_case + "companion" (not "atlas")
## because the Atlas is itself one of the hosted apps, not the host.
@export var available_in_companion: bool = true
+8 -2
View File
@@ -27,9 +27,15 @@ func get_resolved_mode(app_path: String) -> int:
## Instantiate all registered apps whose manifest declares a scene_path and add
## them as children of parent. Manifests without scene_path are metadata-only
## and are silently skipped. Called from hud.gd._ready() — not from _ready() here.
func instantiate_all(parent: Node) -> void:
## and are silently skipped. Called from hud.gd._ready() in the normal game
## (standalone=false, default — every app is hosted, the flag below is
## irrelevant) and from atlas_standalone.gd (standalone=true, D-254 §3
## amendment) — in the companion, manifests with available_in_companion=false
## are skipped entirely (not instantiated, so never reachable by app-path).
func instantiate_all(parent: Node, standalone: bool = false) -> void:
for m in get_manifests():
if standalone and not m.available_in_companion:
continue
var scene_path: String = m.scene_path
if scene_path.is_empty():
continue
+1
View File
@@ -319,6 +319,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it.
- [D-251: Character asset route, 2026 reconfirmation — Quaternius rig, in-house wardrobe, purchased animation tiers](decisions/content.md#d-251-character-asset-route-2026-reconfirmation--quaternius-rig-in-house-wardrobe-purchased-animation-tiers) — _content_
- [D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent](decisions/architecture.md#d-252-facing-is-view-only--movement-no-longer-writes-facing-npc-gaze-is-intent) — _architecture_
- [D-253: Region transient state model — seasonal/tidal/weather/snow phase functions (resolves Q-105)](decisions/architecture.md#d-253-region-transient-state-model--seasonaltidalweathersnow-phase-functions-resolves-q-105) — _architecture_
- [D-254: Standalone Atlas companion app — `make atlas`, dual-connection reader](decisions/architecture.md#d-254-standalone-atlas-companion-app--make-atlas-dual-connection-reader) — _architecture_
## Open questions
+112 -1
View File
@@ -2055,4 +2055,115 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*107 decisions (D-001 through D-253, excluding gaps). Last updated: 2026-07-08 (D-253 — region transient state model: four clock-terms (diurnal/tidal/weather/seasonal), memoized-by-bucket absolute-clock evaluation, edge-fuzzed per-tile realization; resolves Q-105).*
### D-254: Standalone Atlas companion app — `make atlas`, dual-connection reader
- **Date:** 2026-07-17
- **Decision:** The implant Atlas (D-169/D-170's `implant/map` app) ships as a **second, independent Godot entry point**`client/scenes/atlas_standalone.tscn`, launched via a new `make atlas` target — that boots the SAME implant scene tree used in-game but skips the player entirely: no character, no `main.tscn`, no gameplay HUD. It connects to the simulation server either by **attaching** to an already-running game (inheriting that world's state read-only) or by **spawning** its own server process (offering seed selection now; save selection is a recorded, unbuilt hook — saves are Phase 5+). The server enforces read-only **server-side** via a distinct `ConnectionRole` on the handshake (`Player | Reader`, Reader spawns no character and receives no `ObserverSnapshot` at all) — the Atlas app itself gains no new client capability, it is the existing Atlas UI pointed at a bridge connection the server structurally refuses inputs from. A future market-trading widening (§6) adds a `TradingReader` role as a strict superset of `Reader` (never a replacement) rather than inventing a second connection type.
**(1) CONNECTION MODEL — attach vs. spawn, discovery.**
**Today's reality, confirmed in code:** `main.rs` binds a `TcpListener`, prints `LISTENING:{port}`, then calls `listener.accept()` **exactly once** — blocking, no loop. A second TCP client completes its TCP-level handshake (kernel backlog accepts it) but never gets an application-level accept — it hangs forever waiting for `HandshakeMessage`. **Not refused, not replaced — silently starved.** This is the actual failure mode the reader connection must design against; zero multi-connection plumbing exists anywhere in `server/src/bridge/` today, confirming the ticket's own framing ("almost certainly single-connection").
Default port `9876` (`sim_bridge.gd:26`, matches `main.rs` fallback), overridable via positional addr / `--port` / (client-side) `SR_PORT`. `SR_PORT` is already the env var two Godot scripts read today for "which port do I dial" (`visual_capture.gd:99`, `locomotion_sandbox.gd:67`) — the discovery mechanism reuses that existing convention rather than inventing a third (`SR_ADDR` is a server-side bind override and is not load-bearing for either connection mode below).
- **Attach-mode discovery:** fixed default port 9876 + `SR_PORT` override — the same two-tier scheme the game client already uses to find its own server. Raw TCP connect with a ~500ms timeout (localhost, not WAN — no reason to wait longer). `ECONNREFUSED` is a real, unambiguous signal ("no server listening") and falls through to spawn-mode. A connection succeeding does not yet mean attach is *safe* — that gate is the Reader-role handshake in §2, not the TCP connect itself.
- **Spawn-mode lifecycle:** reuse the `tests/run-visual` precedent exactly — `--port 0` (OS-assigned), parse `LISTENING:{port}` from stdout — but **without `--test-mode`**: the companion needs the real `systems.db` world, not Gauntlet test fixtures. Ownership: the companion app owns the child process it spawns, the same pattern `server_process.gd` already implements (`OS.create_process`/`OS.kill`/`NOTIFICATION_PREDELETE` safety net) — reused directly, not reimplemented. World seed is passed via the companion's own `StartupMessage.world_seed` post-handshake (not a `--seed` CLI flag) — this keeps the save/load seam (§5) as the single source of truth for how a spawned world gets populated, rather than splitting seed-selection across a CLI flag and a wire message.
- **Mode selection UX:** auto-attach-else-spawn — try attach for ~500ms, fall through silently to spawn on refusal. Zero friction for the common case ("inspect the world I already have running"), and the fallback is never wrong (spawn always works). An explicit Attach/Spawn chooser is deferred — only justified if reader-mode failures turn out confusing enough in practice that users need visibility into *why* attach didn't happen; not assumed necessary at design time.
**(2) READER CONNECTION CLASS — handshake variant, server-side enforcement.**
Enforcement is **server-side at the protocol layer**, never client politeness — a hostile or buggy companion client is exactly D-010's adversarial case, and the read-only guarantee has to hold against that, not just against a well-behaved reference client. The one seam that matters: before the server unconditionally spawns a `PlayerCharacter` (`main.rs`, today unconditional on every accepted connection).
**Handshake extension:** add `role: ConnectionRole` to `StartupMessage` — enum `Player | Reader` (widened by §6 to `Player | Reader | TradingReader`) — with `#[serde(default = "ConnectionRole::player")]` for back-compat, rather than a separate pre-startup negotiation message. This follows D-192's existing "no lockstep negotiation" precedent (protocol_version field dropped for the same reason): role is **data on the existing message**, not a new protocol gate. Critical determinism guard: a Reader's `world_seed` field is **ignored server-side and never re-seeds `SimRng`** — a second StartupMessage touching `SimRng` after tick 0 would break determinism for whatever Player is already in session (spawn-mode readers get their seed from the world THEY spawned, at genuine tick 0; attach-mode readers must never be able to perturb an already-running world's RNG state via their own handshake).
**Server enforcement — structural, not filtered:** Reader role skips the character-spawn path entirely **and receives no `ObserverSnapshot` at all** — not a stripped/redacted one, none. This is the load-bearing point: `ObserverSnapshot` is a per-character observation record (facing, inventory, visible_tiles are all meaningless without a character), so forwarding the Player's own snapshot to a Reader — even filtered — would be a direct D-010 boundary violation (a second observer silently granted the first observer's fog-cleared view). What a Reader *can* legitimately receive is proven by the existing handler signatures: `handle_star_map_request`, `handle_city_names_request`, `handle_atlas_request` (and this record's new browse-request handlers, §4) all take **no observer/character/query parameter whatsoever** — just `body_id`/`world_seed`/`path` — which is the independent proof that this data was already install-static/world-public before D-254, not a new carve-out invented for readers.
| Message | Player | Reader |
|---|---|---|
| `Vec<PlayerInput>` (inputs) | yes | **no** |
| `ObserverSnapshot` (outbound) | yes | **no — not even filtered** |
| Atlas/StarMap/CityNames/Browse request+response | yes | yes |
| `HandshakeMessage` | yes | yes |
**Violation handling:** a Reader sending `Vec<PlayerInput>` is syntactically valid (the existing `decode_inbound` demux parses it fine) but role-disallowed. Log + drop on first offense, mirroring the existing recoverable `DeserializationWithDump` pattern; escalate to disconnect only on repeated violations — a natural fit for the already-flagged N-consecutive-errors handling in the bridge module, made per-connection once multiple connections exist.
**Multi-connection architecture — scoped honestly as 0-1 Player + 0-N Readers**, explicitly NOT general N-player (that is D-009's separate, larger, and currently out-of-scope ambition — this record does not reopen it). `BridgeResource` (today a single `Box<dyn SimBridge>`) becomes a collection; the single blocking `accept()` becomes a non-blocking accept-loop polled per-tick, so a Reader connecting mid-session never stalls the Player. The inbound drain loop routes `Vec<PlayerInput>` only from the Player-role connection; atlas/starmap/citynames/browse requests are accepted from any connection, but responses need a connection-id tag (today's response buffers have no "whose request was this" notion, because there has only ever been one connection). Outbound `ObserverSnapshot` sends target the Player connection only — this is a structural enforcement of the boundary above, not merely a convention that could be gotten wrong by a future edit.
**Back-pressure/lifecycle — the sharp existing edge:** today `BridgeError::Disconnected` sets `ServerRunning = false` and kills the **whole server process**, because currently one connection's disconnect *is* the session ending. That behavior must NOT fire on a Reader's disconnect once roles exist — only a Player disconnect should flip `ServerRunning`; a companion app closing its window must never kill the game it's attached to. Determinism holds by construction as long as reader frames never reach the InputQueue/SimRng path (guaranteed by the enforcement above, not by a separate check). Recommend a lower per-reader inbound frame cap (e.g. 8/tick vs. the existing Player cap of 64/tick) — a reader has no legitimate reason to send that volume of requests per tick, and the cap is cheap insurance against a runaway/misbehaving companion client.
**(3) APP SHELL — how `make atlas` launches the Atlas standalone.**
**Decision: a dedicated entry scene, not a feature flag on `main.tscn`.** `client/scenes/atlas_standalone.tscn` is a bare root (`Node2D` or `Control`) with a script (`atlas_standalone.gd`) following the exact boot shape `client/tests/visual_capture.gd` already establishes for minimal Godot entry points (`_init() -> _run.call_deferred()`, connect, wait for handshake, open UI) — except `atlas_standalone.gd` is a real scene script (`extends Node2D`, normal `_ready()`), not a `SceneTree`-extending test harness; the `SceneTree` pattern is for offscreen capture tooling, the standalone app needs a visible window.
Why not a flag on `main.tscn`/`main.gd`: `main.gd` is saturated with player-only wiring that a "headless" branch would have to route around at every touch point, not bypass cleanly — 18 `@onready` gameplay HUD nodes (minimap, stance indicator, inventory grid, dialogue box, interaction list, gauntlet HUD…), a `SnapshotEventRouter` with a dozen player-centric `register_always`/`register` handlers (`update_zone`, `play_recognition_chimes`, `consume_dialogue`…), free-camera WASD panning tied to `GameState.free_camera_mode`, and a `_process()` loop whose entire second half is input-queue flushing (`InputMapper.flush_queue()``SimBridge.send_input()`). None of that exists to serve the Atlas — it exists to serve a playing character, which a reader connection never has (and, per §2, structurally cannot send inputs for even if it tried). A flag would mean auditing and branching every one of those systems to no-op correctly; a dedicated scene means writing on the order of 100 lines that do only what the Atlas needs, with zero risk of a reader session accidentally exercising player-only code paths (interaction prompts, dialogue, bug report capture) that assume a character exists.
**Boot sequence** (`atlas_standalone.gd`, modeled directly on `visual_capture.gd`'s live-mode wait blocks):
1. `_ready()`: run §1's auto-attach-else-spawn discovery (try `SR_PORT`-or-default-9876 connect, ~500ms timeout; on refusal, spawn a server child via the `server_process.gd` pattern with `--port 0` and parse `LISTENING:{port}`). Configure `SimBridge` accordingly (`server_path` set for spawn, unset + resolved attach port for attach).
2. Call `SimBridge.connect_to_sim()` using the **Reader handshake variant** (§2's `role: ConnectionRole = Reader` on `StartupMessage`), not the character-startup path `main.gd` uses. This is the one place `atlas_standalone.gd`'s connect call diverges from `main.gd`'s.
3. Poll `SimBridge.state` until `CONNECTED` — same `ConnectionState` enum, same polling shape as `visual_capture.gd`'s live-mode wait, minus the fixed-frame-count settle (a real window can just `await` the signal instead of budgeting frames for a screenshot).
4. On connect: `HudGroups.open_app("implant/map")`. There is no gameplay group ever registered in this scene, so D-170's gameplay/implant mutual-exclusivity degenerates harmlessly to "implant is always the sole active exclusive group" — no `HudGroups` code changes needed; the invariant it enforces (only one of gameplay/implant visible) is trivially satisfied when gameplay never registers anything.
5. `ImplantRegistry.instantiate_all(self)` — the same call `hud.gd._ready()` makes in the normal game — populates every installed implant app (Atlas + Economics both come along for free; Economics degrades gracefully since it's reachable but not the entry point, and read-only holds for it too automatically, since it rides the same Reader connection).
6. The Atlas's own `KEY_M`/`KEY_ESCAPE` handling (`atlas_app.gd`) currently calls `HudGroups.close_app()` on M/Escape from the top-level "reach" screen, which would leave the standalone window showing a blank Control with nothing to fall back to (there is no gameplay layer). Two options, left for the implementation ticket to pick: (a) `atlas_standalone.gd` intercepts the close and either quits the app or re-opens `implant/map` instead of demoting to a nonexistent gameplay layer, or (b) `atlas_app.gd` gains a `standalone_mode` flag that no-ops the close-to-gameplay branch. **(a)** is recommended — it does not touch `atlas_app.gd` at all, keeping the in-game and standalone Atlas byte-identical.
**Automatic extension + lockout flag (added mid-implementation per Jeroen, 2026-07-17):** the companion is a generic implant HOST, not an Atlas launcher — every implant app registered with `ImplantRegistry` is automatically available in the standalone shell, so future Atlas screens and future implant apps (the `implant/browser` app of §4, wiki/GTTR, economics dashboards, whatever comes) extend the external app with **zero companion-side work**. The lockout is the exception, not the rule: the implant app manifest (the `app.tres` resource each app already carries) gains `available_in_companion: bool = true` — an **opt-out** flag set `false` only for apps that structurally cannot work without a playing character/gameplay context. The standalone shell consults the flag at `instantiate_all`/app-open time (flagged-off apps are not instantiated and not offered in any app-switching surface); the **in-game implant ignores the flag entirely** — it exists only for the companion host. Apps needing finer granularity may gate individual screens on a standalone-mode query, but v1 needs only the app-level flag (both current apps — Atlas and Economics — are read-only-safe and stay `true`). Naming note: Jeroen's suggested name was `availableInAtlasApp`; recorded here as `available_in_companion` (snake_case per GDScript convention, and "companion" avoids colliding with `client/ui/implant/apps/atlas/` — the Atlas is itself one of the hosted apps, not the host).
**Window title/branding:** `atlas_standalone.tscn` sets its own window title via `DisplayServer.window_set_title()` in `_ready()` (e.g. "The Settled Reach — Atlas"), since `project.godot`'s shared `config/name` would otherwise make the standalone window read identically to the main game window in the taskbar/alt-tab — a second-monitor companion needs to be visually distinguishable at a glance. This is the only project-level Godot config touched; no `run/main_scene` override, no export preset changes in this ticket.
**Dev launch (un-exported):** `make atlas` runs `$(GODOT) --path client client/scenes/atlas_standalone.tscn` — Godot accepts an explicit scene path as a positional argument, overriding `run/main_scene` for that invocation only (the same mechanism `godot --path client -s res://tests/visual_capture.gd` already uses to run a non-default entry script). No `project.godot` edit needed; `run/main_scene` stays `main_menu.tscn` for the normal game. Since discovery (§1) is auto-attach-else-spawn at runtime, `make atlas` itself stays a single simple target — it does not need `make game`'s explicit background-`cargo run` + `sleep` + launch + `make stop` choreography, because `atlas_standalone.gd` owns its own spawn decision and child-process lifecycle internally (§1/§2). `make atlas` is just: build client, launch it.
**Exportable later:** because this is a genuine second scene (not a runtime-detected mode), it is also a legitimate Godot **export preset** target down the line — `godot --export-release "Atlas" build/atlas/...` with `atlas_standalone.tscn` as that preset's main scene. Nothing in this design blocks that; it is out of scope for this ticket (no export preset is added now) but the architecture does not need to change to support it later. This directly serves purpose (2) in the epic: "remains available as a LEGITIMATE player-facing pattern post-release." One caveat inherited from §2/§6, flagged here because it bears on export/distribution specifically: the default bind (`127.0.0.1:9876`) is loopback-only, and loopback is the entire security boundary the read-only guarantee currently leans on. A same-machine export is safe as designed. A LAN companion (a genuinely different second monitor — a different physical machine on the same network) is a different, larger feature: it requires the non-default-bind + real-auth work §6 already flags as a prerequisite for `TradingReader`, and arguably for `Reader` too once "same machine" stops holding. Not built now; recorded so nobody exports this to a non-loopback bind by default.
**(4) DATA BROWSER — "scan ALL database data."**
**Browse surface.** A new implant app, `implant/browser` (or folded into the Atlas as a new top-level screen reachable from "reach" — the implementation ticket picks the exact navigation entry point; recorded here as its own app since the entity set is broader than geography and doesn't naturally nest under the Atlas's reach→system→planet→regional drill-down), composed entirely from the existing D-169 component library (`ImplantPanel`/`ImplantHeader`/`ImplantDataRow`/`ImplantTextBlock`/`ImplantSeparator`) — no new UI primitives needed, this is exactly the list+detail pattern the library was built for. Two screen shapes, reused per entity kind:
- **Index screen** — a scrollable `ImplantDataRow` list (name + one or two summary columns), filterable/searchable by name, one per entity kind.
- **Detail screen** — an `ImplantPanel` of `ImplantDataRow`s (and `ImplantTextBlock` for free text / descriptions) showing every column the wire response carries for that one entity, `nav.push()`-reachable from the index row.
**v1 entity scope (deliberately narrow, honest about phase).** `systems-schema.sql`'s table set spans registry data (systems, bodies, stations, corporations, commodities, trait templates) and cascade-derived atlas geometry (`atlas_cities`, `atlas_roads`, `atlas_rivers`, `atlas_province_boundaries`…) that is Phase-4-in-progress and per-body-optional (populated only once a body's generation cascade has run — the same `AtlasLayerStatus::Ready`-vs-`Pending` gating the Atlas's regional screen already handles). v1 ships **registry-tier screens only** — tables that exist, are fully populated, and are stable regardless of cascade progress:
1. **Star systems** (`star_systems` + `system_economy`/`system_factions`/`system_culture` folded into one detail screen — small tables, natural 1:1 join)
2. **Bodies** (`bodies`, filterable by system — the existing `SystemScreen`'s body list is the UI precedent)
3. **Stations** (`stations`)
4. **Corporations** (`corporations` + `corp_presence`/`corp_financial_state` folded in)
5. **Commodities** (`commodities` + `production_chains`/`chain_inputs`)
6. **Trait catalog** (`trait_templates`) — Jeroen's brief names this explicitly
Deliberately **excluded from v1**, left for a follow-up ticket once Phase 4 cascade tables stabilize: `atlas_cities`/`atlas_roads`/`atlas_railroads`/`atlas_pois`/`atlas_rivers`/`atlas_oceans`/`atlas_mountain_ranges`/`atlas_province_boundaries` (cascade-derived, per-body, partially populated mid-Phase-4 — a browser screen over a table that's empty for most bodies today is not a useful v1 screen) and `corp_lifecycle_events`/`system_history`/`historical_events` (event-log tables, better served by a future timeline/log UI shape than list+detail). The six-entity v1 list above is the full set of "always fully populated, one row = one interesting thing" registry tables; everything else waits.
**Data path — wire-only, extending the existing proxy pattern (no local SQLite read).** Two options exist in principle: (a) the client opens `server/data/systems.db` directly (it already ships in the client build — instant, complete, works even with no server running), or (b) every browser screen is a wire request/response pair through the bridge, exactly like `StarMapRequest`/`CityNamesRequest` today. **This record picks (b), unambiguously, for two independent reasons:**
- **Pragmatic: Godot has no built-in SQLite.** `client/addons/` holds exactly two addons today (`gdUnit4`, `messagepack`) — no SQLite driver exists anywhere in the client. Reading `systems.db` locally would mean adding a third-party GDExtension (e.g. `godot-sqlite`) as a new dependency. D-020 explicitly rejected GDExtension for the core client-server bridge specifically to avoid "gdext pre-1.0 API churn, Godot version ABI breakage, FFI thread safety" — introducing a GDExtension now, for a companion-app convenience, reopens exactly the risk category D-020 spent effort closing. This is not a hard architectural violation (D-020 scoped its GDExtension rejection to the simulation bridge, not "any GDExtension ever") but it is the wrong trade for a feature whose entire value proposition is "lightweight."
- **Architectural: the codebase already made this call, recently, on purpose.** T-949 migrated the star map — 100% static, authored, non-per-body data — off a direct client-side `FileAccess` read of `star_map_data.json` and onto a wire request, specifically because "the client never reads game data files directly" (D-010 boundary framing). That decision already resolved the "but this data is static, why not read it locally" question a companion-app data browser would otherwise re-litigate — T-949 answered it for star-map data, and there is no principled reason `star_systems`/`bodies`/`corporations` are different in kind. One data-access rule for the whole client (server-authoritative reads, always through the bridge) is simpler to reason about and extend than "static tables read locally, dynamic tables read over the wire, judgment call per table" — especially since today's "fully static" table can grow a cascade-dynamic column later (`corp_financial_state` already looks time-varying).
So: **static registry data is NOT read locally — it goes through the SAME wire path as everything else**, because the server is already the sole owner of `systems.db` access and that ownership is a feature (single source of truth, single enforcement point for D-010 boundaries), not a latency cost worth working around. The "instant, complete, offline-capable" properties Jeroen's brief names as motivations are achieved a different way: attach-mode's "instant" comes from a fast local TCP round-trip (sub-millisecond on loopback — the ~1-5ms serialization cost D-020 already accepted is not the bottleneck for a data browser that isn't rendering 60fps), and "complete" comes from reading the same open handle the running server already has, with no second file-format copy to keep in sync.
**Server-side extension (the actual new work).** One new proxy, following `atlas_data_proxy.rs`'s established shape: **per-entity-kind request types** (mirroring `StarMapRequest`'s "thin, one dataset" shape), not a generic SQL-ish query surface — a generic query API is a much bigger security/complexity surface for a v1 feature that only needs six fixed table shapes, and is explicitly rejected for that reason. Each handler is a `rusqlite` read against `systems.db` using the exact `CityContextReader::open()`-style pattern already proven server-side — the server already has this dependency and this pattern; this ticket is "write five more read functions," not "introduce a new capability."
**D-010 boundary note — the "no character" framing, reinforced by §2.** A reader connection has no character (§2: it receives no `ObserverSnapshot` at all), so there is no per-character knowledge/fog to bound against — this is a SIMPLER boundary case than the normal player observation, not a harder one. What the Reader class is allowed to see is bounded by **connection class**, not character knowledge state, and §2 already proved the six v1 entities pass that bar independently (their handlers take no observer/character parameter — they were install-static/world-public before this record, not a carve-out invented for readers). The one thing explicitly ruled OUT of v1 scope: browsing a **specific save's diverged dynamic state** (an economy snapshot that has drifted from the shared baseline via play, one corp's post-game-start financial trajectory) is information a Reader attached to someone else's playthrough should not casually have. v1's six entities are registry-tier (identical across all saves, cascade-independent), so this doesn't bite yet — it becomes live the moment a market-state screen is added (§6) or a cascade-tier table (the excluded list above) is browsed against an attach-mode connection to someone else's running game. Flagged here so whichever follow-up ticket adds those screens re-reads this paragraph first.
**(5) SAVE/LOAD SEAM — recorded hook, not built.**
Saves are Phase 5+ (per the cascade); `meta.schema_version` (T-888) already carries the lineage-migration seam on the DB side, but no save file format or save/load UI exists yet anywhere in the client. This record fixes WHERE the Atlas's save/load interaction slots in, once it exists, without building any of it:
- **Attach-mode** has no save/load UI at all — it inherits whatever world the attached game session is running, save/load included; the Atlas is a read-only window onto a live session, and "loading a different save" from inside an attached reader is a contradiction (that's just attaching elsewhere, not loading). No hook needed here.
- **Spawn-mode v1** (this ticket's actual scope) offers **seed selection only** at launch — the standalone app's own minimal startup screen (part of `atlas_standalone.tscn`, shown before the `HudGroups.open_app("implant/map")` call in the boot sequence above) asks for a world seed the same way `character_creation.tscn`/`GameState.world_seed` does today for a normal new game, then spawns a Reader-role server against that seed via §1's `StartupMessage.world_seed` (not a CLI flag — §1 already fixed this as the single source of truth for spawn-mode seeding).
- **Spawn-mode's future save picker** slots into that SAME pre-Atlas startup screen, as a second choice alongside "new seed": once a save file format exists, the startup screen gains a "load existing save" option that spawns the server and immediately issues whatever the (then-existing) `LoadGame` flow is — the exact wire action `main.gd`'s `_dispatch_pending_load()` already sends today (`InputMapper.Action.LOAD_GAME``SimBridge.send_input()`), reused verbatim. The Reader-role server applies the load exactly as a normal server does, then simply never accepts player inputs afterward (§2's enforcement doesn't care how the world was populated — it gates on connection role, not on world provenance). **No new save/load mechanism is invented for the Atlas** — it is a consumer of whatever Phase 5+ builds, hooked in at exactly one point (the pre-launch startup screen), recorded now so future work knows the seam exists and where.
**(6) FUTURE TRADING — what changes when the app gains write verbs.**
Designing the seam now, not implementing it.
**Per-verb allowlist via a widened role, not a new connection type.** The `ConnectionRole` enum from §2 extends to `Player | Reader | TradingReader`. `TradingReader` is strictly **additive** to `Reader` — everything a `Reader` gets, plus a narrow, explicitly-enumerated `PlayerAction` allowlist for trade verbs — never a replacement. This keeps `Player ⊇ TradingReader ⊇ Reader` a strict superset relationship, so widening later only adds match arms at the same enforcement point (§2's role-gated input handling) and never touches the `Reader` path at all — the base read-only guarantee this whole record establishes is structurally unaffected by trading being added later.
**Idempotency/ordering.** Trade commands travel through the existing `tick`-stamped `PlayerInput{tick, action}` envelope (not a bespoke unstamped request), so ordering against the Player's own concurrent actions falls out of the existing `InputQueue` ordering for free — no new sequencing mechanism needed. Unlike movement (visibly-wrong-but-harmless if accidentally duplicated), a duplicated trade command is a real bug class (a double-sell). Recommend a client-generated idempotency token + a short server-side dedup window — cheap and bounded for a localhost, single-user, low-frequency command class. Rejected alternative: relying on TCP's delivery guarantee alone — that only catches transport-level duplication, not the actual threat (a user double-clicking through a UI hiccup and generating two distinct, both-valid application-level messages).
**Identity/auth — the assumption that must stay visible.** Same machine, same user, no auth — loopback-only IS the security boundary (the server already effectively enforces this via the `127.0.0.1:9876` default bind). This reasoning breaks the instant `SR_ADDR` or any non-default bind lets a `TradingReader` connect from a different machine — which is exactly D-009's actual multiplayer future, or even this record's own §3 export-later note about a genuinely-remote second-monitor companion. **The moment loopback-only stops holding, real auth (at minimum a session-minted token) is required before `TradingReader` widens beyond it.** This assumption is recorded here explicitly so it is visible to whoever eventually picks up a LAN-companion or remote-trading idea, rather than being silently inherited as "it already works, why would auth be needed."
- **Rationale:** Three independent product goals (Jeroen's brief) converge on one architecture cleanly: a dev data-inspection surface (purpose 1), a legitimate post-release second-monitor pattern (purpose 2), and an attach-or-spawn reader with a save seam (purpose 3) all want the SAME thing underneath — an implant UI that can run without a player. Building that once (dedicated entry scene + `ConnectionRole`-gated reader connection + wire-only data access) serves all three simultaneously; there is no version of this where the dev tool and the shipped companion app are different pieces of software. The wire-only data path is the one design choice that could have gone either way and didn't — it is deliberately consistent with T-949's precedent rather than reopening it, and it avoids a new GDExtension dependency for a "lightweight" feature. The app-shell choice (dedicated scene over a `main.tscn` flag) keeps blast radius smallest: the standalone Atlas cannot regress player-only code paths because it never touches them. The `Reader`/`TradingReader` superset relationship (§2/§6) means the read-only guarantee this record exists to make is never at risk from the later trading feature — it can only be extended, never weakened, by construction.
- **Implementation:** New ticket tree under [T-1128](../../.pql) (epic) — proposed tree delivered in the T-1129 design-pass report, not filed here (tree ownership: team lead). Client: `client/scenes/atlas_standalone.tscn` + `atlas_standalone.gd`, a new `implant/browser` app (or Atlas-nested screen) under `client/ui/implant/apps/`, `Makefile` `atlas` target. Server: `ConnectionRole` on `StartupMessage`, the accept-loop + `BridgeResource` multi-connection change, per-connection response tagging, and the Player-only `ServerRunning`/snapshot-targeting fixes (§2) in `server/src/bridge/`; a new `BrowseRequest`/`BrowseResponse` proxy in `server/src/atlas/` (sibling to `atlas_data_proxy.rs`, §4). No `systems-schema.sql` changes required — v1's six entity screens read existing tables as-is.
- **Cross-reference:** [D-010](#d-010) (client-server boundary — the wire-only data-path rationale; "no character" reader framing; the adversarial-client enforcement stance), [D-009](#d-009) (multiplayer design-for-it baseline — this record's 0-1 Player + 0-N Reader model is explicitly NOT that larger ambition), [D-020](#d-020) (subprocess/IPC over GDExtension — why local SQLite is rejected; `SimBridge`/bridge trait extension point), [D-169](#d-169) (implant component library — the data browser is composed entirely from existing components), [D-170](#d-170) (HudGroups — the standalone scene's degenerate single-group case), [D-192](#d-192) (no lockstep negotiation precedent — why `ConnectionRole` is a `StartupMessage` field, not a new pre-handshake message). T-949 (star map wire-migration precedent this record extends rather than re-litigates), T-888 (schema_version save lineage — the seam §5 hooks into once it exists). Tickets: [T-1128](../../.pql) (epic), [T-1129](../../.pql) (this design pass).
- **Raised by:** Jeroen (2026-07-17, brief: standalone Atlas via `make atlas`, read-only reader against the running game or its own spawned server, save/load interaction seam, future trading). Designed by Tyre (architecture lead, §3–§5, integration, record author) + Oscar (§1, §2, §6 — connection model, reader protocol, trading seam).
- **Dissent:** None recorded at design time. Two judgment calls flagged for confirmation rather than dissent, both revisable by the implementation ticket without touching the rest of this record: (a) §4's choice to make the data browser a **separate `implant/browser` app** rather than a new top-level screen nested inside the existing Atlas — the six v1 entity kinds don't share the Atlas's geographic drill-down shape, so a separate app was chosen for navigational clarity (the Atlas stays "the map," the browser is "the database") but this is a naming/IA call, not architecture; (b) §3's `atlas_app.gd`-unmodified close-handling option (a) vs. a `standalone_mode` flag option (b) — recommended but not forced.
---
*108 decisions (D-001 through D-254, excluding gaps). Last updated: 2026-07-17 (D-254 — standalone Atlas companion app: dedicated entry scene, ConnectionRole-gated reader connection (0-1 Player + 0-N Reader), wire-only data browser extending the T-949 proxy pattern, save/load and trading seams recorded not built).*
+47 -26
View File
@@ -94,7 +94,11 @@ fn serve_atlas_requests(
let reader = city_reader.as_ref().map(|r| &r.0);
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
// D-254 §2: 1:1, in-order request->response — the connection id rides
// alongside the request untouched by handle_atlas_request (which has no
// notion of connections) and is re-attached to the response so the
// bridge's send_atlas_responses routes it back to only that connection.
for (conn_id, req) in pending {
let resp = match resolver.as_ref() {
Some(r) => handle_atlas_request(
&req,
@@ -116,7 +120,7 @@ fn serve_atlas_requests(
region_grid: None,
},
};
responses.0.push(resp);
responses.0.push((conn_id, resp));
}
}
@@ -133,7 +137,7 @@ fn serve_star_map_requests(
return;
}
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
for (conn_id, req) in pending {
let resp = match path.as_ref() {
Some(p) => handle_star_map_request(&req, &p.0),
None => crate::atlas::atlas_data_proxy::StarMapResponse {
@@ -143,7 +147,7 @@ fn serve_star_map_requests(
data: None,
},
};
responses.0.push(resp);
responses.0.push((conn_id, resp));
}
}
@@ -159,8 +163,10 @@ fn serve_city_names_requests(
}
let reader = city_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for req in pending {
responses.0.push(handle_city_names_request(&req, reader));
for (conn_id, req) in pending {
responses
.0
.push((conn_id, handle_city_names_request(&req, reader)));
}
}
@@ -760,6 +766,7 @@ mod tests {
use super::*;
use crate::atlas::gen_queue::{GenPriority, GenWorkItem};
use crate::atlas::road_graph::{RoadEdge, RoadNode, RoadNodeKind};
use crate::bridge::ConnectionId;
use crate::seed::SeedChain;
use crate::simulation::generator::{
ArrangementPattern, AttractorType, FoundingOrientation, MaintenanceAuthority,
@@ -834,10 +841,13 @@ mod tests {
use crate::atlas::layer_proxy::AtlasLayerRequest;
let mut world = World::new();
world.insert_resource(AtlasRequestBuffer(vec![AtlasLayerRequest {
body_id: "GJ1c".to_string(),
up_to: CascadeLayer::Topography,
}]));
world.insert_resource(AtlasRequestBuffer(vec![(
ConnectionId(0),
AtlasLayerRequest {
body_id: "GJ1c".to_string(),
up_to: CascadeLayer::Topography,
},
)]));
world.insert_resource(AtlasResponseBuffer::default());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.insert_resource(GenerationQueue::with_threads(1));
@@ -849,9 +859,13 @@ mod tests {
let responses = world.resource::<AtlasResponseBuffer>();
assert_eq!(responses.0.len(), 1, "request should produce one response");
assert_eq!(responses.0[0].body_id, "GJ1c");
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
// No resolver wired → Error status (exercises the drain + push path).
assert!(matches!(responses.0[0].status, AtlasLayerStatus::Error(_)));
assert!(matches!(
responses.0[0].1.status,
AtlasLayerStatus::Error(_)
));
// The request buffer was drained.
assert!(world.resource::<AtlasRequestBuffer>().0.is_empty());
}
@@ -869,9 +883,10 @@ mod tests {
std::fs::write(&path, r#"{"_meta": {}, "nodes": [], "edges": []}"#).unwrap();
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapRequestBuffer(vec![(
ConnectionId(0),
StarMapRequest { star_map: true },
)]));
world.insert_resource(StarMapResponseBuffer::default());
world.insert_resource(StarMapDataPath(path.clone()));
@@ -881,7 +896,8 @@ mod tests {
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].status, StarMapStatus::Ready);
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.status, StarMapStatus::Ready);
assert!(world.resource::<StarMapRequestBuffer>().0.is_empty());
let _ = std::fs::remove_file(&path);
@@ -894,9 +910,10 @@ mod tests {
use crate::atlas::atlas_data_proxy::{StarMapRequest, StarMapStatus};
let mut world = World::new();
world.insert_resource(StarMapRequestBuffer(vec![StarMapRequest {
star_map: true,
}]));
world.insert_resource(StarMapRequestBuffer(vec![(
ConnectionId(0),
StarMapRequest { star_map: true },
)]));
world.insert_resource(StarMapResponseBuffer::default());
// No StarMapDataPath resource.
@@ -906,7 +923,7 @@ mod tests {
let responses = world.resource::<StarMapResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert!(matches!(responses.0[0].status, StarMapStatus::Error(_)));
assert!(matches!(responses.0[0].1.status, StarMapStatus::Error(_)));
}
/// T-949b: without `CityContextReaderResource` wired, the serve system
@@ -917,10 +934,13 @@ mod tests {
use crate::atlas::atlas_data_proxy::{CityNamesRequest, CityNamesStatus};
let mut world = World::new();
world.insert_resource(CityNamesRequestBuffer(vec![CityNamesRequest {
city_names: true,
body_id: "GJ1c".to_string(),
}]));
world.insert_resource(CityNamesRequestBuffer(vec![(
ConnectionId(0),
CityNamesRequest {
city_names: true,
body_id: "GJ1c".to_string(),
},
)]));
world.insert_resource(CityNamesResponseBuffer::default());
// No CityContextReaderResource.
@@ -930,8 +950,9 @@ mod tests {
let responses = world.resource::<CityNamesResponseBuffer>();
assert_eq!(responses.0.len(), 1);
assert_eq!(responses.0[0].body_id, "GJ1c");
assert!(matches!(responses.0[0].status, CityNamesStatus::Error(_)));
assert_eq!(responses.0[0].0, ConnectionId(0), "connection id preserved");
assert_eq!(responses.0[0].1.body_id, "GJ1c");
assert!(matches!(responses.0[0].1.status, CityNamesStatus::Error(_)));
assert!(world.resource::<CityNamesRequestBuffer>().0.is_empty());
}
+660 -128
View File
@@ -10,6 +10,7 @@ use crate::atlas::atlas_data_proxy::{
CityNamesRequest, CityNamesResponse, StarMapRequest, StarMapResponse,
};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
use crate::bridge::tcp::TcpBridge;
pub mod debug;
pub mod framing;
@@ -190,45 +191,247 @@ pub trait SimBridge: Send + Sync {
fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError>;
}
/// BridgeResource: Bevy Resource wrapper for SimBridge trait object
#[derive(Resource)]
/// 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 {
inner: Box<dyn SimBridge>,
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 {
Self {
inner: Box::new(bridge),
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())),
}
}
pub fn send_handshake(&self) -> Result<(), BridgeError> {
self.inner.send_handshake()
}
/// 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> {
self.inner.receive_startup()
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> {
self.inner.send_snapshot(snapshot)
match &self.player {
Some(c) => c.bridge.send_snapshot(snapshot),
None => Err(BridgeError::Disconnected),
}
}
pub fn receive(&self) -> Result<Option<Inbound>, BridgeError> {
self.inner.receive()
/// 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)
}
pub fn send_atlas_response(&self, resp: &AtlasLayerResponse) -> Result<(), BridgeError> {
self.inner.send_atlas_response(resp)
/// 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(())
}
}
}
pub fn send_star_map_response(&self, resp: &StarMapResponse) -> Result<(), BridgeError> {
self.inner.send_star_map_response(resp)
/// 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(())
}
}
}
pub fn send_city_names_response(&self, resp: &CityNamesResponse) -> Result<(), BridgeError> {
self.inner.send_city_names_response(resp)
/// 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(())
}
}
}
}
@@ -250,14 +453,30 @@ pub enum HandshakeState {
/// normal traffic is one input batch plus the occasional atlas request.
const MAX_INBOUND_FRAMES_PER_TICK: usize = 64;
/// Receive inputs from bridge and push to InputQueue.
/// Drains every complete frame buffered this tick (T-1045) — a single
/// receive() per tick would backlog mixed input/atlas traffic at one frame
/// per 50 ms. Relies on receive() being non-blocking (Ok(None) = no frame).
/// 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<Res<BridgeResource>>,
bridge: Option<ResMut<BridgeResource>>,
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
mut running: ResMut<ServerRunning>,
handshake: Res<HandshakeState>,
@@ -267,115 +486,266 @@ pub fn receive_bridge_inputs(
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(bridge) = bridge else { return };
let Some(mut bridge) = bridge else { return };
let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
for _ in 0..MAX_INBOUND_FRAMES_PER_TICK {
match bridge.receive() {
Ok(Some(Inbound::Inputs(inputs))) => {
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
// -- 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));
}
// 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!(
"Received {} input(s) before handshake completedprocessing anyway (forward-compatible)",
inputs.len()
"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;
}
}
for input in &inputs {
tracing::trace!(
"Received input: tick={} action={:?}",
input.tick,
input.action
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(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;
}
for input in inputs {
input_queue.push(input);
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;
}
}
Ok(Some(Inbound::AtlasRequest(req))) => {
atlas_requests.0.push(req);
}
Ok(Some(Inbound::StarMapRequest(req))) => {
star_map_requests.0.push(req);
}
Ok(Some(Inbound::CityNamesRequest(req))) => {
city_names_requests.0.push(req);
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
tracing::info!("Client disconnected, shutting down");
running.0 = false;
break;
}
Err(BridgeError::Io(ref e))
if e.kind() == std::io::ErrorKind::BrokenPipe
|| e.kind() == std::io::ErrorKind::ConnectionReset =>
{
tracing::info!("Pipe broken, shutting down cleanly");
running.0 = false;
break;
}
Err(BridgeError::MutexPoisoned(ref msg)) => {
tracing::error!("Bridge mutex poisoned: {}. Shutting down.", msg);
running.0 = false;
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;
}
}
}
for id in disconnected_readers
.into_iter()
.chain(to_disconnect_for_violations)
{
bridge.remove_reader(id);
}
}
/// Send snapshot from buffer to bridge.
/// Any send error is fatal — the client cannot proceed without snapshots.
/// 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<Res<BridgeResource>>,
bridge: Option<ResMut<BridgeResource>>,
mut buffer: ResMut<SnapshotBuffer>,
mut running: ResMut<ServerRunning>,
) {
let Some(bridge) = bridge else {
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!("Client disconnected during send, shutting down");
tracing::info!("Player disconnected during send, shutting down");
}
BridgeError::MutexPoisoned(msg) => {
tracing::error!("Bridge mutex poisoned during send: {}", msg);
tracing::error!("Player bridge mutex poisoned during send: {}", msg);
}
_ => {
tracing::error!("Bridge send error: {}", e);
tracing::error!("Player bridge send error: {}", e);
}
}
running.0 = false;
bridge.player = None;
}
}
}
@@ -391,81 +761,231 @@ impl Default for ServerRunning {
}
/// Inbound atlas layer requests routed off the bridge (#969, D-225), drained by
/// the proxy serve system in `PreInput`.
/// 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<AtlasLayerRequest>);
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).
/// to the client in `PostSnapshot` (#969, D-225). Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct AtlasResponseBuffer(pub Vec<AtlasLayerResponse>);
pub struct AtlasResponseBuffer(pub Vec<(ConnectionId, AtlasLayerResponse)>);
/// Flush buffered atlas responses to the client (#969, D-225). A failed send is
/// logged but not fatal — an atlas response is not load-bearing like a snapshot.
/// 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 resp in buffer.0.drain(..) {
if let Err(e) = bridge.send_atlas_response(&resp) {
tracing::warn!("failed to send atlas response for {}: {}", resp.body_id, e);
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`.
/// proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct StarMapRequestBuffer(pub Vec<StarMapRequest>);
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).
/// to the client in `PostSnapshot` (T-949a). Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct StarMapResponseBuffer(pub Vec<StarMapResponse>);
pub struct StarMapResponseBuffer(pub Vec<(ConnectionId, StarMapResponse)>);
/// Flush buffered star-map responses to the client (T-949a). A failed send is
/// logged but not fatal.
/// 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 resp in buffer.0.drain(..) {
if let Err(e) = bridge.send_star_map_response(&resp) {
tracing::warn!("failed to send star map response: {}", e);
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`.
/// proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct CityNamesRequestBuffer(pub Vec<CityNamesRequest>);
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).
/// flushed to the client in `PostSnapshot` (T-949b). Connection-tagged
/// (D-254 §2).
#[derive(Resource, Default)]
pub struct CityNamesResponseBuffer(pub Vec<CityNamesResponse>);
pub struct CityNamesResponseBuffer(pub Vec<(ConnectionId, CityNamesResponse)>);
/// Flush buffered city-names responses to the client (T-949b). A failed send
/// 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 resp in buffer.0.drain(..) {
if let Err(e) = bridge.send_city_names_response(&resp) {
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 {}: {}",
"failed to send city names 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;
@@ -488,6 +1008,18 @@ impl Plugin for BridgePlugin {
.init_resource::<StarMapResponseBuffer>()
.init_resource::<CityNamesRequestBuffer>()
.init_resource::<CityNamesResponseBuffer>()
.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))
+165
View File
@@ -143,6 +143,35 @@ impl TcpBridge {
})
}
/// Server-side: promote an already-connected, already-handshaken stream
/// into a full `TcpBridge` (D-254 §2, T-1130).
///
/// Used by [`PendingConnection`] once its non-blocking handshake/startup
/// exchange completes — unlike `accept`/`accept_on`, this does not call
/// `listener.accept()` itself; the stream is already connected and (per
/// `PendingConnection`'s contract) already sent `HandshakeMessage` and
/// received a valid `StartupMessage`. Sets the stream non-blocking for
/// the steady-state tick loop, same as every other constructor here.
pub fn from_connected_stream(stream: TcpStream) -> Result<Self, BridgeError> {
let local_addr = stream
.local_addr()
.map_err(|e| BridgeError::Transport(format!("failed to get local address: {}", e)))?;
stream
.set_nonblocking(true)
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
let reader_stream = stream.try_clone().map_err(|e| {
BridgeError::Transport(format!("failed to clone stream for reader: {}", e))
})?;
Ok(Self {
reader: Mutex::new(ReadHalf::new(reader_stream)),
writer: Mutex::new(BufWriter::new(stream)),
local_addr,
})
}
/// Get the local address (useful for OS-assigned port discovery in tests).
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
@@ -285,3 +314,139 @@ impl SimBridge for TcpBridge {
Ok(())
}
}
/// A connection that has been TCP-accepted but has not yet completed the
/// handshake/startup exchange (D-254 §2, T-1130).
///
/// Exists because the multi-connection accept-loop in `main.rs` runs inside
/// the live tick loop — unlike the original single-connection startup
/// sequence (`TcpBridge::accept` + `receive_startup`, which blocks freely
/// because nothing else is running yet), a connection accepted *after* the
/// server is already ticking must never stall other connections while it
/// completes its own handshake. `PendingConnection` is polled once per tick,
/// non-blockingly, exactly like `TcpBridge::receive()` already is — a slow
/// or hostile client sits here indefinitely, consuming no thread and
/// blocking nothing, until it finishes or disconnects.
///
/// State machine: `AwaitingHandshakeSend` (send the empty `HandshakeMessage`
/// — reused from `send_handshake`'s wire shape) → `AwaitingStartup` (poll for
/// a complete `StartupMessage` frame via the same `FrameAccumulator` the
/// steady-state `receive()` path uses, so a startup message split across TCP
/// segments reassembles correctly here too).
pub struct PendingConnection {
stream: TcpStream,
accum: FrameAccumulator,
state: PendingState,
}
enum PendingState {
AwaitingHandshakeSend,
AwaitingStartup,
}
/// Result of one `PendingConnection::poll()` call.
pub enum PendingPoll {
/// Handshake not yet complete — keep polling next tick.
Waiting,
/// Startup message received and decoded. Caller promotes this into a
/// full `TcpBridge` via `TcpBridge::from_connected_stream`.
Ready {
stream: TcpStream,
startup: super::StartupMessage,
},
/// The connection died before completing its handshake (EOF or a fatal
/// I/O error). Caller drops this pending connection.
Failed,
}
impl PendingConnection {
/// Wrap a freshly-`accept()`-ed stream. Sets non-blocking immediately —
/// this type never blocks the tick loop, by construction.
pub fn new(stream: TcpStream) -> Result<Self, BridgeError> {
stream
.set_nonblocking(true)
.map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?;
Ok(Self {
stream,
accum: FrameAccumulator::new(),
state: PendingState::AwaitingHandshakeSend,
})
}
/// Advance the handshake by one tick's worth of non-blocking I/O.
///
/// Never blocks: a `WouldBlock` on either the handshake write or the
/// startup read simply returns `PendingPoll::Waiting` for another tick
/// to retry. `HandshakeMessage` is a fixed few bytes (an empty
/// MessagePack map plus the 4-byte length prefix) — in practice it
/// completes in a single non-blocking write, but the retry path exists
/// for the same reason `send_handshake`'s blocking toggle exists on the
/// first connection: TCP send buffers are not guaranteed instantaneous.
pub fn poll(&mut self) -> PendingPoll {
use super::types::HandshakeMessage;
use std::io::Write;
if matches!(self.state, PendingState::AwaitingHandshakeSend) {
let msg = HandshakeMessage {};
let payload = match rmp_serde::to_vec_named(&msg) {
Ok(p) => p,
Err(e) => {
tracing::error!("pending connection: failed to encode handshake: {}", e);
return PendingPoll::Failed;
}
};
match write_framed(&mut self.stream, &payload) {
Ok(()) => {
self.state = PendingState::AwaitingStartup;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return PendingPoll::Waiting;
}
Err(e) => {
tracing::warn!("pending connection: handshake send failed: {}", e);
return PendingPoll::Failed;
}
}
// write_framed calls flush() internally — no separate flush needed.
let _ = self.stream.flush();
}
match self.accum.poll_frame(&mut self.stream) {
Ok(Some(payload)) => match rmp_serde::from_slice::<super::StartupMessage>(&payload) {
Ok(startup) => {
tracing::info!(
"pending connection: startup received, role={:?}",
startup.role
);
// try_clone so the caller gets an owned stream; self.stream
// is dropped with this PendingConnection once the caller
// promotes the clone into a TcpBridge.
match self.stream.try_clone() {
Ok(stream) => PendingPoll::Ready { stream, startup },
Err(e) => {
tracing::error!(
"pending connection: failed to clone stream for promotion: {}",
e
);
PendingPoll::Failed
}
}
}
Err(e) => {
tracing::warn!("pending connection: malformed startup message: {}", e);
PendingPoll::Failed
}
},
Ok(None) => {
// Clean EOF — the peer disconnected before sending startup.
PendingPoll::Failed
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => PendingPoll::Waiting,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => PendingPoll::Failed,
Err(e) => {
tracing::warn!("pending connection: startup read failed: {}", e);
PendingPoll::Failed
}
}
}
}
+88 -1
View File
@@ -20,6 +20,29 @@ pub use crate::simulation::time::{DayPhase, TickRate};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HandshakeMessage {}
/// Connection role, gating what a connection may send/receive (D-254 §2).
///
/// `Player` is the sole role that may enter the character-spawn path, send
/// `PlayerInput`, or receive `ObserverSnapshot`. `Reader` is a genuinely new
/// class: no character, no inputs, no per-tick snapshot — structurally
/// enforced server-side (`main.rs`, `bridge::mod`), never client courtesy.
///
/// Shaped for growth per D-254 §6: a future `TradingReader` variant is a
/// strict superset of `Reader` (everything `Reader` gets, plus a narrow
/// per-verb `PlayerInput` allowlist) — not a replacement, and not added by
/// this ticket (T-1130 scope is `Player`/`Reader` only).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum ConnectionRole {
/// The single character-controlling connection. Only role that existed
/// before D-254. Default for back-compat (see `StartupMessage::role`).
#[default]
Player,
/// Read-only observer: no character, no inputs, no `ObserverSnapshot`.
/// May request install-static/world-public data (atlas/star-map/
/// city-names) — see the permitted-message matrix in `bridge::mod`.
Reader,
}
/// Startup message sent by the client after receiving HandshakeMessage (#175).
/// Contains the world seed for deterministic simulation (D-010, D-029).
///
@@ -35,7 +58,22 @@ pub struct StartupMessage {
/// World seed for SimRng initialization.
/// Generated by SessionManager.new_game() on the client.
/// Same seed → same EntanglementConfig → same NPC population (D-029).
///
/// Ignored server-side for `Reader` connections (D-254 §2): a reader
/// inherits whatever `SimRng` state the server already has (from the
/// Player's own `StartupMessage`, or `--seed` in spawn-mode) — a second
/// StartupMessage must never re-seed `SimRng` after tick 0, or a
/// reader attaching mid-session would silently break determinism for
/// the Player already connected.
pub world_seed: u64,
/// Connection role (D-254 §2). `#[serde(default)]` makes this field
/// optional on the wire: an old-format client that only ever sent
/// `{world_seed}` (every client before D-254) still decodes cleanly,
/// with `role` defaulting to `ConnectionRole::Player` — the same
/// connection behavior that client always got. Byte-compatible,
/// forward-compatible; no protocol version bump (D-192 precedent).
#[serde(default)]
pub role: ConnectionRole,
}
/// The ONLY data structure crossing the client-server boundary (D-020)
@@ -1101,6 +1139,7 @@ mod tests {
fn startup_message_roundtrip() {
let msg = StartupMessage {
world_seed: 0xDEADBEEF,
role: ConnectionRole::Player,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -1110,7 +1149,10 @@ mod tests {
#[test]
fn startup_message_zero_seed() {
let msg = StartupMessage { world_seed: 0 };
let msg = StartupMessage {
world_seed: 0,
role: ConnectionRole::Player,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, 0);
@@ -1120,12 +1162,57 @@ mod tests {
fn startup_message_max_seed() {
let msg = StartupMessage {
world_seed: u64::MAX,
role: ConnectionRole::Player,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, u64::MAX);
}
/// D-254 §2 / T-1130 behavior 1: an old-format `StartupMessage` — the
/// exact wire shape every client before this ticket sent, `{world_seed}`
/// only, no `role` key at all — must still decode cleanly, with `role`
/// defaulting to `Player`. This is the byte-compatibility guarantee: no
/// already-shipped client encoder needs to change for this ticket to be
/// safe to deploy alongside it.
#[test]
fn old_format_startup_message_decodes_as_player() {
// Hand-encode the pre-D-254 shape directly (a single-key map), rather
// than deriving it from a struct literal, so this test can't
// accidentally pass just because both sides changed together.
#[derive(Serialize)]
struct OldStartupMessage {
world_seed: u64,
}
let old_msg = OldStartupMessage {
world_seed: 0x1234_5678,
};
let bytes = rmp_serde::to_vec_named(&old_msg).expect("serialize old-format message");
let decoded: StartupMessage =
rmp_serde::from_slice(&bytes).expect("old-format message must still decode");
assert_eq!(decoded.world_seed, 0x1234_5678);
assert_eq!(
decoded.role,
ConnectionRole::Player,
"role must default to Player when absent from the wire"
);
}
/// D-254 §2: a new-format message explicitly carrying `role: Reader`
/// must decode with that role preserved — the default only applies when
/// the field is genuinely absent, it must not clobber an explicit value.
#[test]
fn startup_message_reader_role_roundtrips() {
let msg = StartupMessage {
world_seed: 99,
role: ConnectionRole::Reader,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.role, ConnectionRole::Reader);
}
#[test]
fn handshake_is_distinct_from_snapshot() {
// HandshakeMessage and ObserverSnapshot are different types on the wire.
+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);
+513 -1
View File
@@ -421,9 +421,521 @@ fn single_tick_drains_all_ready_inbound_frames() {
1,
"the city-names request must drain in the same tick (H6: real wire path)"
);
assert_eq!(city_names[0].body_id, "GJ1c");
assert_eq!(city_names[0].1.body_id, "GJ1c");
assert!(
world.resource::<ServerRunning>().0,
"draining must not shut the server down"
);
}
// -- D-254 §2 / T-1130: multi-connection bridge + ConnectionRole gate ---------
//
// Six behaviors below (a 7th — old-format StartupMessage decodes as Player —
// is a unit test in server/src/bridge/types.rs, next to the type itself).
// Each test drives the real wire path: a genuine TcpStream client performs
// the handshake/startup exchange a real Godot client (or PendingConnection's
// server-side counterpart) would, against the real non-blocking accept-loop
// and drain-loop systems via `run_system_once` — no mocks of the framing or
// role-gate logic itself.
/// Client-side test helper: perform one full handshake/startup exchange over
/// an already-connected stream, exactly as `sim_bridge.gd`'s live-mode path
/// does (read HandshakeMessage, write StartupMessage). Returns the stream so
/// the caller can continue driving it (send inputs, read responses, etc.).
fn client_handshake(mut stream: TcpStream, role: ConnectionRole) -> TcpStream {
let handshake_payload = read_framed(&mut stream)
.expect("failed to read handshake")
.expect("unexpected EOF reading handshake");
let _: HandshakeMessage =
rmp_serde::from_slice(&handshake_payload).expect("failed to decode HandshakeMessage");
let startup = StartupMessage {
world_seed: 12345,
role,
};
let payload = rmp_serde::to_vec_named(&startup).expect("failed to serialize StartupMessage");
write_framed(&mut stream, &payload).expect("failed to write StartupMessage");
stream
}
/// Drive `accept_new_connections` for up to `max_ticks` schedule passes, or
/// until `bridge.readers().len() + bridge.has_player() as usize` (checked via
/// the passed predicate) is satisfied — matches this test file's existing
/// wall-clock-deadline-over-fixed-sleep hardening (see `collect_input_batches`
/// above): connection promotion depends on TCP delivery timing, not a fixed
/// tick count, so poll until true or time out rather than guessing a sleep.
fn drive_accept_loop_until(
world: &mut bevy_ecs::world::World,
condition: impl Fn(&bevy_ecs::world::World) -> bool,
) {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::bridge::accept_new_connections;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !condition(world) {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for accept-loop condition"
);
world
.run_system_once(accept_new_connections)
.expect("accept_new_connections failed to run");
thread::sleep(std::time::Duration::from_millis(1));
}
}
/// Build a `bevy_ecs::World` wired exactly like `BridgePlugin` wires it for
/// the systems under test here (accept-loop + drain-loop + response
/// buffers), bound to a fresh OS-assigned port.
fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr) {
use settled_reach_server::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, CityNamesRequestBuffer,
CityNamesResponseBuffer, ConnectionListener, HandshakeState, PendingConnections,
ServerRunning, SnapshotBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
let addr = listener.local_addr().expect("failed to get local address");
listener
.set_nonblocking(true)
.expect("failed to set listener non-blocking");
let mut world = bevy_ecs::world::World::new();
world.insert_resource(ConnectionListener(Some(listener)));
world.init_resource::<PendingConnections>();
world.insert_resource(BridgeResource::default());
world.init_resource::<InputQueue>();
world.init_resource::<ServerRunning>();
world.insert_resource(HandshakeState::Complete);
world.init_resource::<SimErrorBuffer>();
world.init_resource::<AtlasRequestBuffer>();
world.init_resource::<AtlasResponseBuffer>();
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<StarMapResponseBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<CityNamesResponseBuffer>();
world.init_resource::<SnapshotBuffer>();
(world, addr)
}
/// T-1130 behavior 2: a Reader connection's handshake succeeds — it reaches
/// `BridgeResource`'s reader collection — without any character-spawn
/// concept ever entering the picture. There is no `PlayerCharacter`-query
/// assertion here because that's the correct proof of the D-254 §2
/// guarantee: character-spawn is a `main.rs`/world-setup concern entirely
/// disjoint from connection acceptance (see `main.rs`'s D-254 §2 scope-note
/// comment at the `setup_proof_room`/`setup_gauntlet` call site) — a Reader
/// reaching `BridgeResource.readers` never touches that code path at all,
/// which this test demonstrates by never invoking it and the connection
/// still working end-to-end.
#[test]
fn reader_handshake_succeeds_and_installs_as_reader() {
use settled_reach_server::bridge::BridgeResource;
let (mut world, addr) = new_multi_connection_world();
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let _stream = client_handle.join().expect("client thread panicked");
let bridge = world.resource::<BridgeResource>();
assert!(
!bridge.has_player(),
"a Reader-only connection must never be installed as Player"
);
assert_eq!(bridge.reader_count(), 1, "exactly one reader installed");
}
/// T-1130 behavior 3: an atlas/star-map/city-names response addressed to a
/// Reader's `ConnectionId` reaches that Reader over the wire — the
/// per-connection response-tagging plumbing (D-254 §2) actually delivers,
/// not just tags in-memory. Drives `send_star_map_responses` directly
/// (bypassing `serve_star_map_requests`/the atlas proxy, which are already
/// covered elsewhere) to isolate exactly the tagging/routing behavior this
/// ticket adds.
#[test]
fn reader_receives_tagged_star_map_response() {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::atlas::atlas_data_proxy::{StarMapResponse, StarMapStatus};
use settled_reach_server::bridge::{
send_star_map_responses, BridgeResource, StarMapResponseBuffer,
};
let (mut world, addr) = new_multi_connection_world();
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
let reader_id = world
.resource::<BridgeResource>()
.reader_ids()
.first()
.copied()
.expect("reader must be installed");
world.resource_mut::<StarMapResponseBuffer>().0.push((
reader_id,
StarMapResponse {
status: StarMapStatus::Ready,
data: Some(serde_json::json!({"nodes": [], "edges": []})),
},
));
world
.run_system_once(send_star_map_responses)
.expect("send_star_map_responses failed to run");
let payload = read_framed(&mut stream)
.expect("failed to read response frame")
.expect("unexpected EOF reading response");
let resp: StarMapResponse = rmp_serde::from_slice(&payload).expect("failed to decode");
assert_eq!(resp.status, StarMapStatus::Ready);
}
/// T-1130 behavior 4: while a Player streams `ObserverSnapshot` every tick,
/// a concurrently-connected Reader receives NOTHING on its socket — not a
/// filtered snapshot, not an empty one, nothing at all (D-254 §2: "not even
/// filtered" is structural, this test proves it holds over the wire with
/// both connections genuinely live at once, not just by code inspection).
#[test]
fn reader_never_receives_observer_snapshot_while_player_streams() {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::bridge::{send_bridge_snapshot, BridgeResource, SnapshotBuffer};
let (mut world, addr) = new_multi_connection_world();
// Player connects first (mirrors main.rs's structural first-connection
// path in spirit, though this test drives it through the SAME
// accept-loop the Reader below uses — the accept-loop must handle a
// Player exactly as well as a Reader, not just readers-after-a-
// pre-existing-Player).
let player_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (player)");
client_handshake(stream, ConnectionRole::Player)
});
drive_accept_loop_until(&mut world, |w| w.resource::<BridgeResource>().has_player());
let mut player_stream = player_handle.join().expect("player thread panicked");
let reader_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut reader_stream = reader_handle.join().expect("reader thread panicked");
// Non-blocking so a read that would otherwise hang forever (the whole
// point being tested — nothing ever arrives) returns WouldBlock instead.
reader_stream
.set_nonblocking(true)
.expect("failed to set reader stream non-blocking");
world.resource_mut::<SnapshotBuffer>().snapshot = Some(sample_snapshot(7));
world
.run_system_once(send_bridge_snapshot)
.expect("send_bridge_snapshot failed to run");
// Player DID get the snapshot — establishes the positive control so a
// trivially-broken send path (e.g. send_bridge_snapshot silently
// no-op'ing for everyone) can't masquerade as "reader correctly got
// nothing".
let payload = read_framed(&mut player_stream)
.expect("failed to read player frame")
.expect("unexpected EOF reading player snapshot");
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload).expect("failed to decode");
assert_eq!(snapshot.tick, 7);
// Reader got NOTHING — not WouldBlock-then-eventually-something, a
// sustained absence over a real wall-clock window.
let check_until = std::time::Instant::now() + std::time::Duration::from_millis(300);
while std::time::Instant::now() < check_until {
let mut probe = [0u8; 1];
match std::io::Read::read(&mut reader_stream, &mut probe) {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok(0) => panic!("reader stream unexpectedly closed"),
other => panic!(
"reader received unexpected data/result while player streamed: {:?}",
other
),
}
thread::sleep(std::time::Duration::from_millis(10));
}
}
/// T-1130 behavior 5: a Reader sending the forbidden `Vec<PlayerInput>`
/// shape is dropped (not forwarded to `InputQueue`) on each offense, and
/// disconnected once it crosses the strike threshold — never on the first
/// offense (D-254 §2: "log + drop on first offense, repeated = disconnect").
#[test]
fn forbidden_reader_input_is_dropped_then_disconnected() {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::bridge::{receive_bridge_inputs, BridgeResource};
use settled_reach_server::simulation::input::InputQueue;
let (mut world, addr) = new_multi_connection_world();
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
let send_one_input_frame = |stream: &mut TcpStream| {
let inputs = vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
}];
let payload = rmp_serde::to_vec_named(&inputs).expect("failed to serialize");
write_framed(stream, &payload).expect("failed to write input frame");
};
// Strikes 1 and 2: the reader survives, but nothing reaches InputQueue.
for strike in 1..=2 {
send_one_input_frame(&mut stream);
// Wall-clock settle for TCP delivery, then drain — mirrors this
// file's existing pattern (see single_tick_drains_all_ready_inbound_frames).
thread::sleep(std::time::Duration::from_millis(50));
world
.run_system_once(receive_bridge_inputs)
.expect("receive_bridge_inputs failed to run");
assert_eq!(
world.resource::<InputQueue>().len(),
0,
"forbidden reader input must never reach InputQueue (strike {})",
strike
);
assert_eq!(
world.resource::<BridgeResource>().reader_count(),
1,
"reader must survive strike {} (below disconnect threshold)",
strike
);
}
// Strike 3 crosses READER_VIOLATION_DISCONNECT_THRESHOLD (3) — the
// reader is disconnected.
send_one_input_frame(&mut stream);
thread::sleep(std::time::Duration::from_millis(50));
world
.run_system_once(receive_bridge_inputs)
.expect("receive_bridge_inputs failed to run");
assert_eq!(
world.resource::<InputQueue>().len(),
0,
"forbidden reader input must never reach InputQueue, even on the disconnecting strike"
);
assert_eq!(
world.resource::<BridgeResource>().reader_count(),
0,
"reader must be disconnected after crossing the violation threshold"
);
}
/// T-1130 behavior 6 (THE CRITICAL FIX): a Reader disconnecting must leave
/// a running Player session completely unaffected — `ServerRunning` stays
/// true, and the Player connection keeps working (proven by successfully
/// sending it a snapshot AFTER the reader is gone, not just by inspecting
/// the flag).
#[test]
fn reader_disconnect_does_not_affect_running_player_session() {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::bridge::{
receive_bridge_inputs, send_bridge_snapshot, BridgeResource, ServerRunning, SnapshotBuffer,
};
let (mut world, addr) = new_multi_connection_world();
let player_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (player)");
client_handshake(stream, ConnectionRole::Player)
});
drive_accept_loop_until(&mut world, |w| w.resource::<BridgeResource>().has_player());
let mut player_stream = player_handle.join().expect("player thread panicked");
let reader_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let reader_stream = reader_handle.join().expect("reader thread panicked");
// The reader disconnects (drop closes the TCP connection — clean EOF).
drop(reader_stream);
// Drain: the server observes the reader's EOF and removes it.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
world
.run_system_once(receive_bridge_inputs)
.expect("receive_bridge_inputs failed to run");
if world.resource::<BridgeResource>().reader_count() == 0 {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for reader disconnect to be observed"
);
thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
world.resource::<ServerRunning>().0,
"a reader's disconnect must never flip ServerRunning"
);
assert!(
world.resource::<BridgeResource>().has_player(),
"the player connection must still be installed after the reader disconnects"
);
// Prove the player session is still genuinely functional, not just
// structurally present: send it a snapshot and read it back.
world.resource_mut::<SnapshotBuffer>().snapshot = Some(sample_snapshot(99));
world
.run_system_once(send_bridge_snapshot)
.expect("send_bridge_snapshot failed to run");
let payload = read_framed(&mut player_stream)
.expect("failed to read post-disconnect player frame")
.expect("unexpected EOF — player session was affected by reader disconnect");
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload).expect("failed to decode");
assert_eq!(
snapshot.tick, 99,
"player session must remain fully functional after reader disconnect"
);
assert!(
world.resource::<ServerRunning>().0,
"sending to the still-live player must not flip ServerRunning either"
);
}
/// T-1130 behavior 7: a second connection attempting `role: Player` while a
/// Player is already connected gets a clean, immediate rejection (the
/// connection closes right after its handshake completes) — NOT the
/// original starvation bug (silent hang forever), and NOT a crash or
/// disruption to the existing Player's session.
#[test]
fn second_player_attempt_is_cleanly_rejected() {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::bridge::BridgeResource;
let (mut world, addr) = new_multi_connection_world();
let first_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (first player)");
client_handshake(stream, ConnectionRole::Player)
});
drive_accept_loop_until(&mut world, |w| w.resource::<BridgeResource>().has_player());
let _first_stream = first_handle.join().expect("first player thread panicked");
let second_handle = thread::spawn(move || {
let mut stream = TcpStream::connect(addr).expect("failed to connect (second player)");
// Read handshake and send StartupMessage{role: Player} exactly like
// a normal client — the rejection happens AFTER this, not by
// refusing the handshake itself (the connection is genuinely
// accepted and handshaken; it's the ROLE promotion that's refused).
stream = client_handshake(stream, ConnectionRole::Player);
// The server closes the connection right after — prove it's a
// clean disconnect (not the original silent-hang bug) by reading
// until EOF. read_framed blocks here (this stream is never set
// non-blocking), which is deliberate: it's this thread's own proof
// that the disconnect actually happens — if the original starvation
// bug were still present, this call would hang forever with no
// internal deadline of its own. The OUTER test loop below bounds
// total test time via `second_handle.is_finished()` polling against
// ITS OWN 5s deadline, so a regression here still fails the test in
// bounded time rather than hanging the test suite.
match read_framed(&mut stream) {
Ok(None) => {} // clean EOF — the expected rejection signal
Ok(Some(_)) => panic!("second player attempt must never receive a message"),
Err(_) => {} // connection reset also counts as "rejected, not hung"
}
});
// Drive the accept-loop until the second connection has been processed
// (it will never appear as a reader OR a second player — reader_count
// stays 0 and has_player stays true throughout, which is exactly the
// rejection this test verifies).
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !second_handle.is_finished() {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for second player rejection to complete"
);
world
.run_system_once(settled_reach_server::bridge::accept_new_connections)
.expect("accept_new_connections failed to run");
thread::sleep(std::time::Duration::from_millis(5));
}
second_handle.join().expect("second player thread panicked");
assert!(
world.resource::<BridgeResource>().has_player(),
"the original player connection must be untouched by the rejected second attempt"
);
assert_eq!(
world.resource::<BridgeResource>().reader_count(),
0,
"a rejected second-Player attempt must never be silently installed as a reader"
);
}
/// Minimal `ObserverSnapshot` for the tests above — same shape as
/// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized
/// only by `tick` (the one field these tests assert on).
fn sample_snapshot(tick: u64) -> ObserverSnapshot {
ObserverSnapshot {
tick,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
sound_events: vec![],
follow_state: None,
character_pressure: None,
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
settings_response: None,
economy_snapshot: None,
bookmark_catalog: None,
}
}
+4 -1
View File
@@ -81,7 +81,10 @@ fn server_subprocess_sends_snapshot_on_connect() {
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
// 5. Send StartupMessage with world_seed (#175)
let startup = StartupMessage { world_seed: 42 };
let startup = StartupMessage {
world_seed: 42,
role: ConnectionRole::Player,
};
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");