Files
settled-reach/client/scripts/protocol/atlas_map_protocol.gd
T
jpmschweitzer ce90d69ae8 feat(client): T-1153 + T-1152 client half — continuous cursor-anchored zoom ladder, Region-rung orbital entry, click-through retired
The atlas 'regional' screen now opens the LADDER at the canonical orbital
frame (Region granularity, whole body fitted and centered) and wheel zoom
descends continuously — cursor-anchored, unclamped across rungs, with
progressive refinement (held composite keeps drawing, finer rung swaps in
place on arrival; no blank frame, no mode flip). Full-zoom-out resets to
the canonical planetary frame per Jeroen's HARD condition
(is_fully_zoomed_out = extent >= body circumference, not a zoom-value
heuristic). The district_screen nav hop is deleted — D-013 restored:
descent is a zoom gesture, not a nav push. AtlasViewer's heightmap-texture
path is unreachable from nav (code intact; overlay surface deferred, see
report/tickets).

Rung selection: design doc §5's literal formula has NO legal District band
at any real viewport (visual-tolerance band and n=64 coverage ceiling
never overlap — pinned by executable boundary tests at 1600x900);
select_rung() splits it into a coverage ceiling (decides Region) then the
2x visual tolerance (District vs Quarter), documented at the function.
In practice the ladder steps Region -> Quarter directly.

Wire: window_granularity_v2 encoded (omitted at District for byte-compat),
granularity_v2 echoed value keyed + staleness-guarded end to end; Region
clamp mirror replicates the server's bounded halving loop (no closed
form). MIN/MAX_ZOOM widened to [0.0005, 64] — the old 0.5 floor would
have clamped a real body's canonical fit zoom, violating the reset
condition.

Real pre-existing bug fixed in atlas_window_overlay.gd: the draw path used
echoed n as both cell-grid dimension and district extent — only
coincidentally correct at District granularity; Quarter/Region would have
read wrong array offsets. cell_grid_side_for_window() now mirrors the
server's WindowGranularity::cell_grid_side.

Tests: +26 pure-function geometry tests, new 30-test zoom-ladder suite,
extensions across the window cache/request/overlay/delivery suites.
Full suite 3518 green; cold-parse clean.
2026-07-22 11:41:37 +02:00

224 lines
11 KiB
GDScript

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").
##
## `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_granularity`/`window_min_wl_m` (T-1150): the derivation-granularity
## axis (district=1/omitted vs. quarter=4) and the octave cutoff, in whole
## metres. Both OMITTED (not sent as 0) when at their default — this is
## struct/key plumbing only (T-1150 scope): no caller in this codebase
## requests quarter granularity yet (that's T-1153); this function just makes
## it possible to ask, byte-compatible with every existing caller that
## doesn't pass them.
##
## `window_granularity_v2` (T-1152, R5 redesign — see
## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the ONLY way to
## express a coarser-than-district rung (`"Region"`) the legacy `u32` field
## cannot encode. 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`, falling back to the legacy `u32`
## field's `resolve_window_granularity_v2()` precedence rule (that field wins
## over the legacy one whenever present — see that Rust doc for the full
## precedence contract). Every pre-T-1152 caller (and every T-1150 caller that
## only ever sends `window_granularity`) omits this field entirely and stays
## byte-compatible.
##
## **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_granularity: 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_granularity != 0:
msg["window_granularity"] = window_granularity
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<u64, QuarterFootprintEntry> 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", []),
}