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: