diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 744b7afd2..120c43676 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -726,6 +726,47 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: return result.value +## Encode an AtlasLayerRequest (#969, D-225) for the layer-stream proxy. +## A bare map {body_id, up_to} — NOT the Vec array — so the server's +## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant +## (bare string: "Heightmap" | "Topography"). +static func encode_atlas_layer_request(body_id: String, up_to: String = "Topography") -> PackedByteArray: + var msg := {"body_id": body_id, "up_to": up_to} + var result = _mp().encode(msg) + if result.status != null: + push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary +## {body_id, status, error, layer1}, or null if the bytes are not an atlas +## response (no "status" key — e.g. an ObserverSnapshot). status is the variant +## 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 + if not raw is Dictionary or not raw.has("status"): + return null + var status_raw = raw["status"] + var status := "" + var error := "" + if status_raw is String: + status = status_raw + elif status_raw is Dictionary and status_raw.has("Error"): + status = "Error" + error = str(status_raw["Error"]) + return { + "body_id": raw.get("body_id", ""), + "status": status, + "error": error, + "layer1": raw.get("layer1"), + } + + ## 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/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack new file mode 100644 index 000000000..aa580b491 --- /dev/null +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -0,0 +1 @@ +ƒ§body_id¥ghost¦status¨NotFound¦layer1À \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack new file mode 100644 index 000000000..1606cc5ab --- /dev/null +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -0,0 +1 @@ +ƒ§body_id¤GJ1c¦status§Pending¦layer1À \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack new file mode 100644 index 000000000..17afc26fa Binary files /dev/null and b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack differ diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index be744df71..84cf7b8d4 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -463,3 +463,51 @@ func test_encode_confirm_bookmark_roundtrip() -> void: 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") + + +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() diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index bbb33c75d..8aadb5ac8 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -1,7 +1,13 @@ //! Generate MessagePack fixture files for cross-language testing (D-030 Layer 1). //! Run with: cargo test --test gen_fixtures -- --ignored +use settled_reach_server::atlas::body_world_state::{DrainageBasin, RiverNetwork}; +use settled_reach_server::atlas::layer1::Layer1Output; +use settled_reach_server::atlas::layer_proxy::{AtlasLayerResponse, AtlasLayerStatus}; use settled_reach_server::bridge::types::*; +use settled_reach_server::simulation::generator::{ + AttractorType, GeographicAttractor, SubBiomeVariant, +}; use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; @@ -529,3 +535,60 @@ fn generate_snapshot_with_bookmark_catalog() { rmp_serde::to_vec_named(&snapshot).expect("serialize snapshot_with_bookmark_catalog"); write_fixture("snapshot_with_bookmark_catalog", &bytes); } + +/// Atlas layer-stream responses (#969, D-225) — the client decodes these to +/// render the per-layer Atlas overlays (#960). Covers Ready (with a small +/// Layer1Output), Pending, and NotFound. +#[test] +#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored +fn generate_atlas_layer_response_fixtures() { + let layer1 = Layer1Output { + body_id: "GJ1c".into(), + river_network: RiverNetwork { + river_cells: vec![(12, 58), (12, 59)], + confluences: vec![], + mouths: vec![(12, 58)], + }, + drainage_basins: vec![DrainageBasin { + basin_id: 1, + boundary: vec![(0, 0), (0, 10), (10, 10), (10, 0)], + area_pct: 0.42, + }], + attractors: vec![GeographicAttractor { + position: (12, 58), + attractor_type: AttractorType::CoastalAccess, + strength: 0.9, + sub_biome: SubBiomeVariant::CoastalLowland, + terrain_modification_cost: 1.7, + }], + }; + let ready = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Ready, + layer1: Some(layer1), + }; + write_fixture( + "atlas_response_ready", + &rmp_serde::to_vec_named(&ready).unwrap(), + ); + + let pending = AtlasLayerResponse { + body_id: "GJ1c".into(), + status: AtlasLayerStatus::Pending, + layer1: None, + }; + write_fixture( + "atlas_response_pending", + &rmp_serde::to_vec_named(&pending).unwrap(), + ); + + let not_found = AtlasLayerResponse { + body_id: "ghost".into(), + status: AtlasLayerStatus::NotFound, + layer1: None, + }; + write_fixture( + "atlas_response_not_found", + &rmp_serde::to_vec_named(¬_found).unwrap(), + ); +}