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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user