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 _test_tick: int = 0 var _test_player_pos: Vector2i = Vector2i(10, 10) var _test_facing: String = "North" var _test_input_queue: Array = [] # Queued actions for test mode 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: print("SimBridge: Running in test mode (dynamic snapshot)") # Reset test state — call before tests that use _test_snapshot() func reset_test_state() -> void: _test_tick = 0 _test_player_pos = Vector2i(10, 10) _test_facing = "North" _test_input_queue.clear() # 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") var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)]) 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) # Send input to simulation server. # player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec". # In test mode, inputs are silently dropped. 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(): _test_input_queue.append(wire_name) return OK var action_name := _action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): # _action_enum_to_wire already emits push_warning for invalid actions return ERR_INVALID_PARAMETER # Use the server's current tick so drain_for_tick processes this input immediately. # The client-side timestamp_msec is only useful for ordering within a frame. var tick: int = GameState.current_tick var entry: Dictionary = { "tick": tick, "action_name": action_name } # Data variants (e.g. UsePerceptionMode) carry payload 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 returns hardcoded data. In live mode, returns the last decoded snapshot (if any). func poll_snapshot() -> Variant: if state != ConnectionState.CONNECTED: return null if test_mode: var snapshot = _test_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 semantics: newer snapshots replace unconsumed ones. This is correct # for real-time rendering (stale frames are worthless). Upgrade to queue if needed. func receive_bytes(bytes: PackedByteArray) -> void: var snapshot = Protocol.decode_snapshot(bytes) if snapshot != null: if _last_snapshot != null: push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick]) _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 # 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.OPEN_MENU: # Client-only action, not part of wire protocol push_warning("SimBridge: OPEN_MENU is client-only, not sent to server") return "" _: push_warning("SimBridge: unknown action enum %s" % action) return "" # Dynamic test snapshot — processes queued inputs to move player, generates # visibility based on current position. Matches Protocol.decode_snapshot() format. # NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10) # is intentionally decoupled from the E2E proof room (player at 16,16; NPC at # 16,13; wall at 16,14). This ensures standalone tests don't depend on server # map layout and can exercise the rendering pipeline independently. func _test_snapshot() -> Dictionary: _test_tick += 1 # Process queued inputs for action_name in _test_input_queue: var delta := _action_to_delta(action_name) var new_pos := _test_player_pos + delta if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: _test_facing = _delta_to_facing(delta) _test_input_queue.clear() var px := _test_player_pos.x var py := _test_player_pos.y # Build entities — player always visible var entities: Array = [{ "entity_id": 1, "x": float(px), "y": float(py), "z": 0, "kind": { "variant": "Player", "data": null }, "visibility": "Forward", }] # NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10) var npc_pos := Vector2i(12, 9) var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y) if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos): var sector: String = "Forward" if npc_pos.y <= py else "Peripheral" entities.append({ "entity_id": 2, "x": float(npc_pos.x), "y": float(npc_pos.y), "z": 0, "kind": { "variant": "Npc", "data": null }, "visibility": sector, }) # v4: nearby_interactions when NPC is nearby and visible (#404/#405) var nearby: Array = [] if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos): nearby.append({ "entity_id": 2, "entity_type": "Npc", "distance": npc_dist, "verbs": [ {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, ], }) # v5: monologue on first tick (#414) var monologue: Variant = null if _test_tick == 1: monologue = { "id": "test_enter_001", "text": "Sova Transit District. Population twelve thousand and change.", "duration_seconds": 5.0, } return { "tick": _test_tick, "version": Protocol.PROTOCOL_VERSION, "game_time": { "day": 0, "time_of_day": _test_tick * 10, "day_phase": "Morning", "tick_rate": "Full", }, "player_facing": _test_facing, "entities": entities, "tiles": _test_tiles(), "visible_tiles": _test_visible_tiles(), "visible_positions": _test_visible_positions(), "nearby_interactions": nearby, "current_monologue": monologue, } # Generate a small test room: 8x6 room with walls, a door, and floor func _test_tiles() -> Array: var tiles: Array = [] var room_x := 7 var room_y := 7 var room_w := 8 var room_h := 8 for x in range(room_x, room_x + room_w): for y in range(room_y, room_y + room_h): var is_edge := (x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1) var tile_type: String if is_edge: # Door on the south wall, center if y == room_y + room_h - 1 and x == room_x + room_w / 2: tile_type = "door" else: tile_type = "wall" else: tile_type = "floor" tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) # Corridor south of the door var door_x := room_x + room_w / 2 for y in range(room_y + room_h, room_y + room_h + 4): tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) return tiles # Test visible tiles with visibility sectors (v2 format) # Tiles ahead of the player are Forward, others Peripheral. func _test_visible_tiles() -> Array: var vtiles: Array = [] var px := _test_player_pos.x var py := _test_player_pos.y var radius := 4 var room_x := 7 var room_y := 7 var room_w := 8 var room_h := 8 for x in range(px - radius, px + radius + 1): for y in range(py - radius, py + radius + 1): var dist := absf(x - px) + absf(y - py) if dist <= radius: if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: var sector: String = "Forward" if y <= py else "Peripheral" vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) return vtiles # Test visibility: tiles within radius 4 of player, inside room bounds func _test_visible_positions() -> Array: var positions: Array = [] var px := _test_player_pos.x var py := _test_player_pos.y var radius := 4 var room_x := 7 var room_y := 7 var room_w := 8 var room_h := 8 for x in range(px - radius, px + radius + 1): for y in range(py - radius, py + radius + 1): var dist := absf(x - px) + absf(y - py) if dist <= radius: if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: positions.append({"x": x, "y": y}) return positions # -- Test mode helpers -- const _TEST_WALLS: Array = [ # Room walls (8x8 room from (7,7) to (14,14)) Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), Vector2i(7,12), Vector2i(7,13), Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), Vector2i(14,12), Vector2i(14,13), # Interior wall blocking NPC Vector2i(12, 10), ] func _test_is_walkable(pos: Vector2i) -> bool: return not _TEST_WALLS.has(pos) # Simple LOS check — blocked if a wall tile sits between start and end func _test_has_los(from: Vector2i, to: Vector2i) -> bool: # Bresenham-lite: check tiles along the line var dx := absi(to.x - from.x) var dy := absi(to.y - from.y) var sx := 1 if from.x < to.x else -1 var sy := 1 if from.y < to.y else -1 var err := dx - dy var cx := from.x var cy := from.y while true: if cx == to.x and cy == to.y: return true if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)): return false var e2 := 2 * err if e2 > -dy: err -= dy cx += sx if e2 < dx: err += dx cy += sy return true static func _action_to_delta(action_name: String) -> Vector2i: match action_name: "MoveNorth": return Vector2i(0, -1) "MoveNortheast": return Vector2i(1, -1) "MoveEast": return Vector2i(1, 0) "MoveSoutheast": return Vector2i(1, 1) "MoveSouth": return Vector2i(0, 1) "MoveSouthwest": return Vector2i(-1, 1) "MoveWest": return Vector2i(-1, 0) "MoveNorthwest": return Vector2i(-1, -1) _: return Vector2i.ZERO static func _delta_to_facing(delta: Vector2i) -> String: match delta: Vector2i(0, -1): return "North" Vector2i(1, -1): return "Northeast" Vector2i(1, 0): return "East" Vector2i(1, 1): return "Southeast" Vector2i(0, 1): return "South" Vector2i(-1, 1): return "Southwest" Vector2i(-1, 0): return "West" Vector2i(-1, -1): return "Northwest" _: return "North"