feat(client): protocol v6 bridge — stance, inventory, input mapping

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:05:47 +01:00
co-authored by Claude Opus 4.6
parent f3550577a9
commit 24cce41379
7 changed files with 444 additions and 4 deletions
+14
View File
@@ -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:
+6 -1
View File
@@ -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:
+4
View File
@@ -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(),
+21 -1
View File
@@ -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,
+2 -2
View File
@@ -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()
+394
View File
@@ -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 = []
+3
View File
@@ -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 {