Files
settled-reach/client/scripts/autoloads/sim_bridge.gd
T
jpmschweitzerandClaude Opus 4.6 57da75c509 feat(client): upgrade protocol decoder and GameState for v2 snapshot
Server shipped ObserverSnapshot v2 with game_time, player_facing,
visible_tiles (with visibility sectors), and per-entity visibility.
Protocol decoder was silently ignoring these fields. Now extracts
all v2 data with null defaults for backward compatibility.

GameState gains game_time, player_facing, visibility_sectors vars.
Derives visible_positions from visible_tiles when present (for real
server mode). Test snapshot updated with v2 fields.

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

349 lines
12 KiB
GDScript

extends Node
# Connection states
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = true # Enable test mode for development without Rust server
var _test_tick: int = 0
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 (hardcoded snapshot)")
# 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<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)
# 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:
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
var tick: int = player_input.get("timestamp_msec", 0)
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 ""
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
# Uses the same {tick, entities, tiles} schema as Protocol.decode_snapshot() returns.
func _test_snapshot() -> Dictionary:
_test_tick += 1
return {
"tick": _test_tick,
"version": 2,
"game_time": {
"day": 0,
"time_of_day": 0,
"day_phase": "Morning",
"paused": false,
},
"player_facing": "North",
"entities": [
{
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": { "variant": "Player", "data": null },
"visibility": "Forward",
},
{
"entity_id": 2,
"x": 12.0,
"y": 10.0,
"z": 0,
"kind": { "variant": "Npc", "data": null },
"visibility": "Peripheral",
},
],
"tiles": _test_tiles(),
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
}
# 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 (y <= player_y) are Forward, others Peripheral.
func _test_visible_tiles() -> Array:
var vtiles: Array = []
var player_x := 10
var player_y := 10
var radius := 4
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(player_x - radius, player_x + radius + 1):
for y in range(player_y - radius, player_y + radius + 1):
var dist := absf(x - player_x) + absf(y - player_y)
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 <= player_y else "Peripheral"
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
return vtiles
# Test visibility: player at (10,10) can see tiles within radius 4, blocked by walls
func _test_visible_positions() -> Array:
var positions: Array = []
var player_x := 10
var player_y := 10
var radius := 4
# Room bounds (inner floor area)
var room_x := 7
var room_y := 7
var room_w := 8
var room_h := 8
for x in range(player_x - radius, player_x + radius + 1):
for y in range(player_y - radius, player_y + radius + 1):
var dist := absf(x - player_x) + absf(y - player_y)
if dist <= radius:
# Walls are visible but block further vision
# For test purposes, include all tiles within radius that are inside the room
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