Files
settled-reach/client/scripts/autoloads/sim_bridge.gd
T
jpmschweitzerandClaude Opus 4.6 83a244fc2c fix(client): address PR #67 round 2 — broken tests, BBCode escape, expiry loop
- Add missing _test_input_queue proxy to SimBridge (26 call sites across
  6 test files broken by TestHarness extraction)
- Parent quit dialog to SceneTree root instead of caller node to prevent
  orphaned reference if caller freed before user responds
- Remove deprecated rng.randomize() call (Godot 4 auto-seeds)
- Clear debug overlay state (_npc_paths, tick timing) on session change
  via new GameState.game_id_changed signal to prevent entity ID collisions
- Update settings_dialog quit_to_menu() call site (no-arg signature)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:48:47 +01:00

332 lines
12 KiB
GDScript

extends Node
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
# Transport layer (non-test mode)
var _bridge: LocalBridge = null
var _server: ServerProcess = null
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()
# Connection retry state — handles server startup delay (Critical fix #1)
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
var _connect_retries: int = 0
var _retry_timer: float = 0.0
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
func _ready() -> void:
if test_mode:
harness = TestHarness.new()
print("SimBridge: Running in test mode (dynamic snapshot)")
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
func reset_test_state() -> void:
if harness: harness.reset()
func _test_snapshot() -> Dictionary:
return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to)
var _test_tick: int:
get: return harness.tick if harness else 0
set(v):
if harness: harness.tick = v
var _test_player_pos: Vector2i:
get: return harness.player_pos if harness else Vector2i.ZERO
set(v):
if harness: harness.player_pos = v
var _test_facing: String:
get: return harness.facing if harness else "North"
set(v):
if harness: harness.facing = v
var _test_in_dialogue: bool:
get: return harness.in_dialogue if harness else false
set(v):
if harness: harness.in_dialogue = v
var _test_gauntlet_mode: bool:
get: return harness.gauntlet_mode if harness else false
set(v):
if harness: harness.gauntlet_mode = v
var _test_npc_relationship: String:
get: return harness.npc_relationship if harness else "Unknown"
set(v):
if harness: harness.npc_relationship = v
var _test_input_queue: Array:
get: return harness.input_queue if harness else []
# -- Connection lifecycle ------------------------------------------------------
# Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void:
if state != new_state:
var old_state = state
state = new_state
connection_state_changed.emit(old_state, new_state)
# Connect to simulation server.
# In test mode, immediately transitions to CONNECTED.
# In live mode, spawns server subprocess and defers TCP connection to _process()
# to allow the server time to bind its port.
func connect_to_sim() -> void:
_set_state(ConnectionState.CONNECTING)
if test_mode:
_set_state(ConnectionState.CONNECTED)
return
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
var args := ["127.0.0.1:" + str(server_port)]
var game_id: String = GameState.current_game_id
if not game_id.is_empty():
args.append_array(["--game-id", game_id])
var pid := _server.start(server_path, args)
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
return
# Defer TCP connection to _process() — server needs time to bind its port
_connect_retries = 0
_retry_timer = 0.0
_bridge = null
# Disconnect from simulation server
func disconnect_from_sim() -> void:
if _bridge != null:
_bridge.disconnect_from_server()
_bridge = null
if _server != null:
_server.stop()
_server = null
_connect_retries = 0
_set_state(ConnectionState.DISCONNECTED)
# Attempt TCP connection. Called from _process() during CONNECTING state.
func _try_connect() -> void:
_bridge = LocalBridge.new()
var err := _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK:
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)])
_bridge = null
# Poll transport layer every frame (non-test mode only)
func _process(delta: float) -> void:
if test_mode:
return
# CONNECTING state: retry TCP connection until server is ready
if state == ConnectionState.CONNECTING:
if _bridge == null:
_retry_timer += delta
if _retry_timer >= CONNECT_RETRY_INTERVAL or _connect_retries == 0:
_retry_timer = 0.0
_connect_retries += 1
if _connect_retries > MAX_CONNECT_RETRIES:
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
_set_state(ConnectionState.ERROR)
return
_try_connect()
return
# Bridge exists — poll for connection completion
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
_set_state(ConnectionState.CONNECTED)
StreamPeerTCP.STATUS_CONNECTING:
pass # Still connecting, wait
StreamPeerTCP.STATUS_ERROR:
# Connection attempt failed — retry
_bridge = null
if _connect_retries >= MAX_CONNECT_RETRIES:
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
_set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE:
_bridge = null # Reset and retry
return
if _bridge == null:
return
_bridge.poll()
match _bridge.get_status():
StreamPeerTCP.STATUS_CONNECTED:
# Receive: drain all complete messages from the bridge
var msg := _bridge.poll_message()
while msg.size() > 0:
receive_bytes(msg)
msg = _bridge.poll_message()
# Send: batch-encode and flush outbound buffer as one frame (Vec<PlayerInput>).
# Inputs are drained before encoding. On encode failure the inputs are
# intentionally dropped — re-queuing would retry the same bad data and
# the server tick has already advanced, making stale inputs invalid.
var outbound := drain_outbound()
if outbound.size() > 0:
var encoded := Protocol.encode_player_inputs(outbound)
if encoded.size() > 0:
var err := _bridge.send_message(encoded)
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
else:
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
StreamPeerTCP.STATUS_CONNECTING:
pass # Should not happen in CONNECTED state
StreamPeerTCP.STATUS_ERROR:
if state != ConnectionState.ERROR:
push_error("SimBridge: TCP connection error")
_set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE:
if state == ConnectionState.CONNECTED:
push_warning("SimBridge: connection lost")
_set_state(ConnectionState.DISCONNECTED)
# -- Input / snapshot ----------------------------------------------------------
# Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are delegated to the test harness.
# In live mode, encoded and buffered for transport.
# Returns OK on success, or an error code on failure.
func send_input(player_input: Dictionary) -> Error:
if state != ConnectionState.CONNECTED:
return ERR_CONNECTION_ERROR
if test_mode:
var action: int = player_input.get("action", -1)
var wire_name: String = action_enum_to_wire(action)
if not wire_name.is_empty():
if wire_name == "SetFacing":
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
harness.process_facing(facing)
else:
harness.process_input(wire_name)
return OK
var action_name := action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
return ERR_INVALID_PARAMETER
var tick: int = GameState.current_tick
var entry: Dictionary = { "tick": tick, "action_name": action_name }
var action_data: Variant = player_input.get("action_data")
if action_data != null:
entry["action_data"] = action_data
_outbound_buffer.append(entry)
return OK
# Poll for snapshot from simulation.
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
func poll_snapshot() -> Variant:
if state != ConnectionState.CONNECTED:
return null
if test_mode:
var snapshot = harness.snapshot()
snapshot_received.emit(snapshot)
return snapshot
if _last_snapshot != null:
var snapshot = _last_snapshot
_last_snapshot = null
snapshot_received.emit(snapshot)
return snapshot
return null
# Called by transport layer when raw bytes arrive from the server.
# Latest-wins for positional state (stale frames are worthless), but one-shot
# events (monologue, dialogue) are carried forward from overwritten snapshots
# so they aren't silently dropped when server ticks faster than client consumes.
func receive_bytes(bytes: PackedByteArray) -> void:
var snapshot = Protocol.decode_snapshot(bytes)
if snapshot == null:
push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size())
return
if _last_snapshot != null:
# Carry forward one-shot events the client hasn't consumed yet.
if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null:
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
if old_conv_events.size() > 0:
var new_conv_events: Array = snapshot.get("conversation_events", [])
snapshot["conversation_events"] = old_conv_events + new_conv_events
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
func drain_outbound() -> Array[Dictionary]:
var inputs = _outbound_buffer.duplicate()
_outbound_buffer.clear()
return inputs
# -- Wire protocol mapping -----------------------------------------------------
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
InputMapper.Action.MOVE_EAST: return "MoveEast"
InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast"
InputMapper.Action.MOVE_SOUTH: return "MoveSouth"
InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest"
InputMapper.Action.MOVE_WEST: return "MoveWest"
InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest"
InputMapper.Action.INTERACT: return "Interact"
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
InputMapper.Action.PAUSE: return "Pause"
InputMapper.Action.UNPAUSE: return "Unpause"
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""