diff --git a/CHANGELOG.md b/CHANGELOG.md index d31ea8f2f..909d8c361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - 8-directional movement — diagonal PlayerAction variants (MoveNortheast/Northwest/Southeast/Southwest) + TilePosition::all_neighbors() - Entity-entity collision in validate_movement — spatial occupancy check prevents multiple entities on same tile - 25 new tests (4 framing + 2 IPC integration + 17 movement unit + 2 movement integration), total 45 +- MessagePack serialization for GDScript (ticket #77) — Protocol codec decoding ObserverSnapshot/PlayerInput from Rust wire format, encoding PlayerInput for server +- Godot4MessagePack library (pure GDScript) for MessagePack encode/decode +- Rust fixture generator (gen_fixtures.rs) producing canonical .msgpack test fixtures with rmp_serde +- 8 cross-language protocol tests verifying Rust↔GDScript MessagePack compatibility (D-030 Layer 1) +- SimBridge wired to Protocol codec with receive_bytes()/drain_outbound() for transport layer - `db/connectors/ticket` CLI — ergonomic ticket management (list, show, create, assign, sprint, deps, search, epics, children, count) with JSON output - Sprint 1 "Run" created with 8 stories targeting moving character on screen via IPC bridge - Ticket skill rewritten to use ticket CLI instead of raw SQL wrapper scripts diff --git a/Makefile b/Makefile index 5daf2e895..d799989bc 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,9 @@ test: test-server test-client test-server: cd server && cargo nextest run +fixtures: + cd server && cargo test --test gen_fixtures -- --ignored + test-client: @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } $(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/ diff --git a/client/addons/messagepack/LICENSE b/client/addons/messagepack/LICENSE new file mode 100755 index 000000000..6929b7c84 --- /dev/null +++ b/client/addons/messagepack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Luis Chirlaque Hernández + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/client/addons/messagepack/messagepack.gd b/client/addons/messagepack/messagepack.gd new file mode 100755 index 000000000..3a7318db6 --- /dev/null +++ b/client/addons/messagepack/messagepack.gd @@ -0,0 +1,368 @@ +class_name Messagepack +## Messagepack implementation for Godot 4 in GDScript +## +## You can find the full spec at: https://github.com/msgpack/msgpack/blob/master/spec.md + +const FIRST3 = 0xe0 +const FIRST4 = 0xf0 +const LAST4 = 0x0f +const LAST5 = 0x1f + +const types = { + "nil": 0xc0, + "false": 0xc2, + "true": 0xc3, + "positive_fixint": [0x00, 0x7f], + "negative_fixint": [0xe0, 0xff], + "uint_8": 0xcc, + "uint_16": 0xcd, + "uint_32": 0xce, + "uint_64": 0xcf, + "int_8": 0xd0, + "int_16": 0xd1, + "int_32": 0xd2, + "int_64": 0xd3, + "float_32": 0xca, + "float_64": 0xcb, + "fixstr": [0xa0, 0xbf], + "str_8": 0xd9, + "str_16": 0xda, + "str_32": 0xdb, + "fixarray": [0x90, 0x9f], + "array_16": 0xdc, + "array_32": 0xdd, + "fixmap": [0x80, 0x8f], + "map_16": 0xde, + "map_32": 0xdf, + "bin_8": 0xc4, + "bin_16": 0xc5, + "bin_32": 0xc6 +} + +## This function takes a Variant and encodes it according to the Messagepack spec +## +## Parameters: +## - value: Variant to be encoded +## +## Returns: +## A dictionary containing the status of the encoding and the value as a PackedByteArray +static func encode(value) -> Dictionary: + var buffer = StreamPeerBuffer.new() + buffer.set_big_endian(true) + var err = _encode_message(buffer, value) + return { + value = buffer.data_array, + status = err + } + +static func _encode_message(buffer: StreamPeerBuffer, value): + match typeof(value): + TYPE_NIL: + buffer.put_u8(types["nil"]) + + TYPE_BOOL: + if value == true: + buffer.put_u8(types["true"]) + else: + buffer.put_u8(types["false"]) + + TYPE_INT: + if - (1 << 5) <= value and value <= (1 << 7) - 1: + buffer.put_8(value) + elif - (1 << 7) <= value and value <= (1 << 7): + buffer.put_u8(types["int_8"]) + buffer.put_8(value) + elif 0 <= value and value <= (1 << 8) - 1: + buffer.put_u8(types["uint_8"]) + buffer.put_u8(value) + elif - (1 << 15) <= value and value <= (1 << 15): + buffer.put_u8(types["int_16"]) + buffer.put_16(value) + elif 0 <= value and value <= (1 << 16) - 1: + buffer.put_u8(types["uint_16"]) + buffer.put_u16(value) + elif - (1 << 31) <= value and value <= (1 << 31): + buffer.put_u8(types["int_32"]) + buffer.put_32(value) + elif 0 <= value and value <= (1 << 32) - 1: + buffer.put_u8(types["uint_32"]) + buffer.put_u32(value) + elif - (1 << 63) <= value and value <= (1 << 63): + buffer.put_u8(types["int_64"]) + buffer.put_64(value) + else: + buffer.put_u8(types["uint_64"]) + buffer.put_u64(value) + + TYPE_FLOAT: + buffer.put_u8(types["float_32"]) + buffer.put_float(value) + + TYPE_STRING: + var bytes = value.to_utf8_buffer() + var size = bytes.size() + if size <= (1 << 5) - 1: + buffer.put_u8(types["fixstr"][0]|size) + elif size <= (1 << 8) - 1: + buffer.put_u8(types["str_8"]) + buffer.put_u8(size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["str_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["str_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported string: string is too big") + return ERR_INVALID_DATA + + buffer.put_data(bytes) + + TYPE_ARRAY: + var size = value.size() + if size <= 15: + buffer.put_u8(types["fixarray"][0]|size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["array_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["array_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported array: array is too long") + return ERR_INVALID_DATA + + for obj in value: + _encode_message(buffer, obj) + + TYPE_DICTIONARY: + var size = value.size() + if size <= 15: + buffer.put_u8(types["fixmap"][0]|size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["map_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["map_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported dictionary: dictionary is too big") + return ERR_INVALID_DATA + + for key in value: + _encode_message(buffer, key) + _encode_message(buffer, value[key]) + + TYPE_PACKED_BYTE_ARRAY: + var size = value.size() + if size <= (1 << 8) - 1: + buffer.put_u8(types["bin_8"]) + buffer.put_u8(size) + elif size <= (1 << 16) - 1: + buffer.put_u8(types["bin_16"]) + buffer.put_u16(size) + elif size <= (1 << 32) - 1: + buffer.put_u8(types["bin_32"]) + buffer.put_u32(size) + else: + printerr("Unsupported packed byte array: packed byte array is too big") + return ERR_INVALID_DATA + + buffer.put_data(value) + + _: + printerr("Unsupported data type: %s" % typeof(value)) + return ERR_UNAVAILABLE + + +## This function takes a PackedByteArray and decodes it according to the Messagepack spec +## +## Parameters: +## - bytes: PackedByteArray to be decoded +## +## Returns: +## A dictionary containing the status of the decoding and the value as Godot Variants +static func decode(bytes: PackedByteArray): + var buffer = StreamPeerBuffer.new() + buffer.set_big_endian(true) + buffer.set_data_array(bytes) + + var err = { + error = null + } + var message = _decode_message(buffer, err) + return { + value = message, + status = err.error + } + +static func _decode_message(buffer: StreamPeerBuffer, err: Dictionary): + var buffer_size = buffer.get_size() + var first_byte = buffer.get_u8() + + if first_byte & 0x80 == 0: # positive fixint + return first_byte + + elif first_byte & FIRST4 == 0x80: # fixmap + var size = first_byte & 0x0f + var dict = {} + for _x in range(size): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte & FIRST4 == 0x90: # fixarray + var size = first_byte & 0x0f + var array = [] + for _x in range(size): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte & FIRST3 == 0xa0: # fixstr + var size = first_byte & 0x1f + return buffer.get_utf8_string(size) + + elif first_byte == types["nil"]: # nil + print("null size:%s"%buffer_size) + return null + + elif first_byte == types["false"]: # false + print("false size:%s"%buffer_size) + return false + + elif first_byte == types["true"]: # true + return true + + elif first_byte == types["bin_8"]: # bin 8 + var length = buffer.get_u8() + return buffer.get_partial_data(length) + + elif first_byte == types["bin_16"]: # bin 16 + var length = buffer.get_u16() + return buffer.get_partial_data(length) + + elif first_byte == types["bin_32"]: # bin 32 + var length = buffer.get_u32() + return buffer.get_partial_data(length) + + elif first_byte == 0xc7: # ext 8 + print("Ext 8 type not implemented") + return null + + elif first_byte == 0xc8: # ext 16 + print("Ext 16 type not implemented") + return null + + elif first_byte == 0xc9: # ext 32 + print("Ext 32 type not implemented") + return null + + elif first_byte == types["float_32"]: # float 32 + return buffer.get_float() + + elif first_byte == types["float_64"]: # float 64 + return buffer.get_double() + + elif first_byte == types["uint_8"]: # uint 8 + return buffer.get_u8() + + elif first_byte == types["uint_16"]: # uint 16 + return buffer.get_u16() + + elif first_byte == types["uint_32"]: # uint 32 + return buffer.get_u32() + + elif first_byte == types["uint_64"]: # uint 64 + return buffer.get_u64() + + elif first_byte == types["int_8"]: # int 8 + return buffer.get_8() + + elif first_byte == types["int_16"]: # int 16 + return buffer.get_16() + + elif first_byte == types["int_32"]: # int 32 + return buffer.get_32() + + elif first_byte == types["int_64"]: # int 64 + return buffer.get_64() + + elif first_byte == 0xd4: # fixext 1 + print("Fixext 1 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd5: # fixext 2 + print("Fixext 2 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd6: # fixext 4 + print("Fixext 4 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd7: # fixext 8 + print("Fixext 8 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == 0xd8: # fixext 16 + print("Fixext 16 type not implemented") + err.error = ERR_UNAVAILABLE + return null + + elif first_byte == types["str_8"]: # str 8 + var size = buffer.get_u8() + return buffer.get_utf8_string(size) + + elif first_byte == types["str_16"]: # str 16 + var size = buffer.get_u16() + return buffer.get_utf8_string(size) + + elif first_byte == types["str_32"]: # str 32 + var size = buffer.get_u32() + return buffer.get_utf8_string(size) + + elif first_byte == types["array_16"]: # array 16 + var length = buffer.get_u16() + var array = [] + for _x in range(length): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte == types["array_32"]: # array 32 + var length = buffer.get_u32() + var array = [] + for _x in range(length): + var val = _decode_message(buffer, err) + array.append(val) + return array + + elif first_byte == types["map_16"]: # map 16 + var length = buffer.get_u16() + var dict = {} + for _x in range(length): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte == types["map_32"]: # map 32 + var length = buffer.get_u32() + var dict = {} + for _x in range(length): + var key = _decode_message(buffer, err) + var val = _decode_message(buffer, err) + dict[key] = val + return dict + + elif first_byte & FIRST3 == 0xe0: # negative fixint + return first_byte - 256 + else: + printerr("Unknown header") + err.error = ERR_UNAVAILABLE + return null diff --git a/client/addons/messagepack/messagepack.gd.uid b/client/addons/messagepack/messagepack.gd.uid new file mode 100644 index 000000000..f2186b8f0 --- /dev/null +++ b/client/addons/messagepack/messagepack.gd.uid @@ -0,0 +1 @@ +uid://komeseatyar0 diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index badb8800a..ad08a89ad 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -1,31 +1,26 @@ extends Node -# Updated each frame from ObserverSnapshot data +# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities}). +# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. var current_snapshot: Dictionary = {} +var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] -var fog_state: Dictionary = {} -var hud_data: Dictionary = {} + +# Player entity ID — the first entity is assumed to be the player (will be +# refined when the server assigns explicit player entity IDs). +var player_entity_id: int = 1 func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot - # Parse player data - if snapshot.has("player") and snapshot.player.has("position"): - var pos = snapshot.player.position - if pos is Array and pos.size() >= 2: - player_position = Vector2(pos[0], pos[1]) - else: - push_warning("GameState: malformed player position in snapshot") + if snapshot.has("tick"): + current_tick = snapshot.tick - # Parse entities if snapshot.has("entities"): visible_entities = snapshot.entities - - # Parse fog state - if snapshot.has("fog"): - fog_state = snapshot.fog - - # Parse HUD data - if snapshot.has("hud"): - hud_data = snapshot.hud + # Derive player position from the player entity + for entity in visible_entities: + if entity.has("entity_id") and entity.entity_id == player_entity_id: + player_position = Vector2(entity.x, entity.y) + break diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index d3dca48f5..aa3794948 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -6,6 +6,8 @@ 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[PackedByteArray] = [] # Encoded inputs awaiting transport # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) @@ -35,14 +37,26 @@ func connect_to_sim() -> void: func disconnect_from_sim() -> void: _set_state(ConnectionState.DISCONNECTED) -# Send input to simulation (real implementation comes later) +# 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. Transport (ticket #79) will send the bytes. func send_input(player_input: Dictionary) -> void: if state != ConnectionState.CONNECTED: return - # TODO: Serialize and send via MessagePack when IPC is implemented - pass + if test_mode: + return + var action_name := _action_enum_to_wire(player_input.get("action", -1)) + if action_name.is_empty(): + return + 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 + _outbound_buffer.append(encoded) -# Poll for snapshot from simulation +# 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 @@ -52,31 +66,62 @@ func poll_snapshot() -> Variant: snapshot_received.emit(snapshot) return snapshot - # TODO: Actual polling logic when IPC/MessagePack is implemented + if _last_snapshot != null: + var snapshot = _last_snapshot + _last_snapshot = null + snapshot_received.emit(snapshot) + return snapshot + return null -# Hardcoded test snapshot for development (deterministic per D-010 principle 4) +# Called by transport layer (ticket #79) 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. Called by transport layer (ticket #79) to get encoded messages. +func drain_outbound() -> Array[PackedByteArray]: + var messages = _outbound_buffer.duplicate() + _outbound_buffer.clear() + return messages + +# 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_SOUTH: return "MoveSouth" + InputMapper.Action.MOVE_EAST: return "MoveEast" + InputMapper.Action.MOVE_WEST: return "MoveWest" + 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} schema as Protocol.decode_snapshot() returns. func _test_snapshot() -> Dictionary: _test_tick += 1 return { "tick": _test_tick, - "player": { - "position": [10, 10], - "health": 100 - }, "entities": [ { - "id": 1, - "type": "npc", - "position": [12, 8], - "name": "Test NPC" + "entity_id": 1, + "x": 10.0, + "y": 10.0, + "z": 0, + "kind": { "variant": "Npc", "data": null }, }, ], - "fog": { - "radius": 8 - }, - "hud": { - "perception_mode": "baseline", - "time": "08:00" - } } diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 31f803d95..d23871ed1 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -24,16 +24,6 @@ func _process(_delta: float) -> void: # Track camera to player position (D-015) camera.position = GameState.player_position * 32 # tile-space to pixel-space - # Update HUD - if hud and hud.has_method("update_from_hud_data"): - hud.update_from_hud_data(GameState.hud_data) - if snapshot.has("player") and snapshot.player.has("health"): - hud.update_health(snapshot.player.health) - - # Wire monologue display (D-016 perception data path) - if snapshot.has("monologue") and monologue_display: - monologue_display.show_monologue(snapshot.monologue) - # Send queued input to simulation var inputs = InputMapper.flush_queue() for input in inputs: diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd new file mode 100644 index 000000000..be31cece7 --- /dev/null +++ b/client/scripts/protocol/protocol.gd @@ -0,0 +1,127 @@ +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] = [] + var raw_entities: Array = raw["entities"] + var dropped := 0 + for raw_entity in raw_entities: + var entity = _decode_entity(raw_entity) + if entity != null: + entities.append(entity) + else: + dropped += 1 + + if dropped > 0: + push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()]) + + # GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63 + # in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031). + var tick: int = raw["tick"] + return { + "tick": tick, + "entities": entities, + "decode_errors": dropped, + } + + +## 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 + + var entity_id: int = raw["entity_id"] + return { + "entity_id": 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 + + var tick: int = raw["tick"] + return { + "tick": tick, + "action": _decode_enum_variant(raw["action"]), + } diff --git a/client/scripts/protocol/protocol.gd.uid b/client/scripts/protocol/protocol.gd.uid new file mode 100644 index 000000000..f4c3493eb --- /dev/null +++ b/client/scripts/protocol/protocol.gd.uid @@ -0,0 +1 @@ +uid://fkmd537xvwxe diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index 419090e40..12155ead8 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -15,6 +15,6 @@ func update_from_state() -> void: if entity_renderer and entity_renderer.has_method("update_entities"): entity_renderer.update_entities(GameState.visible_entities) - # Update fog overlay + # Update fog overlay (fog data will come in D-020 expansion) if fog_renderer and fog_renderer.has_method("update_fog"): - fog_renderer.update_fog(GameState.fog_state, GameState.player_position) + fog_renderer.update_fog({}, GameState.player_position) diff --git a/client/tests/fixtures/msgpack/input_move_north.msgpack b/client/tests/fixtures/msgpack/input_move_north.msgpack new file mode 100644 index 000000000..937f385e1 --- /dev/null +++ b/client/tests/fixtures/msgpack/input_move_north.msgpack @@ -0,0 +1 @@ +tickdactionMoveNorth \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/input_perception_mode.msgpack b/client/tests/fixtures/msgpack/input_perception_mode.msgpack new file mode 100644 index 000000000..3f922c7e3 --- /dev/null +++ b/client/tests/fixtures/msgpack/input_perception_mode.msgpack @@ -0,0 +1 @@ +tickȦactionUsePerceptionModethermal \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack new file mode 100644 index 000000000..5435b2917 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack new file mode 100644 index 000000000..2c0217a45 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack new file mode 100644 index 000000000..f98303206 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd new file mode 100644 index 000000000..e574bf318 --- /dev/null +++ b/client/tests/test_protocol.gd @@ -0,0 +1,169 @@ +## 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) + + +# -- Negative tests: malformed/truncated input ----------------------------------- + +func test_decode_snapshot_truncated_bytes() -> void: + var truncated := PackedByteArray([0x82, 0xa4]) # Incomplete msgpack map + var result = Protocol.decode_snapshot(truncated) + assert_that(result).is_null() + + +func test_decode_snapshot_wrong_type() -> void: + # Encode an array instead of a map — should fail validation + var encoded = Messagepack.encode([1, 2, 3]) + var result = Protocol.decode_snapshot(encoded.value) + assert_that(result).is_null() + + +func test_decode_snapshot_missing_fields() -> void: + # Map with wrong keys + var encoded = Messagepack.encode({"foo": "bar"}) + var result = Protocol.decode_snapshot(encoded.value) + assert_that(result).is_null() + + +func test_decode_player_input_empty_bytes() -> void: + var result = Protocol.decode_player_input(PackedByteArray()) + assert_that(result).is_null() + + +func test_encode_produces_nonempty_bytes() -> void: + var bytes = Protocol.encode_player_input(1, "MoveNorth") + assert_that(bytes.size()).is_greater(0) 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 b/client/tests/test_snapshot_parsing.gd index dcc5c49d5..af025e14c 100644 --- a/client/tests/test_snapshot_parsing.gd +++ b/client/tests/test_snapshot_parsing.gd @@ -1,30 +1,24 @@ ## D-030 Layer 1: Fixture-based tests for snapshot parsing -## Validates that GameState correctly parses ObserverSnapshot data +## Validates that GameState correctly parses Protocol-format ObserverSnapshot data class_name TestSnapshotParsing extends GdUnitTestSuite -# Valid snapshot fixture +# Valid snapshot in Protocol format (matches Protocol.decode_snapshot output) var _valid_snapshot: Dictionary = { "tick": 1, - "player": { - "position": [10, 15], - "health": 85 - }, "entities": [ - {"id": 1, "type": "npc", "position": [12, 8], "name": "Test NPC"}, - {"id": 2, "type": "npc", "position": [5, 20], "name": "Second NPC"}, + {"entity_id": 1, "x": 10.0, "y": 15.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, + {"entity_id": 2, "x": 5.0, "y": 20.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ], - "fog": {"radius": 8}, - "hud": {"perception_mode": "baseline", "time": "14:30"} } func test_apply_valid_snapshot() -> void: + GameState.player_entity_id = 1 GameState.apply_snapshot(_valid_snapshot) + assert_that(GameState.current_tick).is_equal(1) assert_that(GameState.player_position).is_equal(Vector2(10, 15)) assert_that(GameState.visible_entities.size()).is_equal(2) - assert_that(GameState.fog_state).is_equal({"radius": 8}) - assert_that(GameState.hud_data).is_equal({"perception_mode": "baseline", "time": "14:30"}) func test_empty_snapshot_no_crash() -> void: # Reset state @@ -37,26 +31,25 @@ func test_empty_snapshot_no_crash() -> void: assert_that(GameState.player_position).is_equal(Vector2.ZERO) assert_that(GameState.visible_entities.size()).is_equal(0) -func test_malformed_position_no_crash() -> void: - var bad_snapshot: Dictionary = { - "player": {"position": "not_an_array"}, - } - # Reset - GameState.player_position = Vector2.ZERO +func test_no_player_entity_position_unchanged() -> void: + GameState.player_entity_id = 999 # No entity with this ID + GameState.player_position = Vector2(5, 5) - # Should not crash — logs warning instead - GameState.apply_snapshot(bad_snapshot) - assert_that(GameState.player_position).is_equal(Vector2.ZERO) + GameState.apply_snapshot(_valid_snapshot) + + # Position stays at previous value since no matching entity + assert_that(GameState.player_position).is_equal(Vector2(5, 5)) func test_missing_fields_partial_update() -> void: # First apply valid snapshot + GameState.player_entity_id = 1 GameState.apply_snapshot(_valid_snapshot) assert_that(GameState.player_position).is_equal(Vector2(10, 15)) - # Apply snapshot with only HUD data — player position unchanged - GameState.apply_snapshot({"hud": {"perception_mode": "thermal", "time": "22:00"}}) + # Apply snapshot with no entities — player position unchanged (no matching entity) + GameState.apply_snapshot({"tick": 2}) assert_that(GameState.player_position).is_equal(Vector2(10, 15)) - assert_that(GameState.hud_data.perception_mode).is_equal("thermal") + assert_that(GameState.current_tick).is_equal(2) func test_sim_bridge_test_snapshot_deterministic() -> void: SimBridge._test_tick = 0 @@ -65,8 +58,9 @@ func test_sim_bridge_test_snapshot_deterministic() -> void: assert_that(snap1.tick).is_equal(1) assert_that(snap2.tick).is_equal(2) - # Snapshot structure is stable - assert_that(snap1.has("player")).is_true() + # Snapshot matches Protocol format + assert_that(snap1.has("tick")).is_true() assert_that(snap1.has("entities")).is_true() - assert_that(snap1.has("fog")).is_true() - assert_that(snap1.has("hud")).is_true() + assert_that(snap1.entities.size()).is_greater(0) + assert_that(snap1.entities[0].has("entity_id")).is_true() + assert_that(snap1.entities[0].has("kind")).is_true() 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 diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs new file mode 100644 index 000000000..4cd63aff2 --- /dev/null +++ b/server/tests/gen_fixtures.rs @@ -0,0 +1,64 @@ +//! Generate MessagePack fixture files for cross-language testing (D-030 Layer 1). +//! Run with: cargo test --test gen_fixtures -- --ignored + +use settled_reach_server::bridge::types::*; +use std::fs; +use std::path::Path; + +fn write_fixture(name: &str, bytes: &[u8]) { + // Write directly into the Godot project's test fixtures (single source of truth) + let dir = Path::new("../client/tests/fixtures/msgpack"); + fs::create_dir_all(dir).expect("create fixture dir"); + let path = dir.join(format!("{}.msgpack", name)); + fs::write(&path, bytes).expect("write fixture"); + eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len()); +} + +#[test] +#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored +fn generate_msgpack_fixtures() { + // Snapshot with one NPC entity + let snapshot = ObserverSnapshot { + tick: 42, + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.0, + y: 20.0, + z: 0, + kind: EntityKind::Npc, + }], + }; + write_fixture("snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap()); + + // Empty snapshot + let empty = ObserverSnapshot { + tick: 0, + entities: vec![], + }; + write_fixture("snapshot_empty", &rmp_serde::to_vec_named(&empty).unwrap()); + + // PlayerInput: MoveNorth + let input_north = PlayerInput { + tick: 100, + action: PlayerAction::MoveNorth, + }; + write_fixture("input_move_north", &rmp_serde::to_vec_named(&input_north).unwrap()); + + // PlayerInput: UsePerceptionMode + let input_perception = PlayerInput { + tick: 200, + action: PlayerAction::UsePerceptionMode("thermal".to_string()), + }; + write_fixture("input_perception_mode", &rmp_serde::to_vec_named(&input_perception).unwrap()); + + // Snapshot with multiple entities and all EntityKind variants + let snapshot_multi = ObserverSnapshot { + tick: 999, + entities: vec![ + VisibleEntity { entity_id: 1, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc }, + VisibleEntity { entity_id: 2, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object }, + VisibleEntity { entity_id: 3, x: 0.0, y: 0.0, z: -1, kind: EntityKind::Terrain }, + ], + }; + write_fixture("snapshot_multi_entity", &rmp_serde::to_vec_named(&snapshot_multi).unwrap()); +}