From 24cce4137923bf57a2bb8aaf21d59a660e160853 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 15 Feb 2026 23:05:47 +0100 Subject: [PATCH] =?UTF-8?q?feat(client):=20protocol=20v6=20bridge=20?= =?UTF-8?q?=E2=80=94=20stance,=20inventory,=20input=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade client protocol bridge from v5 to v6 to match server. Adds player_stance (4 variants) and player_inventory decode to ObserverSnapshot. Adds TOGGLE_STANCE_UP/DOWN to InputMapper. Includes 25 gdUnit4 tests for v6 decode + server serialization test gap fix. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 14 + client/scripts/autoloads/input_mapper.gd | 7 +- client/scripts/autoloads/sim_bridge.gd | 4 + client/scripts/protocol/protocol.gd | 22 +- client/tests/test_protocol.gd | 4 +- client/tests/test_protocol_v6.gd | 394 +++++++++++++++++++++++ server/tests/serialization.rs | 3 + 7 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 client/tests/test_protocol_v6.gd diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 62333e36b..a453c7030 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -25,6 +25,10 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs # v5 fields (#414) var current_monologue: Variant = null # {id, text, duration_seconds} or null +# v6 fields (#449, D-053, D-065) +var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch +var player_inventory: Array = [] # [{item_id, name, slot}] + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -82,6 +86,16 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: current_monologue = null + # v6: player_stance (#449, D-053) + if snapshot.has("player_stance") and snapshot.player_stance is String: + player_stance = snapshot.player_stance + + # v6: player_inventory (#449, D-065) + if snapshot.has("player_inventory") and snapshot.player_inventory is Array: + player_inventory = snapshot.player_inventory + else: + player_inventory = [] + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 6ec767791..c02e0e19e 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -6,7 +6,8 @@ extends Node enum Action { MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST, MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST, - INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE + INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, + TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, } var input_queue: Array[Dictionary] = [] @@ -40,6 +41,10 @@ func _unhandled_input(event: InputEvent) -> void: action = Action.OPEN_MENU elif event.is_action_pressed("pause"): action = Action.PAUSE + elif event.is_action_pressed("stance_up"): + action = Action.TOGGLE_STANCE_UP + elif event.is_action_pressed("stance_down"): + action = Action.TOGGLE_STANCE_DOWN # Queue the action if valid if action != -1: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 2e6d885b5..ac450d448 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -242,6 +242,8 @@ static func _action_enum_to_wire(action: int) -> String: InputMapper.Action.INTERACT: return "Interact" InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode" InputMapper.Action.PAUSE: return "Pause" + InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" + InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" InputMapper.Action.OPEN_MENU: # Client-only action, not part of wire protocol push_warning("SimBridge: OPEN_MENU is client-only, not sent to server") @@ -328,6 +330,8 @@ func _test_snapshot() -> Dictionary: "tick_rate": "Full", }, "player_facing": _test_facing, + "player_stance": "Walk", + "player_inventory": [], "entities": entities, "tiles": _test_tiles(), "visible_tiles": _test_visible_tiles(), diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index dc2be42d4..ffeb5a0aa 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 5 +const PROTOCOL_VERSION: int = 6 # -- Decode: bytes from server → GDScript types -------------------------------- @@ -104,6 +104,24 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)), } + # v6: player_stance (#449, D-053) — unit enum → bare string + var player_stance: String = "Walk" + var raw_stance: Variant = raw.get("player_stance") + if raw_stance is String: + player_stance = raw_stance + + # v6: player_inventory (#449, D-065) — array of {item_id, name, slot} + var player_inventory: Array = [] + var raw_inventory: Variant = raw.get("player_inventory") + if raw_inventory is Array: + for raw_item in raw_inventory: + if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"): + player_inventory.append({ + "item_id": int(raw_item["item_id"]), + "name": str(raw_item["name"]), + "slot": int(raw_item.get("slot", 0)), + }) + return { "tick": tick, "entities": entities, @@ -111,6 +129,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "version": version, "game_time": game_time, "player_facing": player_facing, + "player_stance": player_stance, + "player_inventory": player_inventory, "visible_tiles": visible_tiles, "nearby_interactions": nearby_interactions, "current_monologue": current_monologue, diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 6ac1db5ed..ea39cf760 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -280,7 +280,7 @@ func test_decode_snapshot_v2_full() -> void: assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(500) - assert_that(snapshot.version).is_equal(4) + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) # game_time assert_that(snapshot.game_time).is_not_null() @@ -307,7 +307,7 @@ func test_existing_fixtures_have_v2_fields() -> void: var bytes = _load_fixture(fixture_name) var snapshot = Protocol.decode_snapshot(bytes) assert_that(snapshot).is_not_null() - assert_that(snapshot.version).is_equal(4) + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() diff --git a/client/tests/test_protocol_v6.gd b/client/tests/test_protocol_v6.gd new file mode 100644 index 000000000..92d00b20b --- /dev/null +++ b/client/tests/test_protocol_v6.gd @@ -0,0 +1,394 @@ +## D-030 Layer 1: Protocol v6 tests for ObserverSnapshot bridge upgrade (#449). +## Validates player_stance (D-053) and player_inventory (D-065) decode, +## GameState storage, SimBridge test mode, and input encoding for stance toggles. +## Spec refs: D-053, D-065, D-020, #449 +class_name TestProtocolV6 +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()) + + +# -- Protocol version upgrade ------------------------------------------------- + +func test_protocol_version_is_6() -> void: + assert_that(Protocol.PROTOCOL_VERSION).is_equal(6) + + +func test_fixtures_at_protocol_version_6() -> void: + # All regenerated fixtures should be at v6 + for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]: + var bytes = _load_fixture(fixture_name) + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.version).is_equal(6) + + +func test_rejects_version_5() -> void: + var raw := {"tick": 1, "version": 5, "entities": []} + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_null() + + +# -- player_stance decode (D-053) --------------------------------------------- + +func test_decode_player_stance_walk() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Walk", + "player_inventory": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.player_stance).is_equal("Walk") + + +func test_decode_player_stance_sprint() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Sprint", + "player_inventory": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_stance).is_equal("Sprint") + + +func test_decode_player_stance_careful() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Careful", + "player_inventory": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_stance).is_equal("Careful") + + +func test_decode_player_stance_crouch() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Crouch", + "player_inventory": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_stance).is_equal("Crouch") + + +func test_decode_player_stance_missing_defaults_to_walk() -> void: + # v6 snapshot without player_stance → should default to "Walk" + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.player_stance).is_equal("Walk") + + +# -- player_inventory decode (D-065) ------------------------------------------ + +func test_decode_empty_inventory() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Walk", + "player_inventory": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_inventory.size()).is_equal(0) + + +func test_decode_smuggler_inventory_3_items() -> void: + # D-065: smuggler carries 3 specific items + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_stance": "Walk", + "player_inventory": [ + {"item_id": 100, "name": "Manifest Copy", "slot": 0}, + {"item_id": 101, "name": "Access Token", "slot": 1}, + {"item_id": 102, "name": "Comm Log", "slot": 2}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + + assert_that(snapshot.player_inventory.size()).is_equal(3) + assert_that(snapshot.player_inventory[0].item_id).is_equal(100) + assert_that(snapshot.player_inventory[0].name).is_equal("Manifest Copy") + assert_that(snapshot.player_inventory[0].slot).is_equal(0) + assert_that(snapshot.player_inventory[1].name).is_equal("Access Token") + assert_that(snapshot.player_inventory[1].slot).is_equal(1) + assert_that(snapshot.player_inventory[2].name).is_equal("Comm Log") + assert_that(snapshot.player_inventory[2].slot).is_equal(2) + + +func test_decode_full_9_slot_inventory() -> void: + # D-065: 3x3 grid = 9 slots universal + var items: Array = [] + for i in range(9): + items.append({"item_id": 100 + i, "name": "Item %d" % i, "slot": i}) + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_inventory": items, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + + assert_that(snapshot.player_inventory.size()).is_equal(9) + assert_that(snapshot.player_inventory[0].slot).is_equal(0) + assert_that(snapshot.player_inventory[8].slot).is_equal(8) + assert_that(snapshot.player_inventory[8].item_id).is_equal(108) + + +func test_decode_inventory_missing_defaults_to_empty() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_inventory.size()).is_equal(0) + + +func test_decode_inventory_skips_malformed_items() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_inventory": [ + {"item_id": 100, "name": "Valid Item", "slot": 0}, + {"broken": true}, # Missing item_id and name + {"item_id": 101}, # Missing name + {"name": "No ID"}, # Missing item_id + {"item_id": 102, "name": "Also Valid", "slot": 3}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + # Only items with both item_id and name should decode + assert_that(snapshot.player_inventory.size()).is_equal(2) + assert_that(snapshot.player_inventory[0].name).is_equal("Valid Item") + assert_that(snapshot.player_inventory[1].name).is_equal("Also Valid") + + +func test_decode_inventory_item_slot_defaults_to_zero() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "player_inventory": [ + {"item_id": 100, "name": "No Slot"}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.player_inventory[0].slot).is_equal(0) + + +# -- GameState: v6 field storage ----------------------------------------------- + +func test_game_state_stores_player_stance() -> void: + GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Sprint"}) + assert_that(GameState.player_stance).is_equal("Sprint") + GameState.player_stance = "Walk" # Reset + + +func test_game_state_stores_player_inventory() -> void: + var inv := [ + {"item_id": 100, "name": "Manifest Copy", "slot": 0}, + {"item_id": 101, "name": "Access Token", "slot": 1}, + ] + GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inv}) + assert_that(GameState.player_inventory.size()).is_equal(2) + assert_that(GameState.player_inventory[0].name).is_equal("Manifest Copy") + GameState.player_inventory = [] # Reset + + +func test_game_state_clears_inventory_when_absent() -> void: + var inv := [{"item_id": 100, "name": "Item", "slot": 0}] + GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inv}) + assert_that(GameState.player_inventory.size()).is_equal(1) + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.player_inventory.size()).is_equal(0) + + +func test_game_state_stance_persists_when_absent() -> void: + # Stance should NOT reset when field is missing — keep last known value + GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Careful"}) + assert_that(GameState.player_stance).is_equal("Careful") + GameState.apply_snapshot({"tick": 2, "entities": []}) + # Stance persists (no explicit reset to Walk when absent) + assert_that(GameState.player_stance).is_equal("Careful") + GameState.player_stance = "Walk" # Reset + + +func test_game_state_defaults() -> void: + # Default values before any snapshot + var gs_stance = GameState.player_stance + var gs_inv = GameState.player_inventory + assert_that(gs_stance).is_equal("Walk") + assert_that(gs_inv.size()).is_equal(0) + + +# -- SimBridge test mode: v6 fields ------------------------------------------- + +func test_sim_bridge_test_snapshot_has_player_stance() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.has("player_stance")).is_true() + assert_that(snap.player_stance).is_equal("Walk") + + +func test_sim_bridge_test_snapshot_has_player_inventory() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.has("player_inventory")).is_true() + assert_that(snap.player_inventory is Array).is_true() + + +func test_sim_bridge_test_snapshot_version_6() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.version).is_equal(6) + + +# -- Fixture: v6 snapshots include new fields ---------------------------------- + +func test_fixture_snapshots_have_v6_defaults() -> void: + # All regenerated fixtures should have player_stance=Walk and empty inventory + for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player"]: + var bytes = _load_fixture(fixture_name) + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.player_stance).is_equal("Walk") + assert_that(snapshot.player_inventory.size()).is_equal(0) + + +# -- Input encoding: stance toggle actions (D-053) ---------------------------- + +func test_encode_toggle_stance_up() -> void: + var inputs: Array = [{"tick": 10, "action_name": "ToggleStanceUp"}] + var bytes := Protocol.encode_player_inputs(inputs) + var raw = Messagepack.decode(bytes) + assert_that(raw.status == null).is_true() + assert_that(raw.value[0]["action"]).is_equal("ToggleStanceUp") + + +func test_encode_toggle_stance_down() -> void: + var inputs: Array = [{"tick": 10, "action_name": "ToggleStanceDown"}] + var bytes := Protocol.encode_player_inputs(inputs) + var raw = Messagepack.decode(bytes) + assert_that(raw.status == null).is_true() + assert_that(raw.value[0]["action"]).is_equal("ToggleStanceDown") + + +func test_encode_decode_stance_toggle_roundtrip() -> void: + for action_name in ["ToggleStanceUp", "ToggleStanceDown"]: + var bytes = Protocol.encode_player_input(50, action_name) + 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(action_name) + assert_that(decoded.action.data).is_null() + + +# -- Integration: full v6 snapshot round-trip ---------------------------------- + +func test_full_v6_snapshot_decode() -> void: + # Simulate a realistic v6 snapshot with all fields populated + var raw := { + "tick": 100, + "version": Protocol.PROTOCOL_VERSION, + "game_time": {"day": 1, "time_of_day": 720, "day_phase": "Evening", "tick_rate": "Full"}, + "player_facing": "Southeast", + "player_stance": "Careful", + "player_inventory": [ + {"item_id": 100, "name": "Manifest Copy", "slot": 0}, + {"item_id": 101, "name": "Access Token", "slot": 1}, + {"item_id": 102, "name": "Comm Log", "slot": 2}, + ], + "entities": [ + {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player", + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible"}, + ], + "visible_tiles": [ + {"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"}, + ], + "nearby_interactions": [{ + "entity_id": 2, + "entity_type": "Npc", + "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }], + "current_monologue": { + "id": "test_001", + "text": "Sova Transit. The usual crowd.", + "duration_seconds": 4.0, + }, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(100) + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) + assert_that(snapshot.player_facing).is_equal("Southeast") + assert_that(snapshot.player_stance).is_equal("Careful") + assert_that(snapshot.player_inventory.size()).is_equal(3) + assert_that(snapshot.entities.size()).is_equal(1) + assert_that(snapshot.nearby_interactions.size()).is_equal(1) + assert_that(snapshot.current_monologue).is_not_null() + assert_that(snapshot.current_monologue.text).is_equal("Sova Transit. The usual crowd.") + + +func test_full_v6_snapshot_to_game_state() -> void: + var snapshot := { + "tick": 50, + "player_stance": "Crouch", + "player_inventory": [ + {"item_id": 200, "name": "Access Token", "slot": 4}, + ], + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + } + GameState.apply_snapshot(snapshot) + + assert_that(GameState.current_tick).is_equal(50) + assert_that(GameState.player_stance).is_equal("Crouch") + assert_that(GameState.player_inventory.size()).is_equal(1) + assert_that(GameState.player_inventory[0].name).is_equal("Access Token") + assert_that(GameState.player_inventory[0].slot).is_equal(4) + assert_that(GameState.player_position).is_equal(Vector2(5, 5)) + + # Reset + GameState.player_stance = "Walk" + GameState.player_inventory = [] diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index d392f17cb..5b6418ec8 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -90,6 +90,9 @@ fn all_player_action_variants_roundtrip() { PlayerAction::UsePerceptionMode("thermal".to_string()), PlayerAction::Pause, PlayerAction::Unpause, + PlayerAction::SetTickRate(TickRate::Half), + PlayerAction::ToggleStanceUp, + PlayerAction::ToggleStanceDown, ]; for action in actions {