Add Dictionary validation in game_state visible_tiles loop to prevent crash on malformed data (Hoshe critical). Skip redundant modulate.a writes (Hoshe suggestion). Add 3 tests: malformed visible_tiles, facing rotation accuracy, v1 backward compatibility. Clarify D-033 Phase 1/Phase 2 comments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
381 lines
14 KiB
GDScript
381 lines
14 KiB
GDScript
## 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_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(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[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[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[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)
|
|
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_snapshot_malformed_entities_counted() -> void:
|
|
# Snapshot with one valid and one malformed entity — decode_errors should count the bad one
|
|
var raw := {
|
|
"tick": 7,
|
|
"entities": [
|
|
{"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"},
|
|
{"entity_id": 2, "broken": true}, # Missing required fields
|
|
{"x": 1.0}, # Missing entity_id, y, z, kind
|
|
],
|
|
}
|
|
var encoded: Variant = Messagepack.encode(raw)
|
|
assert_that(encoded.status).is_null()
|
|
|
|
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
|
|
assert_that(snapshot).is_not_null()
|
|
assert_that(snapshot.tick).is_equal(7)
|
|
assert_that(snapshot.entities.size()).is_equal(1) # Only the valid entity
|
|
assert_that(snapshot.decode_errors).is_equal(2) # Two malformed entities
|
|
|
|
|
|
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)
|
|
|
|
|
|
# -- Batch input encoding (Vec<PlayerInput> 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)
|
|
|
|
|
|
# -- v2 snapshot decoding -------------------------------------------------------
|
|
|
|
func test_decode_snapshot_v2_full() -> void:
|
|
var bytes = _load_fixture("snapshot_v2_full")
|
|
var snapshot = Protocol.decode_snapshot(bytes)
|
|
|
|
assert_that(snapshot).is_not_null()
|
|
assert_that(snapshot.tick).is_equal(500)
|
|
assert_that(snapshot.version).is_equal(2)
|
|
|
|
# game_time
|
|
assert_that(snapshot.game_time).is_not_null()
|
|
assert_that(snapshot.game_time.day).is_equal(1)
|
|
assert_that(snapshot.game_time.time_of_day).is_equal(720)
|
|
assert_that(snapshot.game_time.paused).is_false()
|
|
|
|
# player_facing (unit enum → bare string)
|
|
assert_that(snapshot.player_facing).is_equal("Southeast")
|
|
|
|
# entities with visibility sector
|
|
assert_that(snapshot.entities.size()).is_equal(1)
|
|
assert_that(snapshot.entities[0].visibility).is_equal("Forward")
|
|
|
|
# visible_tiles
|
|
assert_that(snapshot.visible_tiles.size()).is_equal(3)
|
|
assert_that(snapshot.visible_tiles[0].x).is_equal(10)
|
|
assert_that(snapshot.visible_tiles[0].visibility).is_equal("Forward")
|
|
assert_that(snapshot.visible_tiles[1].visibility).is_equal("Peripheral")
|
|
|
|
|
|
func test_existing_fixtures_have_v2_fields() -> void:
|
|
# All fixtures are generated by v2 fixture_snapshot() — verify decoder extracts v2 fields
|
|
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(2)
|
|
assert_that(snapshot.player_facing).is_equal("North")
|
|
assert_that(snapshot.game_time).is_not_null()
|
|
|
|
|
|
func test_multi_entity_visibility_sectors() -> void:
|
|
# snapshot_multi_entity has: Player=Forward, Npc=Peripheral, Object=Forward, Terrain=Forward
|
|
var bytes = _load_fixture("snapshot_multi_entity")
|
|
var snapshot = Protocol.decode_snapshot(bytes)
|
|
assert_that(snapshot.entities[0].visibility).is_equal("Forward")
|
|
assert_that(snapshot.entities[1].visibility).is_equal("Peripheral")
|
|
assert_that(snapshot.entities[2].visibility).is_equal("Forward")
|
|
assert_that(snapshot.entities[3].visibility).is_equal("Forward")
|
|
|
|
|
|
# -- v1 backward compatibility (no v2 fields → graceful null defaults) --------
|
|
|
|
func test_decode_v1_snapshot_graceful_defaults() -> void:
|
|
# Minimal v1 snapshot — only tick + entities, no v2 fields
|
|
var v1_raw := {"tick": 10, "entities": [
|
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"},
|
|
]}
|
|
var encoded: Variant = Messagepack.encode(v1_raw)
|
|
assert_that(encoded.status).is_null()
|
|
|
|
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
|
|
assert_that(snapshot).is_not_null()
|
|
assert_that(snapshot.tick).is_equal(10)
|
|
assert_that(snapshot.entities.size()).is_equal(1)
|
|
# v2 fields should be null/empty, not crash
|
|
assert_that(snapshot.version).is_null()
|
|
assert_that(snapshot.game_time).is_null()
|
|
assert_that(snapshot.player_facing).is_null()
|
|
assert_that(snapshot.visible_tiles.size()).is_equal(0)
|
|
# Entity should have null visibility
|
|
assert_that(snapshot.entities[0].visibility).is_null()
|
|
|
|
|
|
# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ----------------
|
|
|
|
func test_decode_batch_input_fixture() -> void:
|
|
# Rust-generated Vec<PlayerInput> fixture — verifies bidirectional Layer 1 compatibility
|
|
var bytes = _load_fixture("input_batch_two")
|
|
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(2)
|
|
assert_that(raw.value[0]["tick"]).is_equal(0)
|
|
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
|
assert_that(raw.value[1]["tick"]).is_equal(0)
|
|
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
|
|
|
|
|
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
|
|
|
|
func test_decode_diagonal_fixtures() -> void:
|
|
# All 4 diagonal fixtures: clockwise NE, SE, SW, NW
|
|
var diagonals: Array = [
|
|
["input_move_northeast", "MoveNortheast"],
|
|
["input_move_southeast", "MoveSoutheast"],
|
|
["input_move_southwest", "MoveSouthwest"],
|
|
["input_move_northwest", "MoveNorthwest"],
|
|
]
|
|
|
|
for pair in diagonals:
|
|
var bytes = _load_fixture(pair[0])
|
|
var input: Variant = 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(pair[1])
|
|
assert_that(input.action.data).is_null()
|