From 2338941721329d7c5fe592cea51035f1972a99eb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:44:14 +0100 Subject: [PATCH 1/6] fix(client): batch-encode inputs as Vec per server wire format Server expects a MessagePack array of PlayerInput objects in one framed message per tick, not individual inputs per frame. Added Protocol.encode_player_inputs() for batch encoding. Changed SimBridge to buffer raw input dicts and batch-encode in _process(). Also fixed server port default (9876) and positional arg format to match server CLI. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 40 +++++++++++++++----------- client/scripts/protocol/protocol.gd | 26 +++++++++++++++++ 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 9bda09f94..d5a64b83f 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -7,12 +7,12 @@ 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[PackedByteArray] = [] # Encoded inputs awaiting transport +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 = 9800 +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) @@ -50,7 +50,8 @@ func connect_to_sim() -> void: # Spawn server subprocess if not server_path.is_empty(): _server = ServerProcess.new() - var pid := _server.start(server_path, ["--port", str(server_port)]) + # 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) @@ -129,12 +130,16 @@ func _process(delta: float) -> void: while msg.size() > 0: receive_bytes(msg) msg = _bridge.poll_message() - # Send: flush outbound buffer through the bridge + # Send: batch-encode and flush outbound buffer as one frame (Vec) var outbound := drain_outbound() - for payload in outbound: - var err := _bridge.send_message(payload) - if err != OK: - push_error("SimBridge: failed to send message: %s" % error_string(err)) + 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" % outbound.size()) StreamPeerTCP.STATUS_CONNECTING: pass # Should not happen in CONNECTED state StreamPeerTCP.STATUS_ERROR: @@ -160,11 +165,12 @@ func send_input(player_input: Dictionary) -> Error: # _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 encoded := Protocol.encode_player_input(tick, action_name) - if encoded.size() == 0: - push_error("SimBridge: failed to encode player input (action=%s)" % action_name) - return ERR_CANT_CREATE - _outbound_buffer.append(encoded) + 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. @@ -196,11 +202,11 @@ func receive_bytes(bytes: PackedByteArray) -> void: push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick]) _last_snapshot = snapshot -# Drain the outbound buffer. Returns encoded messages for transport. -func drain_outbound() -> Array[PackedByteArray]: - var messages = _outbound_buffer.duplicate() +# 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 messages + 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. diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index be31cece7..319737f52 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -107,6 +107,32 @@ static func encode_player_input(tick: int, action_name: String, action_data: Var return result.value +## Encode an array of PlayerInputs to MessagePack bytes (Vec wire format). +## Server expects one framed message per tick containing all inputs as a msgpack array. +## Each entry: { "tick": int, "action_name": String, "action_data": Variant (optional) } +static func encode_player_inputs(inputs: Array) -> PackedByteArray: + var wire_inputs: Array = [] + for input in inputs: + var action_name: String = input["action_name"] + var action_data: Variant = input.get("action_data") + var action_value: Variant + if action_data != null: + action_value = { action_name: action_data } + else: + action_value = action_name + wire_inputs.append({ + "tick": input["tick"], + "action": action_value, + }) + + var result = Messagepack.encode(wire_inputs) + if result.status != null: + push_error("Protocol: msgpack encode failed: %s" % result.status) + return PackedByteArray() + + return result.value + + ## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios). ## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null. static func decode_player_input(bytes: PackedByteArray) -> Variant: From e1e4e346c7db6f02e9808cb530e58bacec5d1583 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:44:20 +0100 Subject: [PATCH 2/6] feat(client): add EntityKind::Player fixture and update multi-entity fixture Server team added EntityKind::Player variant. Added snapshot_player fixture and updated snapshot_multi_entity to include all 4 entity kinds (Player, Npc, Object, Terrain) for complete D-030 Layer 1 coverage. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_multi_entity.msgpack | Bin 140 -> 181 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 0 -> 58 bytes server/tests/gen_fixtures.rs | 27 ++++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 client/tests/fixtures/msgpack/snapshot_player.msgpack diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 2c0217a4547eadf70ccbe33d72e3023431b87d0d..658104fd896a3c3d2fb0bbf11fdf79d3201c6bac 100644 GIT binary patch delta 90 zcmeBS+{)z e^HP=t?3FZ delta 49 zcmdnW*uyB_w4@|6Ir}X0^A)LiC7C6esl}69SAv<9@tG-%6FpQJnI`6GFfvb^E(ZVs Cs1o%6 diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..b1d14171fdc09353b59a46baf35f948dc7e1d2ce GIT binary patch literal 58 zcmZo#Qj(dR&A1{puOzc1GqreP>q;=QGCngUWnslB#})>Lg_RJhieX81W?ss&fSkn2 G)FJ@pm>87+ literal 0 HcmV?d00001 diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 0b6389135..3562393cd 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -60,26 +60,49 @@ fn generate_msgpack_fixtures() { &rmp_serde::to_vec_named(&input_perception).unwrap(), ); + // Snapshot with Player entity (EntityKind::Player added by server team) + let snapshot_player = ObserverSnapshot { + tick: 1, + entities: vec![VisibleEntity { + entity_id: 100, + x: 16.5, + y: 16.5, + z: 0, + kind: EntityKind::Player, + }], + }; + write_fixture( + "snapshot_player", + &rmp_serde::to_vec_named(&snapshot_player).unwrap(), + ); + // Snapshot with multiple entities and all EntityKind variants let snapshot_multi = ObserverSnapshot { tick: 999, entities: vec![ VisibleEntity { entity_id: 1, + x: 16.5, + y: 16.5, + z: 0, + kind: EntityKind::Player, + }, + VisibleEntity { + entity_id: 2, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc, }, VisibleEntity { - entity_id: 2, + entity_id: 3, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object, }, VisibleEntity { - entity_id: 3, + entity_id: 4, x: 0.0, y: 0.0, z: -1, From 5e4b8dbcf3b4996a3ad65fcd7b36ca4724046634 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:44:27 +0100 Subject: [PATCH 3/6] feat(client): add E2E connection test and batch encoding tests D-030 Layer 3: E2E test spawns the Rust server binary, connects via LocalBridge, sends a batched MoveNorth input, and verifies the player moved to (16.5, 15.5). Also adds batch encoding roundtrip tests, framed batch test, and Player entity fixture decode test. 43/43 pass. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_e2e_connection.gd | 99 +++++++++++++++++++++++++++++ client/tests/test_local_bridge.gd | 22 +++++++ client/tests/test_protocol.gd | 96 ++++++++++++++++++++++++++-- 3 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 client/tests/test_e2e_connection.gd diff --git a/client/tests/test_e2e_connection.gd b/client/tests/test_e2e_connection.gd new file mode 100644 index 000000000..9bb4dd977 --- /dev/null +++ b/client/tests/test_e2e_connection.gd @@ -0,0 +1,99 @@ +## D-030 Layer 3: End-to-end connection test +## Spawns the Rust simulation server, connects via LocalBridge, +## sends a MoveNorth input, and verifies the snapshot response. +## Requires: server binary built (cargo build in server/) +class_name TestE2EConnection +extends GdUnitTestSuite + +const TEST_PORT: int = 19876 +const CONNECT_TIMEOUT: float = 3.0 +const RESPONSE_TIMEOUT: float = 5.0 + +var _server_pid: int = -1 +var _bridge: LocalBridge = null + + +func _server_binary_path() -> String: + # Resolve path relative to the Godot project root + var project_dir := ProjectSettings.globalize_path("res://") + return project_dir.path_join("../server/target/debug/settled-reach-server") + + +func after_test() -> void: + # Clean up regardless of test outcome + if _bridge != null: + _bridge.disconnect_from_server() + _bridge = null + if _server_pid > 0 and OS.is_process_running(_server_pid): + OS.kill(_server_pid) + _server_pid = -1 + + +# -- E2E: full round-trip through server binary -------------------------------- + +func test_send_input_receive_snapshot() -> void: + var server_path := _server_binary_path() + if not FileAccess.file_exists(server_path): + push_warning("E2E test skipped: server binary not found at %s" % server_path) + return + + # Spawn server + var addr := "127.0.0.1:%d" % TEST_PORT + _server_pid = OS.create_process(server_path, [addr]) + assert_that(_server_pid).is_greater(0) + + # Connect with retries (server needs time to bind port) + _bridge = LocalBridge.new() + var connected := false + var elapsed := 0.0 + while elapsed < CONNECT_TIMEOUT: + if _bridge.get_status() == StreamPeerTCP.STATUS_NONE: + _bridge.connect_to_server("127.0.0.1", TEST_PORT) + _bridge.poll() + if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED: + connected = true + break + if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR: + # Reset and retry + _bridge.disconnect_from_server() + _bridge.reset() + await get_tree().create_timer(0.1).timeout + elapsed += 0.1 + + assert_bool(connected).is_true() + + # Send batch input: MoveNorth at tick 0 (matching game_loop.rs test) + var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}] + var encoded := Protocol.encode_player_inputs(inputs) + assert_that(encoded.size()).is_greater(0) + var send_err := _bridge.send_message(encoded) + assert_that(send_err).is_equal(OK) + + # Poll for snapshot response + var snapshot_bytes := PackedByteArray() + elapsed = 0.0 + while elapsed < RESPONSE_TIMEOUT: + _bridge.poll() + snapshot_bytes = _bridge.poll_message() + if snapshot_bytes.size() > 0: + break + await get_tree().create_timer(0.05).timeout + elapsed += 0.05 + + assert_that(snapshot_bytes.size()).is_greater(0) + + # Decode snapshot + var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes) + assert_that(snapshot).is_not_null() + + # Server starts at tick 0, snapshot reflects state after processing + assert_that(snapshot.tick).is_equal(0) + assert_that(snapshot.entities.size()).is_equal(1) + + # Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0) + # Render coords: tile center offset → (16.5, 15.5, 0) + var player: Dictionary = snapshot.entities[0] + assert_float(player.x).is_equal_approx(16.5, 0.001) + assert_float(player.y).is_equal_approx(15.5, 0.001) + assert_that(player.z).is_equal(0) + assert_that(player.kind.variant).is_equal("Player") diff --git a/client/tests/test_local_bridge.gd b/client/tests/test_local_bridge.gd index 846a7df68..166978bcd 100644 --- a/client/tests/test_local_bridge.gd +++ b/client/tests/test_local_bridge.gd @@ -123,6 +123,28 @@ func test_framed_protocol_input_roundtrip() -> void: assert_that(input.action.variant).is_equal("MoveNorth") +func test_framed_protocol_batch_input_roundtrip() -> void: + # Encode a batch of inputs (Vec), frame it, decode frame, verify wire format + var inputs: Array = [ + {"tick": 0, "action_name": "MoveNorth"}, + {"tick": 0, "action_name": "Interact"}, + ] + var encoded := Protocol.encode_player_inputs(inputs) + assert_that(encoded.size()).is_greater(0) + + var framed := LocalBridge.frame_encode(encoded) + var decoded_frame: Variant = LocalBridge.frame_decode(framed) + assert_that(decoded_frame).is_not_null() + + # Verify the payload is a valid msgpack array matching server expectations + var raw: Variant = Messagepack.decode(decoded_frame.payload) + assert_that(raw.status).is_null() + assert_that(raw.value is Array).is_true() + assert_that(raw.value.size()).is_equal(2) + assert_that(raw.value[0]["action"]).is_equal("MoveNorth") + assert_that(raw.value[1]["action"]).is_equal("Interact") + + # -- Diagonal movement wire mapping -------------------------------------------- func test_action_enum_to_wire_all_directions_clockwise() -> void: diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index d045f2f7c..0729afcfd 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -42,33 +42,58 @@ func test_decode_snapshot_empty() -> void: assert_that(snapshot.entities.size()).is_equal(0) +func test_decode_snapshot_player() -> void: + var bytes = _load_fixture("snapshot_player") + var snapshot = Protocol.decode_snapshot(bytes) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(1) + assert_that(snapshot.entities.size()).is_equal(1) + + var player = snapshot.entities[0] + assert_that(player.entity_id).is_equal(100) + assert_float(player.x).is_equal_approx(16.5, 0.001) + assert_float(player.y).is_equal_approx(16.5, 0.001) + assert_that(player.z).is_equal(0) + assert_that(player.kind.variant).is_equal("Player") + assert_that(player.kind.data).is_null() + + func test_decode_snapshot_multi_entity() -> void: var bytes = _load_fixture("snapshot_multi_entity") var snapshot = Protocol.decode_snapshot(bytes) assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(999) - assert_that(snapshot.entities.size()).is_equal(3) + assert_that(snapshot.entities.size()).is_equal(4) + + # Player at (16.5, 16.5, 0) + var player = snapshot.entities[0] + assert_that(player.entity_id).is_equal(1) + assert_float(player.x).is_equal_approx(16.5, 0.001) + assert_float(player.y).is_equal_approx(16.5, 0.001) + assert_that(player.z).is_equal(0) + assert_that(player.kind.variant).is_equal("Player") # NPC at (5, 10, 0) - var npc = snapshot.entities[0] - assert_that(npc.entity_id).is_equal(1) + var npc = snapshot.entities[1] + assert_that(npc.entity_id).is_equal(2) assert_float(npc.x).is_equal_approx(5.0, 0.001) assert_float(npc.y).is_equal_approx(10.0, 0.001) assert_that(npc.z).is_equal(0) assert_that(npc.kind.variant).is_equal("Npc") # Object at (15.5, 3, 1) - var obj = snapshot.entities[1] - assert_that(obj.entity_id).is_equal(2) + var obj = snapshot.entities[2] + assert_that(obj.entity_id).is_equal(3) assert_float(obj.x).is_equal_approx(15.5, 0.001) assert_float(obj.y).is_equal_approx(3.0, 0.001) assert_that(obj.z).is_equal(1) assert_that(obj.kind.variant).is_equal("Object") # Terrain at (0, 0, -1) - var terrain = snapshot.entities[2] - assert_that(terrain.entity_id).is_equal(3) + var terrain = snapshot.entities[3] + assert_that(terrain.entity_id).is_equal(4) assert_float(terrain.x).is_equal_approx(0.0, 0.001) assert_float(terrain.y).is_equal_approx(0.0, 0.001) assert_that(terrain.z).is_equal(-1) @@ -169,6 +194,63 @@ func test_encode_produces_nonempty_bytes() -> void: assert_that(bytes.size()).is_greater(0) +# -- Batch input encoding (Vec wire format) ----------------------- + +func test_encode_player_inputs_single() -> void: + var inputs: Array = [{"tick": 10, "action_name": "MoveNorth"}] + var bytes := Protocol.encode_player_inputs(inputs) + assert_that(bytes.size()).is_greater(0) + + # Decode as raw msgpack — should be an array with one element + var raw: Variant = Messagepack.decode(bytes) + assert_that(raw.status).is_null() + assert_that(raw.value is Array).is_true() + assert_that(raw.value.size()).is_equal(1) + assert_that(raw.value[0]["tick"]).is_equal(10) + assert_that(raw.value[0]["action"]).is_equal("MoveNorth") + + +func test_encode_player_inputs_multiple() -> void: + var inputs: Array = [ + {"tick": 1, "action_name": "MoveNorth"}, + {"tick": 1, "action_name": "Interact"}, + {"tick": 2, "action_name": "MoveSouthwest"}, + ] + var bytes := Protocol.encode_player_inputs(inputs) + assert_that(bytes.size()).is_greater(0) + + var raw: Variant = Messagepack.decode(bytes) + assert_that(raw.status).is_null() + assert_that(raw.value.size()).is_equal(3) + assert_that(raw.value[0]["action"]).is_equal("MoveNorth") + assert_that(raw.value[1]["action"]).is_equal("Interact") + assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest") + + +func test_encode_player_inputs_with_data_variant() -> void: + var inputs: Array = [ + {"tick": 5, "action_name": "UsePerceptionMode", "action_data": "thermal"}, + ] + var bytes := Protocol.encode_player_inputs(inputs) + assert_that(bytes.size()).is_greater(0) + + var raw: Variant = Messagepack.decode(bytes) + assert_that(raw.status).is_null() + assert_that(raw.value[0]["action"] is Dictionary).is_true() + assert_that(raw.value[0]["action"]["UsePerceptionMode"]).is_equal("thermal") + + +func test_encode_player_inputs_empty() -> void: + var inputs: Array = [] + var bytes := Protocol.encode_player_inputs(inputs) + assert_that(bytes.size()).is_greater(0) + + var raw: Variant = Messagepack.decode(bytes) + assert_that(raw.status).is_null() + assert_that(raw.value is Array).is_true() + assert_that(raw.value.size()).is_equal(0) + + # -- Diagonal movement fixtures (D-030 Layer 1 cross-language) ----------------- func test_decode_diagonal_fixtures() -> void: From c737d687aacc8bb065680e26979517a84963914c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:44:51 +0100 Subject: [PATCH 4/6] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2934fc4ae..9cf256d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- End-to-end connection test (#81) — GDScript test spawning Rust server, connecting via LocalBridge, sending MoveNorth input, verifying player movement in snapshot response (D-030 Layer 3) +- Batch input encoding (Vec\ wire format) — Protocol.encode_player_inputs() batches all inputs per tick into one framed message matching server expectations +- EntityKind::Player fixture — snapshot_player.msgpack for cross-language testing, multi-entity fixture updated to include all 4 entity kinds +- 7 new tests (4 batch encoding, 1 framed batch roundtrip, 1 Player fixture decode, 1 E2E connection), 43 total client tests passing - LocalBridge GDScript TCP transport (#79) — 4-byte big-endian length-prefix framing matching Rust server, StreamPeerTCP wrapper with partial read handling - ServerProcess subprocess manager — spawns/stops Rust server via OS.create_process(), auto-cleanup on destruction - SimBridge live transport integration — _process() polling loop for TCP receive/send, connection state machine (DISCONNECTED → CONNECTING → CONNECTED → ERROR) @@ -111,6 +115,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf) ### Fixed +- SimBridge wire format: inputs now batch-encoded as Vec\ array per server protocol (was sending individual inputs per frame) +- SimBridge server args: positional address format ("127.0.0.1:9876") matching server CLI, port default corrected to 9876 - Wire format mismatch: LocalBridge now uses rmp_serde::to_vec_named() (named maps) matching client Protocol.gd expectations - EOF on bridge read now returns BridgeError::Transport for disconnect detection instead of empty Vec - WalkabilityMap rewritten from flat Vec to chunk-based HashMap per D-012 architecture (Tyre review) From 8d12776b5d90cda23ddfd7e1d4e290913286f3e4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:57:11 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix(client):=20address=20PR=20#7=20review?= =?UTF-8?q?=20=E2=80=94=20ephemeral=20port,=20input=20drop=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe #1: E2E test now uses random ephemeral port (49152-65535) with port rotation on bind failure, avoiding conflicts in parallel CI. Hoshe #2: Documented intentional input drop on encode failure in SimBridge — re-queuing would retry bad data and server tick has already advanced. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 7 +++-- client/tests/test_e2e_connection.gd | 43 +++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index d5a64b83f..954626f0f 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -130,7 +130,10 @@ func _process(delta: float) -> void: while msg.size() > 0: receive_bytes(msg) msg = _bridge.poll_message() - # Send: batch-encode and flush outbound buffer as one frame (Vec) + # 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) @@ -139,7 +142,7 @@ func _process(delta: float) -> void: if err != OK: push_error("SimBridge: failed to send message: %s" % error_string(err)) else: - push_error("SimBridge: failed to batch-encode %d inputs" % outbound.size()) + 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: diff --git a/client/tests/test_e2e_connection.gd b/client/tests/test_e2e_connection.gd index 9bb4dd977..1f3e6df10 100644 --- a/client/tests/test_e2e_connection.gd +++ b/client/tests/test_e2e_connection.gd @@ -5,22 +5,45 @@ class_name TestE2EConnection extends GdUnitTestSuite -const TEST_PORT: int = 19876 const CONNECT_TIMEOUT: float = 3.0 const RESPONSE_TIMEOUT: float = 5.0 +const MAX_PORT_ATTEMPTS: int = 5 var _server_pid: int = -1 var _bridge: LocalBridge = null +var _test_port: int = 0 func _server_binary_path() -> String: - # Resolve path relative to the Godot project root var project_dir := ProjectSettings.globalize_path("res://") return project_dir.path_join("../server/target/debug/settled-reach-server") +## Pick a random high port to avoid conflicts in parallel CI runs. +## Range 49152-65535 is the dynamic/ephemeral port range (IANA). +static func _random_test_port() -> int: + return 49152 + (randi() % (65535 - 49152 + 1)) + + +## Spawn server with port rotation — if the port is in use, the server exits +## immediately (bind failure). Detect this and retry with a new random port. +func _spawn_server(server_path: String) -> bool: + for attempt in range(MAX_PORT_ATTEMPTS): + _test_port = _random_test_port() + var addr := "127.0.0.1:%d" % _test_port + _server_pid = OS.create_process(server_path, [addr]) + if _server_pid <= 0: + continue + # Give server time to bind or fail + await get_tree().create_timer(0.15).timeout + if OS.is_process_running(_server_pid): + return true + # Server exited — port likely in use, try another + _server_pid = -1 + return false + + func after_test() -> void: - # Clean up regardless of test outcome if _bridge != null: _bridge.disconnect_from_server() _bridge = null @@ -37,24 +60,22 @@ func test_send_input_receive_snapshot() -> void: push_warning("E2E test skipped: server binary not found at %s" % server_path) return - # Spawn server - var addr := "127.0.0.1:%d" % TEST_PORT - _server_pid = OS.create_process(server_path, [addr]) - assert_that(_server_pid).is_greater(0) + # Spawn server with port rotation (retries if port is in use) + var spawned := await _spawn_server(server_path) + assert_bool(spawned).is_true() - # Connect with retries (server needs time to bind port) + # Connect with retries (server needs time to accept) _bridge = LocalBridge.new() var connected := false var elapsed := 0.0 while elapsed < CONNECT_TIMEOUT: if _bridge.get_status() == StreamPeerTCP.STATUS_NONE: - _bridge.connect_to_server("127.0.0.1", TEST_PORT) + _bridge.connect_to_server("127.0.0.1", _test_port) _bridge.poll() if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED: connected = true break if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR: - # Reset and retry _bridge.disconnect_from_server() _bridge.reset() await get_tree().create_timer(0.1).timeout @@ -91,7 +112,7 @@ func test_send_input_receive_snapshot() -> void: assert_that(snapshot.entities.size()).is_equal(1) # Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0) - # Render coords: tile center offset → (16.5, 15.5, 0) + # Render coords: tile center offset -> (16.5, 15.5, 0) var player: Dictionary = snapshot.entities[0] assert_float(player.x).is_equal_approx(16.5, 0.001) assert_float(player.y).is_equal_approx(15.5, 0.001) From beee44b3c874d2e437ddf5429a58ceeae6647329 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 21:57:18 +0100 Subject: [PATCH 6/6] feat(client): add batch fixture, fixture smoke test, decode_errors test Tyre #1: Added Rust-generated input_batch_two.msgpack fixture for bidirectional D-030 Layer 1 symmetry (Vec). Tyre #2: Added all_fixtures_deserialize Rust test that reads every .msgpack fixture and verifies it deserializes (corruption guard). Hoshe #3: Added test_decode_snapshot_malformed_entities_counted test verifying the decode_errors counter on D-010 boundary violations. 45 client tests, 54 server tests pass. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/msgpack/input_batch_two.msgpack | Bin 0 -> 48 bytes client/tests/test_protocol.gd | 35 +++++++++++++++ server/tests/gen_fixtures.rs | 16 +++++++ server/tests/serialization.rs | 41 ++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 client/tests/fixtures/msgpack/input_batch_two.msgpack diff --git a/client/tests/fixtures/msgpack/input_batch_two.msgpack b/client/tests/fixtures/msgpack/input_batch_two.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..4bdca573b8b2326ccf52c6e841280426e995cabc GIT binary patch literal 48 qcmbQ#w4@|6Ih$cwVsc4le%?yo{IXQP{GyT!RPhy_c_pbuKs5jXAQY+q literal 0 HcmV?d00001 diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 0729afcfd..80004b695 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -184,6 +184,26 @@ func test_decode_snapshot_missing_fields() -> void: assert_that(result).is_null() +func test_decode_snapshot_malformed_entities_counted() -> void: + # Snapshot with one valid and one malformed entity — decode_errors should count the bad one + var raw := { + "tick": 7, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"}, + {"entity_id": 2, "broken": true}, # Missing required fields + {"x": 1.0}, # Missing entity_id, y, z, kind + ], + } + var encoded: Variant = Messagepack.encode(raw) + assert_that(encoded.status).is_null() + + var snapshot: Variant = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(7) + assert_that(snapshot.entities.size()).is_equal(1) # Only the valid entity + assert_that(snapshot.decode_errors).is_equal(2) # Two malformed entities + + func test_decode_player_input_empty_bytes() -> void: var result = Protocol.decode_player_input(PackedByteArray()) assert_that(result).is_null() @@ -251,6 +271,21 @@ func test_encode_player_inputs_empty() -> void: assert_that(raw.value.size()).is_equal(0) +# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ---------------- + +func test_decode_batch_input_fixture() -> void: + # Rust-generated Vec fixture — verifies bidirectional Layer 1 compatibility + var bytes = _load_fixture("input_batch_two") + var raw: Variant = Messagepack.decode(bytes) + assert_that(raw.status).is_null() + assert_that(raw.value is Array).is_true() + assert_that(raw.value.size()).is_equal(2) + assert_that(raw.value[0]["tick"]).is_equal(0) + assert_that(raw.value[0]["action"]).is_equal("MoveNorth") + assert_that(raw.value[1]["tick"]).is_equal(0) + assert_that(raw.value[1]["action"]).is_equal("Interact") + + # -- Diagonal movement fixtures (D-030 Layer 1 cross-language) ----------------- func test_decode_diagonal_fixtures() -> void: diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3562393cd..3914094dc 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -115,6 +115,22 @@ fn generate_msgpack_fixtures() { &rmp_serde::to_vec_named(&snapshot_multi).unwrap(), ); + // Batch input: Vec with two actions (D-030 Layer 1 bidirectional symmetry) + let input_batch = vec![ + PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }, + PlayerInput { + tick: 0, + action: PlayerAction::Interact, + }, + ]; + write_fixture( + "input_batch_two", + &rmp_serde::to_vec_named(&input_batch).unwrap(), + ); + // Diagonal movement fixtures (clockwise: NE, SE, SW, NW) for (name, action) in [ ("input_move_northeast", PlayerAction::MoveNortheast), diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index af60a0d75..3755c3d29 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1,6 +1,7 @@ //! IPC serialization round-trip tests (D-030 Layer 1: fixture-based). use settled_reach_server::bridge::types::*; +use std::fs; #[test] fn observer_snapshot_roundtrip() { @@ -82,6 +83,46 @@ fn all_player_action_variants_roundtrip() { } } +/// All .msgpack fixtures must deserialize without error (guards against corruption in git). +/// Snapshot fixtures deserialize as ObserverSnapshot, input_* as PlayerInput, +/// input_batch_* as Vec. +#[test] +fn all_fixtures_deserialize() { + let fixture_dir = std::path::Path::new("../client/tests/fixtures/msgpack"); + assert!( + fixture_dir.exists(), + "Fixture directory not found: {}", + fixture_dir.display() + ); + + let mut count = 0; + for entry in fs::read_dir(fixture_dir).expect("read fixture dir") { + let entry = entry.expect("read dir entry"); + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("msgpack") { + continue; + } + let name = path.file_stem().unwrap().to_str().unwrap().to_string(); + let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name)); + + if name.starts_with("snapshot") { + rmp_serde::from_slice::(&bytes) + .unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e)); + } else if name.starts_with("input_batch") { + rmp_serde::from_slice::>(&bytes) + .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); + } else if name.starts_with("input") { + rmp_serde::from_slice::(&bytes) + .unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e)); + } else { + panic!("unknown fixture naming convention: {}", name); + } + count += 1; + } + assert!(count > 0, "no fixtures found"); + eprintln!("Verified {} fixtures", count); +} + /// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1) #[test] fn all_entity_kind_variants_roundtrip() {