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 _test_in_dialogue: bool = false # Mock dialogue state (#434) var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color 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() _test_in_dialogue = false _test_gauntlet_mode = false _test_npc_relationship = "Unknown" # 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(): if wire_name == "SetFacing": # D-054: Use action_data.facing from the input dict, not InputMapper global 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(): _test_facing = facing else: _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 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 # 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 "" # 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: if action_name == "TeleportToHub": # #501: Reset to hub spawn position, clear dialogue _test_player_pos = Vector2i(10, 10) _test_in_dialogue = false continue if action_name == "Interact": # Mock dialogue trigger (#434): if near NPC, start dialogue var npc_pos := Vector2i(12, 9) var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y) if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): _test_in_dialogue = true continue 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: # Walk-away dismisses dialogue (D-064) if _test_in_dialogue: _test_in_dialogue = false _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, "relationship": _test_npc_relationship, }) # 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, } # v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC # Sustained: dialogue persists across ticks while _test_in_dialogue is true. # Movement (walk-away) clears it. Client consume-once guards against re-show. # Options: structured {text, response_id, priority} per #435. var dialogue: Variant = null if _test_in_dialogue: dialogue = { "npc_name": "Kael", "npc_entity_id": 2, "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", "options": [ {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, ], } # v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity # Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks. # Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters). var pending_recs: Array = [] var cycle_pos := _test_tick % 12 if cycle_pos < 6: var total_delay := 6 var remaining := total_delay - cycle_pos pending_recs.append({ "entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": remaining, "total_delay_ticks": total_delay, }) # #535: Mock overheard NPC-NPC conversation (D-078) # Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3. # Conversation ends after 6 exchanges (~30 ticks). var conv_events: Array = [] var conv_ended: Array = [] var conv_start := 3 var conv_lines := [ {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, ] var conv_tick_interval := 5 var conv_total_ticks := conv_lines.size() * conv_tick_interval if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks: var conv_index := (_test_tick - conv_start) / conv_tick_interval var within_tick := (_test_tick - conv_start) % conv_tick_interval if within_tick == 0 and conv_index < conv_lines.size(): var cl: Dictionary = conv_lines[conv_index] conv_events.append({ "speaker_id": 10, "target_id": 11, "speaker_name": cl.speaker, "target_name": cl.target, "occluded_line": cl.line, }) elif _test_tick == conv_start + conv_total_ticks: conv_ended.append({"speaker_id": 10, "target_id": 11}) 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, "player_stance": "Walk", "player_inventory": [], "entities": entities, "tiles": _test_tiles(), "visible_tiles": _test_visible_tiles(), "visible_positions": _test_visible_positions(), "nearby_interactions": nearby, "current_monologue": monologue, "current_dialogue": dialogue, "pending_recognitions": pending_recs, "gauntlet_mode": _test_gauntlet_mode, "conversation_events": conv_events, "conversation_ended": conv_ended, } # 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"