Files
settled-reach/client/tests/test_protocol.gd
T
jpmschweitzer 0159a63cc2 fix(simulation): PR #191 review round — n-clamp mirror, min_wl band quantization, coalescing coverage, fixture consumer
All seven Hoshe/Tyre findings addressed, none retracted:
- n-clamp/echo/staleness triangle (Tyre C1): client _clamp_window_n_mirror
  (bit-for-bit twin of the server clamp, canonicalize_district_center
  precedent) applied before _n is stored/sent; server test pins the
  quarter n=32 -> echo 16 contract.
- min_wl band quantization (Hoshe 1/Tyre C3): quantize_min_wl_m snaps to
  MIN_WL_BANDS_M {0, 32768, 16384, 8192, 4096} before cache key and echo
  (design doc §5's unbounded-key fix), reusing the one true
  OCTAVE_WAVELENGTHS_M array; docstrings now state the server-quantizes/
  client-sends-raw split; same-band cache-sharing test.
- coalescing granularity axis (Hoshe 2): two tests pin different-
  granularity requests as separate in-flight slots and same-granularity
  coalescing unchanged.
- orphaned fixture (Hoshe 3): test_protocol.gd consumer decodes
  atlas_response_ready_with_window.msgpack through the real IPC path and
  asserts the new fields.
- atlas_window_request coverage (Hoshe 4): new test file — stale-drop on
  granularity mismatch, old-server-shape defaults accepted, clamp mirror
  formula + wiring. First draft's quarter-via-request_now test would have
  passed for the wrong reason (request_now resets granularity by design
  until T-1153) — split into formula pin + reachable-path wiring proof.
- granularity type seam (Tyre C2): field + resolver docstrings state
  finer-only integer multiples with resolve_window_granularity as the
  single widening point; matching contract note added to the D-226
  T-1143-rulings amendment.

cargo --lib 1807/1807; goldens bit-identical; gdlint clean.
2026-07-22 00:47:53 +02:00

563 lines
20 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,
"version": 23,
"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")
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
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)
# 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)
# 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 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.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")
# -- 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)
# Interact is a struct variant: {"Interact": {"target_entity_id": null, "verb": null}}
assert_that(raw.value[1]["action"] is Dictionary).is_true()
assert_that(raw.value[1]["action"].has("Interact")).is_true()
# -- 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()
# -- v23: BookmarkCatalog decode -----------------------------------------------
func test_decode_snapshot_with_bookmark_catalog() -> void:
# Hand-built dict — fixture generation requires server work, skip round-trip (#614).
var raw := {
"tick": 1,
"version": 23,
"entities": [],
"bookmark_catalog":
{
"bookmarks":
[
{
"id": "bm_tycoon_arion",
"title": "The Arion Run",
"subtitle": "Mid-range freight corridor",
"flavor": "You have contacts. Use them.",
"default_location": "loc_arion_prime",
"allowed_locations": ["loc_arion_prime", "loc_vethis_station"],
"allowed_locations_cultures": ["arion", "vethis"],
"career": "tycoon",
"starting_capital_tractus": 50000,
},
],
},
}
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.bookmark_catalog).is_not_null()
var bmc: Dictionary = snapshot.bookmark_catalog
assert_that(bmc.has("bookmarks")).is_true()
assert_that(bmc["bookmarks"].size()).is_equal(1)
var bm: Dictionary = bmc["bookmarks"][0]
assert_that(bm["id"]).is_equal("bm_tycoon_arion")
assert_that(bm["title"]).is_equal("The Arion Run")
assert_that(bm["default_location"]).is_equal("loc_arion_prime")
assert_that(bm["allowed_locations"].size()).is_equal(2)
assert_that(bm["allowed_locations"][0]).is_equal("loc_arion_prime")
assert_that(bm["allowed_locations_cultures"][1]).is_equal("vethis")
assert_that(bm["career"]).is_equal("tycoon")
assert_that(bm["starting_capital_tractus"]).is_equal(50000)
func test_decode_snapshot_bookmark_catalog_fixture() -> void:
# Cross-language round-trip: Rust-generated fixture (#614).
var bytes = _load_fixture("snapshot_with_bookmark_catalog")
var snapshot: Variant = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.bookmark_catalog).is_not_null()
var bmc: Dictionary = snapshot.bookmark_catalog
assert_that(bmc["bookmarks"].size()).is_greater(0)
var bm: Dictionary = bmc["bookmarks"][0]
assert_that(bm.has("id")).is_true()
assert_that(bm.has("title")).is_true()
assert_that(bm.has("allowed_locations")).is_true()
assert_that(bm["career"]).is_equal("tycoon")
func test_decode_snapshot_no_bookmark_catalog_is_null() -> void:
# Snapshot without bookmark_catalog key → field should be null.
var raw := {
"tick": 2,
"version": 23,
"entities": [],
}
var encoded: Variant = Messagepack.encode(raw)
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.bookmark_catalog).is_null()
# -- v23: RequestBookmarkCatalog + ConfirmBookmark encoding --------------------
func test_encode_request_bookmark_catalog_roundtrip() -> void:
var bytes := Protocol.encode_request_bookmark_catalog()
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(1)
var entry: Dictionary = raw.value[0]
assert_that(entry["action_name"]).is_equal("RequestBookmarkCatalog")
assert_that(entry.get("action_data")).is_null()
func test_encode_confirm_bookmark_roundtrip() -> void:
var bytes := Protocol.encode_confirm_bookmark("bm_tycoon_arion", "loc_arion_prime")
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()
var entry: Dictionary = raw.value[0]
assert_that(entry["action_name"]).is_equal("ConfirmBookmark")
var data: Dictionary = entry["action_data"]
assert_that(data["bookmark_id"]).is_equal("bm_tycoon_arion")
assert_that(data["starting_location_id"]).is_equal("loc_arion_prime")
# -- Atlas layer-stream protocol (#969, D-225) ---------------------------------
func test_decode_atlas_response_ready() -> void:
var bytes := _load_fixture("atlas_response_ready")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp).is_not_null()
assert_that(resp.body_id).is_equal("GJ1c")
assert_that(resp.status).is_equal("Ready")
assert_that(resp.layer1).is_not_null()
assert_that(resp.layer1.river_network.river_cells.size()).is_equal(2)
assert_that(resp.layer1.attractors.size()).is_equal(1)
assert_that(resp.layer1.attractors[0].attractor_type).is_equal("CoastalAccess")
assert_that(resp.layer1.attractors[0].sub_biome).is_equal("CoastalLowland")
func test_decode_atlas_response_pending() -> void:
var bytes := _load_fixture("atlas_response_pending")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp).is_not_null()
assert_that(resp.status).is_equal("Pending")
assert_that(resp.layer1).is_null()
func test_decode_atlas_response_not_found() -> void:
var bytes := _load_fixture("atlas_response_not_found")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp.status).is_equal("NotFound")
## PR #191 review, Hoshe 3: `atlas_response_ready_with_window.msgpack` had NO
## consumer anywhere in client/tests — regenerated by the T-1150 `granularity`/
## `min_wl_m` field additions but nothing decoded it through the real IPC path.
## This is that consumer, matching the sibling `test_decode_atlas_response_*`
## tests' style/fixture-dir convention above: full decode_atlas_layer_response()
## round trip (not a hand-built Dictionary like test_atlas_data_delivery.gd's
## passthrough tests), confirming `district_window.granularity`/`.min_wl_m`
## (T-1150's two new echo fields) survive the real client decode path.
func test_decode_atlas_response_ready_with_window() -> void:
var bytes := _load_fixture("atlas_response_ready_with_window")
var resp = Protocol.decode_atlas_layer_response(bytes)
assert_that(resp).is_not_null()
assert_that(resp.status).is_equal("Ready")
assert_that(resp.district_window).is_not_null()
var window: Dictionary = resp.district_window
assert_that(window.get("center")).is_equal([10, -5])
assert_that(int(window.get("n"))).is_equal(2)
assert_that(int(window.get("granularity"))).is_equal(1)
assert_that(int(window.get("min_wl_m"))).is_equal(0)
func test_snapshot_is_not_decoded_as_atlas_response() -> void:
# Disambiguation: an ObserverSnapshot has no "status" key, so the atlas
# decoder rejects it. receive_bytes relies on this to route correctly.
var bytes := _load_fixture("snapshot_empty")
assert_that(Protocol.decode_atlas_layer_response(bytes)).is_null()
func test_encode_atlas_request_shape() -> void:
var bytes := Protocol.encode_atlas_layer_request("GJ1c", "Topography")
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Dictionary).is_true()
assert_that(raw.value["body_id"]).is_equal("GJ1c")
assert_that(raw.value["up_to"]).is_equal("Topography")
# A request is a map with no "status" — must not be mistaken for a response.
assert_that(Protocol.decode_atlas_layer_response(bytes)).is_null()
func test_decode_inbound_classifies_frames() -> void:
# The receive-side classifier: snapshot vs atlas response, decoded once.
var snap = Protocol.decode_inbound(_load_fixture("snapshot_empty"))
assert_that(snap.kind).is_equal("snapshot")
assert_that(snap.value).is_not_null()
var atlas = Protocol.decode_inbound(_load_fixture("atlas_response_ready"))
assert_that(atlas.kind).is_equal("atlas")
assert_that(atlas.value.status).is_equal("Ready")