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