diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 83e9007b0..d0629fa0e 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -3,6 +3,7 @@ extends Node # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) signal snapshot_received(snapshot: Dictionary) +signal atlas_layers_received(response: Dictionary) signal handshake_complete signal handshake_failed(reason: String) @@ -387,6 +388,22 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void _outbound_buffer.append(entry) +## Request a body's generation-cascade layers from the server (#960, D-225). +## Live mode only — sends an AtlasLayerRequest frame; the response arrives via the +## atlas_layers_received signal. No-op in test mode (no server connection). +func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void: + if test_mode or _bridge == null or state != ConnectionState.CONNECTED: + return + var bytes := Protocol.encode_atlas_layer_request(body_id, up_to) + if bytes.is_empty(): + return + var err: int = _bridge.send_message(bytes) + if err != OK: + push_error( + "SimBridge: failed to send atlas layer request for %s: %s" % [body_id, error_string(err)] + ) + + # Poll for snapshot from simulation. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: @@ -412,9 +429,18 @@ func poll_snapshot() -> Variant: # events (monologue, dialogue) are carried forward from overwritten snapshots # so they aren't silently dropped when server ticks faster than client consumes. func receive_bytes(bytes: PackedByteArray) -> void: - var snapshot = Protocol.decode_snapshot(bytes) + # Decode once, branch by frame shape (#960, D-225): atlas layer responses and + # snapshots are both msgpack maps, told apart by field. + var inbound := Protocol.decode_inbound(bytes) + if inbound.kind == "atlas": + atlas_layers_received.emit(inbound.value) + return + if inbound.kind != "snapshot": + push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size()) + return + var snapshot = inbound.value if snapshot == null: - push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size()) + push_warning("SimBridge: snapshot decode returned null for %d bytes" % bytes.size()) return if _last_snapshot != null: # Carry forward one-shot events the client hasn't consumed yet. diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 120c43676..f95d12934 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -15,17 +15,31 @@ static func _mp(): # -- Decode: bytes from server → GDScript types -------------------------------- +## Decode a raw MessagePack frame to its top-level value (or null on error). +## #960: lets receive_bytes decode a frame once and branch by shape before +## committing to the heavier snapshot decode. +static func decode_raw(bytes: PackedByteArray) -> Variant: + var result = _mp().decode(bytes) + if result.status != null: + push_error("Protocol: msgpack decode failed: %s" % result.status) + return null + return result.value + + ## Decode an ObserverSnapshot from MessagePack bytes. ## Returns decoded snapshot Dictionary or null on error. ## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty ## when decoding v1 snapshots for backward compatibility. static func decode_snapshot(bytes: PackedByteArray) -> Variant: - var result = _mp().decode(bytes) - if result.status != null: - push_error("Protocol: msgpack decode failed: %s" % result.status) + var raw = decode_raw(bytes) + if raw == null: return null + return _decode_snapshot_from_raw(raw) - var raw = result.value + +## Build an ObserverSnapshot from an already-decoded raw value (the snapshot +## body, shared by decode_snapshot and the receive-side classifier). +static func _decode_snapshot_from_raw(raw: Variant) -> Variant: if not raw is Dictionary or not raw.has("tick") or not raw.has("entities"): push_error("Protocol: snapshot missing required fields") return null @@ -745,10 +759,12 @@ static func encode_atlas_layer_request(body_id: String, up_to: String = "Topogra ## name ("Ready"|"Pending"|"NotFound"|"Error"); error holds the message for the ## Error variant. layer1 is the raw decoded Layer1Output map, or null. static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant: - var result = _mp().decode(bytes) - if result.status != null: - return null - var raw = result.value + return atlas_response_from_raw(decode_raw(bytes)) + + +## Build an AtlasLayerResponse from an already-decoded raw value. Returns null +## if it is not an atlas response (no "status" key). +static func atlas_response_from_raw(raw: Variant) -> Variant: if not raw is Dictionary or not raw.has("status"): return null var status_raw = raw["status"] @@ -767,6 +783,20 @@ static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant: } +## Decode + classify one inbound frame (#960, D-225). Returns {kind, value} with +## kind "snapshot" | "atlas" | "unknown" — both are msgpack maps, so they are +## told apart by field (a response has "status"; a snapshot has "entities"). +## Lets receive_bytes decode the frame ONCE and branch, instead of double-decoding +## the 20 Hz snapshot path. +static func decode_inbound(bytes: PackedByteArray) -> Dictionary: + var raw = decode_raw(bytes) + if not raw is Dictionary: + return {"kind": "unknown", "value": null} + if raw.has("status") and not raw.has("entities"): + return {"kind": "atlas", "value": atlas_response_from_raw(raw)} + return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)} + + ## Encode a ConfirmBookmark action (#614, #680). ## Struct variant with bookmark_id and starting_location_id. static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: String) -> PackedByteArray: diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 84cf7b8d4..915d15a04 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -511,3 +511,14 @@ func test_encode_atlas_request_shape() -> void: 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")