feat(client): atlas roads/settlements overlays + legend + server-API data delivery (T-960, T-949)

T-960: gen_l2_roads (MaintenanceAuthority-colored polylines, rail styling, junction markers) + gen_l3_settlements (size-scaled markers, capital shape, name labels) overlays — cities render on generated bodies for the first time; left-side generation legend panel (D-226 item 3, data-driven per-overlay spec, implant component library); protocol.gd decodes road_graph/settlements + the two new response types. T-949: system_index/atlas_app/overview_screen migrated off the direct star_map_data.json read to StarMapRequest over the bridge (loading state + replay-on-connect, no silent file fallback); atlas_viewer _load_markers requests CityNamesResponse for non-Sol bodies; Sol keeps the legacy authored markers.json geometry read (D-236/T-1073, load-bearing guard). Lead fix: _send_star_map_request now carries the same guard as request_star_map — the autoload's _star_map_wanted leaked across gdUnit suites and the unguarded replay-on-CONNECTED crashed 8 pre-existing flow tests on a Nil bridge; reset_test_state clears the flag. Fixtures regenerated via gen_fixtures (road/settlement samples). Full suite 2946/2946.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:45:54 +02:00
co-authored by Claude Fable 5
parent 37881acba0
commit 845737617c
28 changed files with 1561 additions and 76 deletions
+73 -2
View File
@@ -4,6 +4,8 @@ extends Node
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
signal snapshot_received(snapshot: Dictionary)
signal atlas_layers_received(response: Dictionary)
signal star_map_received(response: Dictionary) # T-949: StarMapResponse
signal city_names_received(response: Dictionary) # T-949: CityNamesResponse
signal handshake_complete
signal handshake_failed(reason: String)
@@ -34,6 +36,12 @@ var _connect_retries: int = 0
var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0
# T-949: true once any caller has asked for the star map. The Atlas/economics
# screens can call request_star_map() before the bridge finishes its
# handshake — the request is remembered and replayed automatically the
# moment _set_state reaches CONNECTED, instead of silently going nowhere.
var _star_map_wanted: bool = false
var _test_tick: int:
get:
return harness.tick if harness else 0
@@ -93,6 +101,8 @@ func _ready() -> void:
func reset_test_state() -> void:
if harness:
harness.reset()
# T-949: don't leak a star-map request across tests (autoload state).
_star_map_wanted = false
func _test_snapshot() -> Dictionary:
@@ -112,6 +122,10 @@ func _set_state(new_state: ConnectionState) -> void:
var old_state = state
state = new_state
connection_state_changed.emit(old_state, new_state)
# T-949: fire (or re-fire, on reconnect) any star-map request that was
# asked for before we were connected.
if new_state == ConnectionState.CONNECTED and _star_map_wanted:
_send_star_map_request()
# Connect to simulation server.
@@ -406,6 +420,56 @@ func request_atlas_layers(body_id: String, up_to: String = "Topography") -> void
)
## Request the Reach-level star map / system list from the server (T-949,
## D-010 — replaces the client's direct star_map_data.json file read). Live
## mode only; the response arrives via the star_map_received signal. Safe to
## call before the handshake completes — the request is remembered
## (_star_map_wanted) and replayed automatically once CONNECTED (see
## _set_state), so callers don't need to poll or retry themselves.
func request_star_map() -> void:
_star_map_wanted = true
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
_send_star_map_request()
func _send_star_map_request() -> void:
# Same guard as request_star_map(): _star_map_wanted persists on this
# autoload across gdUnit suites, so the replay-on-CONNECTED path in
# _set_state() can fire in test mode where _bridge is null — an unguarded
# send crashed every later suite that called connect_to_sim() (8 tests,
# 2026-07-14). Live mode: _bridge exists before CONNECTED, guard passes.
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_star_map_request()
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error("SimBridge: failed to send star map request: %s" % error_string(err))
## Request one body's atlas city-name pool from the server (T-949, D-223/
## D-236). Live mode only — sends a CityNamesRequest frame; the response
## arrives via the city_names_received signal. No-op in test mode. Never
## called for Sol bodies (system GJ-0) — atlas_viewer.gd keeps the legacy
## markers.json geometry read for those (D-236, T-1073).
func request_city_names(body_id: String) -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_city_names_request(body_id)
if bytes.is_empty():
return
var err: int = _bridge.send_message(bytes)
if err != OK:
push_error(
(
"SimBridge: failed to send city names 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:
@@ -431,12 +495,19 @@ 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:
# Decode once, branch by frame shape (#960, D-225): atlas layer responses and
# snapshots are both msgpack maps, told apart by field.
# Decode once, branch by frame shape (#960, D-225; T-949 adds starmap/
# citynames): all response kinds and snapshots are 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 == "starmap":
star_map_received.emit(inbound.value)
return
if inbound.kind == "citynames":
city_names_received.emit(inbound.value)
return
if inbound.kind != "snapshot":
push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size())
return
+111 -7
View File
@@ -771,6 +771,12 @@ static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
## 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.
## PLACEHOLDER key names ("road_graph"/"settlements") pending confirmation
## against dudley-atlas-server's RoadGraphLayer/SettlementLayer field names —
## this is the one spot to rename if the server picks different keys.
static func atlas_response_from_raw(raw: Variant) -> Variant:
if not raw is Dictionary or not raw.has("status"):
return null
@@ -787,20 +793,118 @@ static func atlas_response_from_raw(raw: Variant) -> Variant:
"status": status,
"error": error,
"layer1": raw.get("layer1"),
"district_grid": raw.get("district_grid"),
"district_grid": raw.get("district_grid"),
"road_graph": raw.get("road_graph"),
"settlements": raw.get("settlements"),
}
## 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.
## 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() -> 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}
## Decode a StarMapResponse from MessagePack bytes. See star_map_response_from_raw.
static func decode_star_map_response(bytes: PackedByteArray) -> Variant:
return star_map_response_from_raw(decode_raw(bytes))
## 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(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", []),
}
## Decode a CityNamesResponse from MessagePack bytes. See city_names_response_from_raw.
static func decode_city_names_response(bytes: PackedByteArray) -> Variant:
return city_names_response_from_raw(decode_raw(bytes))
## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/
## citynames). Returns {kind, value} with kind "snapshot" | "atlas" |
## "starmap" | "citynames" | "unknown" — all four response kinds are msgpack
## maps, so they are told apart by field. Checked most-specific-first:
## StarMapResponse is the only kind with "data" and no "body_id";
## CityNamesResponse is the only kind with "cities"; anything else carrying
## "status" is AtlasLayerResponse. 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"):
if raw.has("entities"):
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}
if raw.has("data") and not raw.has("body_id"):
return {"kind": "starmap", "value": star_map_response_from_raw(raw)}
if raw.has("cities"):
return {"kind": "citynames", "value": city_names_response_from_raw(raw)}
if raw.has("status"):
return {"kind": "atlas", "value": atlas_response_from_raw(raw)}
return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)}