diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd new file mode 100644 index 000000000..3f86e733f --- /dev/null +++ b/client/scripts/protocol/protocol.gd @@ -0,0 +1,114 @@ +class_name Protocol +## MessagePack codec for the Rust↔Godot wire protocol (D-020). +## +## Encodes/decodes ObserverSnapshot and PlayerInput to match +## rmp_serde's named-field encoding of server/src/bridge/types.rs. +## +## Wire format notes (rmp_serde with to_vec_named): +## Structs → msgpack maps with string keys +## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc") +## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"}) + + +# -- Decode: bytes from server → GDScript types -------------------------------- + +## Decode an ObserverSnapshot from MessagePack bytes. +## Returns { "tick": int, "entities": Array[Dictionary] } or null on error. +static func decode_snapshot(bytes: PackedByteArray) -> Variant: + var result = Messagepack.decode(bytes) + if result.status != null: + push_error("Protocol: msgpack decode failed: %s" % result.status) + return null + + var raw = result.value + if not raw is Dictionary or not raw.has("tick") or not raw.has("entities"): + push_error("Protocol: snapshot missing required fields") + return null + + var entities: Array[Dictionary] = [] + for raw_entity in raw["entities"]: + var entity = _decode_entity(raw_entity) + if entity != null: + entities.append(entity) + + return { + "tick": int(raw["tick"]), + "entities": entities, + } + + +## Decode a single VisibleEntity from a raw msgpack map. +static func _decode_entity(raw: Dictionary) -> Variant: + if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \ + or not raw.has("z") or not raw.has("kind"): + push_warning("Protocol: entity missing required fields: %s" % str(raw.keys())) + return null + + return { + "entity_id": int(raw["entity_id"]), + "x": float(raw["x"]), + "y": float(raw["y"]), + "z": int(raw["z"]), + "kind": _decode_enum_variant(raw["kind"]), + } + + +## Decode an enum variant from rmp_serde's encoding. +## Unit variants are bare strings, data variants are single-element maps. +## Returns { "variant": String, "data": Variant } in both cases. +static func _decode_enum_variant(raw) -> Dictionary: + if raw is String: + return { "variant": raw, "data": null } + elif raw is Dictionary and raw.size() == 1: + var variant_name: String = raw.keys()[0] + return { "variant": variant_name, "data": raw[variant_name] } + else: + push_warning("Protocol: unexpected enum encoding: %s" % str(raw)) + return { "variant": "Unknown", "data": raw } + + +# -- Encode: GDScript types → bytes to server ---------------------------------- + +## Encode a PlayerInput to MessagePack bytes. +## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest", +## "Interact", "UsePerceptionMode", "Pause", "Unpause" +## action_data: null for unit variants, String for UsePerceptionMode +static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray: + var action_value: Variant + if action_data != null: + # Data variant → single-element map + action_value = { action_name: action_data } + else: + # Unit variant → bare string + action_value = action_name + + var input := { + "tick": tick, + "action": action_value, + } + + var result = Messagepack.encode(input) + 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: + var result = Messagepack.decode(bytes) + if result.status != null: + push_error("Protocol: msgpack decode failed: %s" % result.status) + return null + + var raw = result.value + if not raw is Dictionary or not raw.has("tick") or not raw.has("action"): + push_error("Protocol: player_input missing required fields") + return null + + return { + "tick": int(raw["tick"]), + "action": _decode_enum_variant(raw["action"]), + } diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd new file mode 100644 index 000000000..b9e396b21 --- /dev/null +++ b/client/tests/test_protocol.gd @@ -0,0 +1,137 @@ +## D-030 Layer 1: Cross-language MessagePack tests +## Validates that Protocol.gd correctly decodes fixtures generated by Rust (rmp_serde). +## Fixtures generated by: cargo test --test gen_fixtures -- --ignored +class_name TestProtocol +extends GdUnitTestSuite + +const FIXTURE_DIR = "res://tests/fixtures/msgpack/" + + +func _load_fixture(name: String) -> PackedByteArray: + var path = FIXTURE_DIR + name + ".msgpack" + var file = FileAccess.open(path, FileAccess.READ) + assert_that(file).is_not_null() + return file.get_buffer(file.get_length()) + + +# -- Snapshot decoding ---------------------------------------------------------- + +func test_decode_snapshot_one_npc() -> void: + var bytes = _load_fixture("snapshot_one_npc") + var snapshot = Protocol.decode_snapshot(bytes) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(42) + assert_that(snapshot.entities.size()).is_equal(1) + + var entity = snapshot.entities[0] + assert_that(entity.entity_id).is_equal(1) + assert_float(entity.x).is_equal_approx(10.0, 0.001) + assert_float(entity.y).is_equal_approx(20.0, 0.001) + assert_that(entity.z).is_equal(0) + assert_that(entity.kind.variant).is_equal("Npc") + assert_that(entity.kind.data).is_null() + + +func test_decode_snapshot_empty() -> void: + var bytes = _load_fixture("snapshot_empty") + var snapshot = Protocol.decode_snapshot(bytes) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(0) + assert_that(snapshot.entities.size()).is_equal(0) + + +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) + + # NPC at (5, 10, 0) + var npc = snapshot.entities[0] + assert_that(npc.entity_id).is_equal(1) + 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) + 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) + 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) + assert_that(terrain.kind.variant).is_equal("Terrain") + + +# -- PlayerInput decoding ------------------------------------------------------- + +func test_decode_input_move_north() -> void: + var bytes = _load_fixture("input_move_north") + var input = Protocol.decode_player_input(bytes) + + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(100) + assert_that(input.action.variant).is_equal("MoveNorth") + assert_that(input.action.data).is_null() + + +func test_decode_input_perception_mode() -> void: + var bytes = _load_fixture("input_perception_mode") + var input = Protocol.decode_player_input(bytes) + + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(200) + assert_that(input.action.variant).is_equal("UsePerceptionMode") + assert_that(input.action.data).is_equal("thermal") + + +# -- PlayerInput encoding ------------------------------------------------------- + +func test_encode_decode_roundtrip_unit_variant() -> void: + var bytes = Protocol.encode_player_input(50, "MoveEast") + assert_that(bytes.size()).is_greater(0) + + var decoded = Protocol.decode_player_input(bytes) + assert_that(decoded).is_not_null() + assert_that(decoded.tick).is_equal(50) + assert_that(decoded.action.variant).is_equal("MoveEast") + assert_that(decoded.action.data).is_null() + + +func test_encode_decode_roundtrip_data_variant() -> void: + var bytes = Protocol.encode_player_input(75, "UsePerceptionMode", "infrared") + assert_that(bytes.size()).is_greater(0) + + var decoded = Protocol.decode_player_input(bytes) + assert_that(decoded).is_not_null() + assert_that(decoded.tick).is_equal(75) + assert_that(decoded.action.variant).is_equal("UsePerceptionMode") + assert_that(decoded.action.data).is_equal("infrared") + + +# -- Cross-language roundtrip: GDScript encode matches Rust decode --------------- + +func test_gdscript_encode_matches_rust_fixture() -> void: + # Encode the same MoveNorth input as the Rust fixture + var bytes = Protocol.encode_player_input(100, "MoveNorth") + + # Decode and verify the content matches the Rust fixture + var rust_bytes = _load_fixture("input_move_north") + var from_gd = Protocol.decode_player_input(bytes) + var from_rust = Protocol.decode_player_input(rust_bytes) + + assert_that(from_gd.tick).is_equal(from_rust.tick) + assert_that(from_gd.action.variant).is_equal(from_rust.action.variant) + assert_that(from_gd.action.data).is_equal(from_rust.action.data) diff --git a/client/tests/test_protocol.gd.uid b/client/tests/test_protocol.gd.uid new file mode 100644 index 000000000..42e5f4aea --- /dev/null +++ b/client/tests/test_protocol.gd.uid @@ -0,0 +1 @@ +uid://cj0et712osytn diff --git a/client/tests/test_snapshot_parsing.gd.uid b/client/tests/test_snapshot_parsing.gd.uid new file mode 100644 index 000000000..50d26428c --- /dev/null +++ b/client/tests/test_snapshot_parsing.gd.uid @@ -0,0 +1 @@ +uid://ct5bcdvoyo65p