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 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). # 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 # #554: Carry forward save/load result (one-shot, consumed by main.gd) if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: snapshot["save_result"] = _last_snapshot["save_result"] _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) InputMapper.Action.SAVE_GAME: return "SaveGame" # #554: F5 quicksave (D-085) InputMapper.Action.LOAD_GAME: return "LoadGame" # #554: F6 quickload (D-085) _: push_warning("SimBridge: unknown action enum %s" % action) return ""