feat(ui): T-1118 climate overlay + T-1119 quarter glyphs — client halves, with file-size extractions

T-1118: gen_region_grid overlay (label TMP) — _draw_gen_region_grid
copies _draw_gen_district's self-contained mapping (dims from the
layer dict); mean-temp cold-to-hot ramp over -50..+50 C;
REGION_TEMP_NONE_DC airless sentinel = skip-cell (undrawn, never an
invented color); legend entry.

T-1119 (touch points 3-6): quarter_footprints protocol passthrough;
gen_l4_quarters overlay (label QTR) — density-scaled glyph anchored
on the L3 settlement dot joined by city_id, shape = dominant district
type (corner-tab/diamond marks for Commercial/Industrial/
Administrative), color = density ramp on the settlement-gold family,
zoom-gated at SETTLEMENT_LABEL_MIN_ZOOM; landmark/corridor counts
never drawn (D-226(d) tooltip-only ceiling); legend entry.

Structure: atlas_viewer.gd and protocol.gd were over gdlint's
1000-line cap before this batch; cleanly-separable responsibilities
extracted on existing precedent — atlas_generation_state.gd (per-layer
data + accessors), atlas_generation_proxy.gd (polling/retry/pending
machinery), atlas_overlay_colors.gd (pure ramp/shape lookups),
atlas_map_protocol.gd (atlas/starmap/citynames codec, the
browse_protocol.gd delegate pattern). Public APIs preserved exactly;
_gen_state stays a field default (RefCounted, pre-_ready safe) because
_ready()-construction breaks every bare AtlasViewer.new() test —
documented inline.

Tests: registration + round-trip for both overlays; pure-function
suites for the temp ramp (endpoints/midpoint/clamp/sentinel) and
quarter glyph (scaling, zoom gate, ramp, notch across all 9
DistrictTypes); Tier-2 replay asserts exact literals from the real
server-generated fixture incl. the airless sentinel. Color.lerp(a,b,
1.0) is not bit-exact to b — endpoint assertions use per-component
is_equal_approx. Full suite 3094/3094; gdlint zero warnings incl. the
two previously-over-cap files. Live capture: legend grows to 7
sections, TMP/QTR toggles clean against a live server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 12:25:47 +02:00
co-authored by Claude Fable 5
parent 8165c61186
commit e41cca2ded
9 changed files with 1102 additions and 302 deletions
@@ -0,0 +1,144 @@
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<PlayerInput> 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(
mp, 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
## 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<u64, QuarterFootprintEntry> on
## the wire, decoding to a Dictionary with int keys (city_id), no reshaping.
## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints" 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; round-tripped by
## test_atlas_overlays.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"),
}
## 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", []),
}
+28 -108
View File
@@ -25,6 +25,12 @@ static func _bp():
return load("res://scripts/protocol/browse_protocol.gd")
## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/Response
## codec (T-1118) — same load()-by-path rationale as _bp() above.
static func _amp():
return load("res://scripts/protocol/atlas_map_protocol.gd")
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -764,105 +770,41 @@ 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<PlayerInput> array — so the server's
## frame demux routes it to the atlas proxy. up_to is a CascadeLayer unit variant
## (bare string: "Heightmap" | "Topography").
## AtlasLayerRequest/Response + StarMapRequest/Response + CityNamesRequest/
## Response codec — factored into atlas_map_protocol.gd (T-1118) to stay
## under gdlint's max-file-lines, same rationale as _bp()/browse_protocol.gd
## above. Every function below is a thin delegate under its ORIGINAL public
## name — external callers (sim_bridge.gd, test_atlas_overlays.gd,
## test_atlas_data_delivery.gd) are unaffected by the move.
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
return _amp().encode_atlas_layer_request(_mp(), body_id, up_to)
## 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.
## {body_id, status, error, layer1, district_grid, road_graph, settlements,
## region_grid, quarter_footprints}, or null if the bytes are not an atlas
## response (no "status" key — e.g. an ObserverSnapshot).
static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
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).
## 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): the region climate grid, same passthrough pattern.
## Key names "road_graph"/"settlements"/"region_grid" are the CONFIRMED wire
## contract — identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's
## field names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and
## the server's msgpack round-trip tests). This remains the one client-side
## spot to touch if the contract ever changes.
## Build an AtlasLayerResponse from an already-decoded raw value. See
## atlas_map_protocol.gd for the full field-by-field wire-shape rationale.
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"),
}
## 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": ""}
return _amp().atlas_response_from_raw(raw)
## 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() -> 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
return _amp().encode_star_map_request(_mp())
## 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.
## Build a StarMapResponse from an already-decoded raw value. See
## atlas_map_protocol.gd for the data-unwrapping rationale.
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}
return _amp().star_map_response_from_raw(raw)
## Decode a StarMapResponse from MessagePack bytes. See star_map_response_from_raw.
@@ -871,37 +813,15 @@ static func decode_star_map_response(bytes: PackedByteArray) -> Variant:
## 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.
## city-name pool.
static func encode_city_names_request(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
return _amp().encode_city_names_request(_mp(), body_id)
## 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).
## Build a CityNamesResponse from an already-decoded raw value. See
## atlas_map_protocol.gd for the SolExcluded/cities-shape rationale.
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", []),
}
return _amp().city_names_response_from_raw(raw)
## Decode a CityNamesResponse from MessagePack bytes. See city_names_response_from_raw.