class_name AtlasMapProtocol ## AtlasLayerRequest/Response, StarMapRequest/Response, CityNamesRequest/Response ## codec — factored out of protocol.gd (T-1118) to stay under gdlint's ## max-file-lines cap, same rationale + shape as browse_protocol.gd ## (T-1131/T-1133): `mp` (the loaded messagepack.gd module) is passed in ## rather than reloaded here — protocol.gd's _mp() already owns that load(). ## ## Protocol delegates every one of these under the SAME public name ## (Protocol.atlas_response_from_raw(), Protocol.encode_star_map_request(), ## etc.) via its _amp() accessor — external callers (sim_bridge.gd, ## test_atlas_overlays.gd, test_atlas_data_delivery.gd) are unaffected by the ## move; only where the body lives changed. ## 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"). ## ## `window_center`/`window_n` (T-1138, D-226 T-1124 amendment §1): the windowed ## district-resolution regional-map query. Both are OMITTED from the encoded ## map (not sent as null) when window_center is null — this is what makes ## `#[serde(default)]` on the Rust side decode absence as `window_center: None` ## for every whole-body-only caller (request_atlas_layers()'s existing call ## sites), byte-identical to pre-T-1138 wire traffic. window_center is a ## DistrictPos, wire-encoded as the same [row, col] int-pair convention every ## other position field on this channel already uses (road_graph node ## positions, settlement positions, Layer-1 river-cell positions) — there is ## no separate DistrictPos struct-map on the wire, just a 2-element array. ## window_n is left unclamped here — §1 is explicit the server clamps to ## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the ## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on ## the regional-window viewer, not duplicated into the codec. ## ## `window_min_wl_m` (T-1150): the octave cutoff, in whole metres. OMITTED ## (not sent as 0) when at its default — struct/key plumbing, byte-compatible ## with every existing caller that doesn't pass it. ## ## `window_granularity_v2` (T-1152, R5 redesign — see ## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the derivation ## granularity, a plain STRING variant tag ("Quarter" | "District" | ## "Region"), matching `RoadNodeKind`'s existing wire precedent on this same ## carrier (a bare `#[derive(Serialize, Deserialize)]` enum with no ## `#[serde(rename_all)]` — rmp_serde encodes the Rust variant NAME verbatim, ## not an integer discriminant). OMITTED (not sent as "") when ## `window_granularity_v2` is the empty string — `#[serde(default)]` on the ## Rust side decodes absence as `None`, resolving to District (the default ## rung). ## ## **T-1159:** the legacy `window_granularity: int` field this codec used to ## also encode (T-1150's finer-than-district-only `u32` wire encoding) is ## retired — `window_granularity_v2` fully shadowed it since T-1152, and no ## caller in this codebase (nor any external client — single-repo ## client/server pair) ever sent it as anything but the byte-compatible ## default. ## ## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m` ## is sent HERE as a raw, unquantized value — this codec does NOT snap it to ## the design doc §5 fixed band set. The SERVER is the one place quantization ## happens (`serve_district_window` → `quantize_min_wl_m`, `layer_proxy.rs`): ## it snaps every request's value to the nearest band before touching the ## cache key or the echo, so a caller here is free to send a ## viewport-continuous estimate (e.g. `E/C` from the rung-selection rule) — ## don't pre-quantize client-side, it would just duplicate logic the server ## already owns and could drift out of sync with it. static func encode_atlas_layer_request( mp, body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0, window_min_wl_m: int = 0, window_granularity_v2: String = "" ) -> PackedByteArray: var msg := {"body_id": body_id, "up_to": up_to} if window_center != null: var center: Vector2i = window_center msg["window_center"] = [center.x, center.y] msg["window_n"] = window_n if window_min_wl_m != 0: msg["window_min_wl_m"] = window_min_wl_m if not window_granularity_v2.is_empty(): msg["window_granularity_v2"] = window_granularity_v2 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 ## Build an AtlasLayerResponse from an already-decoded raw value. Returns null ## if it is not an atlas response (no "status" key). ## road_graph/settlements (T-960): passthrough fields for the L2 road/rail ## graph and L3 settlement placements, mirroring the district_grid precedent ## (T-1046) — raw decoded maps/arrays, no further client-side reshaping. ## region_grid (T-1113/T-1118): the region climate grid, same passthrough ## pattern. quarter_footprints (T-1119, D-226 T-1112 amendment touch point 3): ## the L4 quarter-footprint aggregates, same passthrough pattern — ## QuarterFootprintLayer.entries is a BTreeMap on ## the wire, decoding to a Dictionary with int keys (city_id), no reshaping. ## district_window (T-1138, D-226 T-1124 amendment §2): the windowed ## DistrictWindowLayer — a DISTINCT payload by design (keyed on the request's ## (body, center, n), not the body alone), but the wire passthrough is the ## same shape as every sibling: raw.get() with no reshaping, `None` on the ## wire decodes to GDScript `null` exactly like every other Option field here. ## The response's `center`/`n` echo (inside the layer dict itself) is the ## client's race-condition/staleness guard (§2) — read by the window cache, ## not unwrapped here. `granularity_v2` (T-1152) rides inside the same dict, ## a bare string variant tag ("Quarter"/"District"/"Region") — no separate ## top-level unwrap needed, it passes through with everything else. ## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/ ## "district_window" are the CONFIRMED wire contract — identical to ## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names ## (region_grid pinned 2026-07-14, quarter_footprints pinned 2026-07-18, ## district_window per the D-226 T-1124 amendment §2 struct; round-tripped by ## test_atlas_overlays.gd/test_atlas_data_delivery.gd and the server's msgpack ## round-trip tests). This remains the one client-side spot to touch if the ## contract ever changes. 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"] 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"), "district_grid": raw.get("district_grid"), "road_graph": raw.get("road_graph"), "settlements": raw.get("settlements"), "region_grid": raw.get("region_grid"), "quarter_footprints": raw.get("quarter_footprints"), "district_window": raw.get("district_window"), } ## Decode a status enum shared by StarMapStatus/CityNamesStatus/AtlasLayerStatus ## shape: a unit variant is a bare string ("Ready", "SolExcluded", …); the one ## data variant (Error(String)) is a single-key map {"Error": "message"}. ## Returns {"status": String, "error": String} (error empty unless Error). static func _decode_status_field(status_raw: Variant) -> Dictionary: if status_raw is String: return {"status": status_raw, "error": ""} if status_raw is Dictionary and status_raw.has("Error"): return {"status": "Error", "error": str(status_raw["Error"])} return {"status": "", "error": ""} ## Encode a StarMapRequest (T-949, D-010) for the Reach-level star-map proxy. ## `star_map: true` is the mandatory discriminator field the server's demux ## matches on (dudley-atlas-server contract, 2026-07-14) — always send it, ## never omit it, or the frame can't be routed. static func encode_star_map_request(mp) -> PackedByteArray: var msg := {"star_map": true} var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_star_map_request failed: %s" % result.status) return PackedByteArray() return result.value ## Build a StarMapResponse from an already-decoded raw value. Returns null if ## it is not a star-map response (no "status" key). `data` is a verbatim ## MessagePack re-encoding of star_map_data.json's own top-level shape ## (`_meta`/`nodes`/`edges`) — unwrapped here so callers (SystemIndex) see the ## same {"nodes": [...]} shape they'd have gotten from the raw file, and never ## need to know about the status/data envelope. static func star_map_response_from_raw(raw: Variant) -> Variant: if not raw is Dictionary or not raw.has("status"): return null var decoded_status := _decode_status_field(raw.get("status")) var nodes: Array = [] if decoded_status["status"] == "Ready": var data: Variant = raw.get("data") if data is Dictionary: nodes = data.get("nodes", []) return {"status": decoded_status["status"], "error": decoded_status["error"], "nodes": nodes} ## Encode a CityNamesRequest (T-949, D-223/D-236) for one body's atlas ## city-name pool. `city_names: true` is the mandatory discriminator field ## (same contract as StarMapRequest) — without it the request is structurally ## ambiguous with a malformed AtlasLayerRequest (missing `up_to`). Sent for ## every body INCLUDING Sol — the server itself reports SolExcluded for those ## (D-236) as a defensive backstop; atlas_viewer.gd's own guard is expected to ## make that path rare, not load-bearing on its own. static func encode_city_names_request(mp, body_id: String) -> PackedByteArray: var msg := {"city_names": true, "body_id": body_id} var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_city_names_request failed: %s" % result.status) return PackedByteArray() return result.value ## Build a CityNamesResponse from an already-decoded raw value. Returns null ## unless it carries both "body_id" and "status". `cities` is a flat array of ## {city_id, name, is_capital} — no position (that comes from SettlementLayer, ## T-960's gen_l3_settlements). status is one of "Ready" | "SolExcluded" | ## "Error" (see _decode_status_field) — SolExcluded means the caller must fall ## back to the legacy markers.json read for that body (D-236). static func city_names_response_from_raw(raw: Variant) -> Variant: if not raw is Dictionary or not raw.has("body_id") or not raw.has("status"): return null var decoded_status := _decode_status_field(raw.get("status")) return { "body_id": str(raw.get("body_id", "")), "status": decoded_status["status"], "error": decoded_status["error"], "cities": raw.get("cities", []), }