feat(client): atlas request/response transport on the bridge (#960, D-225)

Client transport half of the layer-stream protocol.
- SimBridge.request_atlas_layers(body_id) sends an AtlasLayerRequest frame
  (live mode only; no-op in test mode); responses arrive via a new
  atlas_layers_received signal.
- receive_bytes now decodes each frame ONCE via Protocol.decode_inbound and
  branches by shape (snapshot vs atlas response) — avoids double-decoding the
  20 Hz snapshot path. decode_snapshot is split into decode_raw +
  _decode_snapshot_from_raw (public decode_snapshot unchanged, so the 70 protocol
  tests stay the regression guard); decode_inbound returns {kind, value}.

70/70 protocol tests pass, including the new decode_inbound classifier test.
(Pre-existing client-suite failures in server-dependent e2e/roundtrip + unrelated
audio/fog/dialogue suites are unchanged — verified identical at baseline.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:36:39 +02:00
co-authored by Claude Opus 4.7
parent a9ba8ba90c
commit 03d0264eb0
3 changed files with 77 additions and 10 deletions
+28 -2
View File
@@ -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.
+38 -8
View File
@@ -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:
+11
View File
@@ -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")