From 845737617c9491e72d374478debdd685aa915e16 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 14 Jul 2026 15:45:54 +0200 Subject: [PATCH] feat(client): atlas roads/settlements overlays + legend + server-API data delivery (T-960, T-949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/scripts/autoloads/sim_bridge.gd | 75 ++++- client/scripts/protocol/protocol.gd | 118 +++++++- .../msgpack/atlas_response_not_found.msgpack | 2 +- .../msgpack/atlas_response_pending.msgpack | 2 +- .../msgpack/atlas_response_ready.msgpack | Bin 359 -> 737 bytes .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 356 -> 347 bytes .../snapshot_boundary_tick_127.msgpack | Bin 356 -> 347 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 360 -> 351 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 364 -> 355 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 358 -> 349 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 356 -> 347 bytes .../fixtures/msgpack/snapshot_full.msgpack | Bin 1216 -> 1207 bytes .../fixtures/msgpack/snapshot_minimal.msgpack | Bin 457 -> 448 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 761 -> 752 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 454 -> 445 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 457 -> 448 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 621 -> 612 bytes .../snapshot_with_bookmark_catalog.msgpack | Bin 599 -> 590 bytes client/tests/test_atlas_city_names.gd | 178 +++++++++++ client/tests/test_atlas_data_delivery.gd | 236 +++++++++++++++ client/tests/test_atlas_overlays.gd | 192 ++++++++++++ client/tests/test_system_index.gd | 117 ++++++++ client/ui/implant/apps/atlas/atlas_app.gd | 24 +- .../implant/apps/atlas/atlas_legend_panel.gd | 161 ++++++++++ .../apps/atlas/atlas_marker_overlay.gd | 278 +++++++++++++++--- client/ui/implant/apps/atlas/atlas_viewer.gd | 161 ++++++++++ .../apps/economics/screens/overview_screen.gd | 17 +- client/ui/implant/widgets/system_index.gd | 76 +++-- 28 files changed, 1561 insertions(+), 76 deletions(-) create mode 100644 client/tests/test_atlas_city_names.gd create mode 100644 client/tests/test_atlas_data_delivery.gd create mode 100644 client/tests/test_system_index.gd create mode 100644 client/ui/implant/apps/atlas/atlas_legend_panel.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 1a2991111..2c9cb85a0 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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 diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 33e7fe19c..8b600196e 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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)} diff --git a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack index aa580b491..ac84932a1 100644 --- a/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_not_found.msgpack @@ -1 +1 @@ -body_idghoststatusNotFoundlayer1 \ No newline at end of file +body_idghoststatusNotFoundlayer1district_gridroad_graphsettlements \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack index 1606cc5ab..97399184a 100644 --- a/client/tests/fixtures/msgpack/atlas_response_pending.msgpack +++ b/client/tests/fixtures/msgpack/atlas_response_pending.msgpack @@ -1 +1 @@ -body_idGJ1cstatusPendinglayer1 \ No newline at end of file +body_idGJ1cstatusPendinglayer1district_gridroad_graphsettlements \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/atlas_response_ready.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready.msgpack index bc1ba44c8f4ee4ec2974f71318ee4065ea0bd614..ec4101d911b251af9aa0ad2183fa188ad28398c4 100644 GIT binary patch delta 391 zcmZ{gK}y3=5Qa^`oeLK^K+oaIZn6_Z4-kgDX*>Gfyzt&AC_+&od*clxp^{+08RUfm;8)DU9}b#CyE< Hm)C9pgR7|r delta 10 RcmaFJ`kaZec_U*QBLEg~18o2R diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 6d9f70243b0c74ebe7a3d6b9ef55b0b022b69486..149c5059afc102f08a7a1a02cccb0f0678818d57 100644 GIT binary patch delta 12 TcmaFDbeoC!9)r+Eo(M(&9ghRe delta 21 ccmcc3^n{7$9)s}mvecsD%=|p@jm!~@09vUBk^lez diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 6b2f4c4f5f4d20c6c275ab2d41487072f7c3eddc..7e7ff03ecb622cde8ec496a45f3c6ab7d1f8ba7b 100644 GIT binary patch delta 12 TcmaFDbeoC!9)r+Eo(M(&9ghRe delta 21 ccmcc3^n{7$9)s}mvecsD%=|p@jm!~@09vUBk^lez diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 376daff0d8dd08772c78ad348200811af56f2185..0f7243d3e95d7a172e6b43f7e5014dad2ed3b1ec 100644 GIT binary patch delta 12 TcmaFCbf1a&9)r+Eo)|^|9q$9# delta 21 ccmcc5^n!`!9)s}mvecsD%=|p@jm$BO09#Q9o&W#< diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 54bd601ac2a5b688c0544d6ce7ceddcfc982f7c4..5a5ba8c565ea2edbf5433f048838d9d3344f6ad4 100644 GIT binary patch delta 12 TcmaFE^q7hH9)r+Eo&-h!9!~@1 delta 21 ccmaFN^oEJ&9)s}mvecsD%=|p@jm!y*09*M7ssI20 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index e648490a96b16daef3007f3784a8dfbd1a5b5793..aa816e294b453860a4fc9f5a490a7ca40b15c5a0 100644 GIT binary patch delta 12 TcmaFHbeDSO5S3 diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index b35bca3775262dc492104460966f8c0ffc55d145..7c826a526cd8d96746181f7cd9c3c47ee0268fc3 100644 GIT binary patch delta 12 TcmX@cyqB5z9)r+Eo?VOp8`A^z delta 21 ccmdnXe2kgr9)s}mvecsD%=|p@jm*0k0aoJ&x&QzG diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 31b2449457d786ebb700a46f33afb93e56170463..cc6f022e970d22fee93b56d0961f9340af6ac32e 100644 GIT binary patch delta 12 TcmX@fe1Mtx9)r+Ep1q6!92*1v delta 21 ccmX@We3F^x9)s}mvecsD%=|p@jm&!)0asxM!vFvP diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 6c1f4b7ca7494993f077806f1ab25218ad2de263..ef9d0bfec68c65c64662be66761f412f8c05aee2 100644 GIT binary patch delta 12 TcmaFM@`Q!?9)r+Eo String: + var project_root: String = ( + ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() + ) + return project_root + "/wiki/star-systems/GJ-0/bodies/GJ0d/heightmap.png" + + +## add_child fires _ready() synchronously (matches test_implant_app_lifecycle.gd's +## established pattern) — builds _city_panel/_overlay_node/etc. so show_body() +## doesn't null-deref. +func _make_viewer() -> AtlasViewer: + var v := AtlasViewer.new() + add_child(v) + return v + + +func test_sol_body_uses_legacy_synchronous_markers_read() -> void: + var v := _make_viewer() + var body := {"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()} + var system := {"system_id": SOL_SYSTEM_ID} + v.show_body(body, system) + + # The legacy path is synchronous — cities/rivers/etc. are populated + # immediately, no bridge round-trip needed. + var markers: Dictionary = v.get_markers() + assert_bool(markers.has("cities")).override_failure_message( + "Sol body must keep the legacy full-geometry markers.json read (D-236)" + ).is_true() + assert_int((markers.get("cities", []) as Array).size()).override_failure_message( + "Sol's real markers.json must yield at least one city" + ).is_greater(0) + assert_bool(v.has_pending_city_names_request()).override_failure_message( + "Sol bodies must never queue a CityNamesRequest (D-236/T-1073 exception)" + ).is_false() + + v.queue_free() + + +func test_non_sol_body_does_not_synchronously_populate_markers() -> void: + var v := _make_viewer() + v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID}) + + # T-949: no more direct file read — markers stay empty until the async + # CityNamesResponse arrives (never, in test mode — SimBridge has no server). + assert_that(v.get_markers()).override_failure_message( + "non-Sol bodies must not synchronously populate markers from a file read" + ).is_equal({}) + assert_bool(v.has_pending_city_names_request()).override_failure_message( + "non-Sol bodies must queue a CityNamesRequest for their own body" + ).is_true() + + v.queue_free() + + +func test_city_names_received_ready_stores_under_dedicated_key() -> void: + var v := _make_viewer() + v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID}) + + v._on_city_names_received( + { + "body_id": NON_SOL_BODY_ID, + "status": "Ready", + "cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}], + } + ) + + # Stored under "city_names", NOT the legacy top-level "cities" key — + # CityNameEntry has no position, so merging it into "cities" would make + # _draw_cities()/_find_city_at() plot every entry at Vector2.ZERO. + var markers: Dictionary = v.get_markers() + assert_that(markers.get("cities", [])).override_failure_message( + "non-Sol markers must NOT expose position-less entries under the top-level 'cities' key" + ).is_equal([]) + assert_int((markers.get("city_names", []) as Array).size()).is_equal(1) + assert_str((markers.get("city_names", [])[0] as Dictionary).get("name")).is_equal("Ridgeback") + assert_bool(v.has_pending_city_names_request()).is_false() + + v.queue_free() + + +func test_city_names_received_sol_excluded_falls_back_to_legacy_read() -> void: + var v := _make_viewer() + # Body claims to be non-Sol at request time, but the server's own D-236 + # backstop says otherwise — the defensive fallback must still work even + # though this shouldn't happen given atlas_viewer.gd's own SOL_SYSTEM_ID + # guard (belt-and-suspenders per dudley-atlas-server's contract note). + v.show_body( + {"body_id": SOL_BODY_ID, "terrain_reference": _sol_heightmap_ref()}, + {"system_id": NON_SOL_SYSTEM_ID} + ) + assert_bool(v.has_pending_city_names_request()).is_true() + + v._on_city_names_received({"body_id": SOL_BODY_ID, "status": "SolExcluded", "cities": []}) + + var markers: Dictionary = v.get_markers() + assert_bool(markers.has("cities")).override_failure_message( + "SolExcluded must fall back to the legacy full-geometry markers.json read" + ).is_true() + assert_int((markers.get("cities", []) as Array).size()).is_greater(0) + assert_bool(v.has_pending_city_names_request()).is_false() + + v.queue_free() + + +func test_city_names_received_ignores_stale_body_response() -> void: + var v := _make_viewer() + v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID}) + + # A response for a DIFFERENT body (the viewer navigated away while the + # request was in flight) must not clobber state. + v._on_city_names_received( + {"body_id": "some_other_body", "status": "Ready", "cities": [{"name": "Nope"}]} + ) + + assert_that(v.get_markers()).override_failure_message( + "a stale-body CityNamesResponse must be ignored" + ).is_equal({}) + assert_bool(v.has_pending_city_names_request()).override_failure_message( + "a stale-body response must not clear the real pending request" + ).is_true() + + v.queue_free() + + +func test_city_names_received_error_status_leaves_markers_empty() -> void: + var v := _make_viewer() + v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID}) + + v._on_city_names_received( + {"body_id": NON_SOL_BODY_ID, "status": {"Error": "db unavailable"}, "cities": []} + ) + + assert_that(v.get_markers()).override_failure_message( + "an Error status must leave markers empty (#960/D-191's empty-markers case)" + ).is_equal({}) + assert_bool(v.has_pending_city_names_request()).is_false() + + v.queue_free() + + +func test_connection_state_change_does_not_crash_or_clear_pending_marker() -> void: + var v := _make_viewer() + v.show_body({"body_id": NON_SOL_BODY_ID}, {"system_id": NON_SOL_SYSTEM_ID}) + assert_bool(v.has_pending_city_names_request()).is_true() + + # SimBridge is in test_mode (no live connection) so request_city_names() + # no-ops either way — this proves the handler doesn't crash, and that only + # a real CityNamesResponse (not the mere state transition) clears the + # pending marker. + v._on_connection_state_changed( + SimBridge.ConnectionState.CONNECTING, SimBridge.ConnectionState.CONNECTED + ) + assert_bool(v.has_pending_city_names_request()).is_true() + + v.queue_free() diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd new file mode 100644 index 000000000..811414772 --- /dev/null +++ b/client/tests/test_atlas_data_delivery.gd @@ -0,0 +1,236 @@ +## T-949 tests: Atlas data delivery migration — StarMapRequest/StarMapResponse +## and CityNamesRequest/CityNamesResponse (protocol.gd encode/decode, +## decode_inbound's frame classification, and SimBridge signal routing). +## +## Wire shapes pinned to dudley-atlas-server's contract (2026-07-14): +## AtlasLayerResponse gains road_graph/settlements (Option, "None until ready" +## like district_grid); StarMapResponse is {status, data} (data = a verbatim +## MessagePack re-encoding of star_map_data.json, unwrapped by +## star_map_response_from_raw so callers see {"nodes": [...]} directly); +## CityNamesResponse is {body_id, status, cities: [{city_id, name, is_capital}]} +## with a SolExcluded status as the server-side Sol backstop (D-236). +class_name TestAtlasDataDelivery +extends GdUnitTestSuite + + +# ============================================================================= +# Encode/decode round-trips +# ============================================================================= + + +func test_encode_star_map_request_carries_discriminator() -> void: + var bytes := Protocol.encode_star_map_request() + assert_int(bytes.size()).is_greater(0) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status == null).is_true() + assert_that(decoded.value.get("star_map")).is_equal(true) + + +func test_star_map_response_from_raw_unwraps_data_to_nodes() -> void: + var nodes := [ + {"system_id": "GJ 71", "proper_name": "Tau Ceti"}, + {"system_id": "GJ 0", "proper_name": "Sol"}, + ] + var raw := {"status": "Ready", "data": {"_meta": {"system_count": 2}, "nodes": nodes}} + var response: Variant = Protocol.star_map_response_from_raw(raw) + assert_that(response).is_not_null() + assert_str((response as Dictionary).get("status")).is_equal("Ready") + assert_that((response as Dictionary).get("nodes")).is_equal(nodes) + + +func test_star_map_response_from_raw_error_status_yields_no_nodes() -> void: + var raw := {"status": {"Error": "db unavailable"}, "data": null} + var response: Variant = Protocol.star_map_response_from_raw(raw) + assert_that(response).is_not_null() + assert_str((response as Dictionary).get("status")).is_equal("Error") + assert_str((response as Dictionary).get("error")).is_equal("db unavailable") + assert_that((response as Dictionary).get("nodes")).is_equal([]) + + +func test_star_map_response_from_raw_rejects_shape_without_status() -> void: + assert_that(Protocol.star_map_response_from_raw({"data": {}})).is_null() + assert_that(Protocol.star_map_response_from_raw("not a dict")).is_null() + + +func test_decode_star_map_response_bytes_round_trip() -> void: + var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}} + var encoded = Messagepack.encode(raw) + var response: Variant = Protocol.decode_star_map_response(encoded.value) + assert_that(response).is_not_null() + assert_that((response as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"]) + + +func test_encode_city_names_request_carries_discriminator_and_body_id() -> void: + var bytes := Protocol.encode_city_names_request("GJ903b") + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status == null).is_true() + assert_that(decoded.value.get("city_names")).is_equal(true) + assert_that(decoded.value.get("body_id")).is_equal("GJ903b") + + +func test_city_names_response_from_raw_round_trips_ready() -> void: + var cities := [{"city_id": 1, "name": "Ridgeback", "is_capital": false}] + var raw := {"body_id": "GJ903b", "status": "Ready", "cities": cities} + var response: Variant = Protocol.city_names_response_from_raw(raw) + assert_that(response).is_not_null() + assert_str((response as Dictionary).get("body_id")).is_equal("GJ903b") + assert_str((response as Dictionary).get("status")).is_equal("Ready") + assert_that((response as Dictionary).get("cities")).is_equal(cities) + + +func test_city_names_response_from_raw_sol_excluded() -> void: + var raw := {"body_id": "GJ0d", "status": "SolExcluded", "cities": []} + var response: Variant = Protocol.city_names_response_from_raw(raw) + assert_that(response).is_not_null() + assert_str((response as Dictionary).get("status")).is_equal("SolExcluded") + + +func test_city_names_response_from_raw_requires_body_id_and_status() -> void: + assert_that(Protocol.city_names_response_from_raw({"body_id": "GJ903b"})).is_null() + assert_that(Protocol.city_names_response_from_raw({"status": "Ready"})).is_null() + + +# ============================================================================= +# decode_inbound classification — snapshot / atlas / starmap / citynames +# ============================================================================= + + +func test_decode_inbound_classifies_snapshot() -> void: + var raw := {"tick": 1, "entities": []} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound.kind).is_equal("snapshot") + + +func test_decode_inbound_classifies_atlas() -> void: + var raw := {"body_id": "GJ1c", "status": "Ready", "layer1": null, "district_grid": null} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound.kind).is_equal("atlas") + + +func test_decode_inbound_classifies_starmap() -> void: + # StarMapResponse is the only kind with "data" and no "body_id". + var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71"}]}} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound.kind).is_equal("starmap") + assert_that((inbound.value as Dictionary).get("nodes")).is_equal(raw["data"]["nodes"]) + + +func test_decode_inbound_classifies_citynames() -> void: + # CityNamesResponse is the only kind with "cities" — must classify as + # citynames, NOT atlas, even though both carry status + body_id. + var raw := {"body_id": "GJ903b", "status": "Ready", "cities": [{"city_id": 1, "name": "Ridgeback"}]} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound.kind).is_equal("citynames") + assert_that((inbound.value as Dictionary).get("body_id")).is_equal("GJ903b") + + +func test_atlas_response_road_graph_and_settlements_passthrough() -> void: + var road_graph := { + "nodes": [{"position": [1, 2], "kind": "Settlement", "city_id": 5}], + "edges": + [ + { + "from": 0, + "to": 1, + "path": [[1, 2], [3, 4]], + "maintenance": "Trade", + "is_rail": false, + "named_route_id": null, + } + ], + } + var settlements := { + "settlements": + [ + { + "city_id": 5, + "name": "Ridgeback", + "position": [1, 2], + "size_class": "Minor", + "is_capital": false, + "is_port": false, + } + ] + } + var raw := { + "body_id": "GJ1c", + "status": "Ready", + "road_graph": road_graph, + "settlements": settlements, + } + var decoded: Variant = Protocol.atlas_response_from_raw(raw) + assert_that(decoded).is_not_null() + assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph) + assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements) + + +func test_atlas_response_road_graph_and_settlements_default_null() -> void: + # A Ready response before road_graph/settlements land on a given server + # build must decode cleanly with both fields absent, not error. + var decoded: Variant = Protocol.atlas_response_from_raw({"body_id": "GJ1c", "status": "Ready"}) + assert_that(decoded).is_not_null() + assert_that((decoded as Dictionary).get("road_graph")).is_null() + assert_that((decoded as Dictionary).get("settlements")).is_null() + + +# ============================================================================= +# SimBridge routing — receive_bytes emits the right signal with the right value +# ============================================================================= + + +func test_sim_bridge_routes_star_map_response_to_signal() -> void: + var received: Array = [] + var handler := func(response: Dictionary) -> void: received.append(response) + SimBridge.star_map_received.connect(handler) + + var raw := {"status": "Ready", "data": {"nodes": [{"system_id": "GJ 71", "proper_name": "Tau Ceti"}]}} + var encoded = Messagepack.encode(raw) + SimBridge.receive_bytes(encoded.value) + + SimBridge.star_map_received.disconnect(handler) + + assert_int(received.size()).is_equal(1) + assert_that(received[0].get("nodes")).is_equal(raw["data"]["nodes"]) + + +func test_sim_bridge_routes_city_names_response_to_signal() -> void: + var received: Array = [] + var handler := func(response: Dictionary) -> void: received.append(response) + SimBridge.city_names_received.connect(handler) + + var raw := { + "body_id": "GJ903b", + "status": "Ready", + "cities": [{"city_id": 1, "name": "Ridgeback", "is_capital": false}], + } + var encoded = Messagepack.encode(raw) + SimBridge.receive_bytes(encoded.value) + + SimBridge.city_names_received.disconnect(handler) + + assert_int(received.size()).is_equal(1) + assert_that(received[0].get("body_id")).is_equal("GJ903b") + + +func test_sim_bridge_still_routes_snapshots_after_starmap_additions() -> void: + # Regression guard: adding the starmap/citynames branches must not steal + # ordinary snapshot frames (the 20 Hz hot path). + var raw := {"tick": 5, "entities": []} + var encoded = Messagepack.encode(raw) + SimBridge.receive_bytes(encoded.value) + assert_that(SimBridge._last_snapshot).is_not_null() + assert_int(SimBridge._last_snapshot.get("tick")).is_equal(5) + + +func test_request_star_map_is_noop_in_test_mode() -> void: + # SimBridge defaults to test_mode = true (SR_LIVE unset) — request_star_map + # must not error even with no live bridge/server. + SimBridge.request_star_map() + + +func test_request_city_names_is_noop_in_test_mode() -> void: + SimBridge.request_city_names("GJ903b") diff --git a/client/tests/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd index 15b76feb3..045a543f2 100644 --- a/client/tests/test_atlas_overlays.gd +++ b/client/tests/test_atlas_overlays.gd @@ -4,6 +4,11 @@ class_name TestAtlasOverlays extends GdUnitTestSuite +# atlas_legend_panel.gd has no class_name (matches atlas_overlay_bar.gd, review +# #8), so its GENERATION_LEGEND spec table is read off the preloaded Script +# resource rather than a global identifier. +const LegendPanelScript := preload("res://ui/implant/apps/atlas/atlas_legend_panel.gd") + func test_generation_overlays_registered() -> void: var ids: Array = [] @@ -53,3 +58,190 @@ func test_implant_pending_start_stop() -> void: assert_bool(p.visible).is_true() p.stop() assert_bool(p.visible).is_false() + + +# ============================================================================= +# T-960: gen_l2_roads / gen_l3_settlements overlays +# ============================================================================= + + +func test_l2_l3_overlays_registered() -> void: + var ids: Array = [] + for d: Dictionary in AtlasViewer.OVERLAY_DEFS: + ids.append(d["id"]) + assert_that(ids).contains(["gen_l2_roads", "gen_l3_settlements"]) + + +## RoadGraphLayer shape pinned to dudley-atlas-server's contract (2026-07-14): +## nodes carry NO `degree` (trimmed as server-internal bookkeeping — the +## overlay derives it from edge endpoints instead, see _draw_gen_roads). +func test_road_graph_round_trips() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + assert_that(v.get_generation_road_graph()).is_null() + var road_graph := { + "nodes": + [ + {"city_id": 1, "position": [10, 20], "kind": "Settlement"}, + {"city_id": null, "position": [12, 22], "kind": "Waypoint"}, + ], + "edges": + [ + { + "from": 0, + "to": 1, + "path": [[10, 20], [12, 22]], + "maintenance": "Administrative", + "is_rail": false, + "named_route_id": null, + }, + ], + } + v.set_generation_road_graph(road_graph) + assert_that(v.get_generation_road_graph()).is_equal(road_graph) + # Round-trips through the protocol decode too (T-1046 district_grid precedent). + var decoded: Variant = Protocol.atlas_response_from_raw( + {"body_id": "GJ1c", "status": "Ready", "road_graph": road_graph} + ) + assert_that((decoded as Dictionary).get("road_graph")).is_equal(road_graph) + + +## SettlementLayer shape pinned to dudley-atlas-server's contract +## (2026-07-14): wrapped under "settlements" (not "cities"); size_class is +## categorical (Major/Standard/Minor), and is_capital is authored, not +## population-derived. +func test_settlements_round_trip() -> void: + var v: AtlasViewer = auto_free(AtlasViewer.new()) + assert_that(v.get_generation_settlements()).is_null() + var settlements := { + "settlements": + [ + { + "city_id": 1, + "name": "Ridgeback", + "position": [30, 40], + "size_class": "Minor", + "is_capital": false, + "is_port": false, + }, + { + "city_id": 2, + "name": "Capital City", + "position": [35, 45], + "size_class": "Major", + "is_capital": true, + "is_port": true, + }, + ], + } + v.set_generation_settlements(settlements) + assert_that(v.get_generation_settlements()).is_equal(settlements) + var decoded: Variant = Protocol.atlas_response_from_raw( + {"body_id": "GJ1c", "status": "Ready", "settlements": settlements} + ) + assert_that((decoded as Dictionary).get("settlements")).is_equal(settlements) + + +## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs +## generate_atlas_layer_response_fixtures, regenerated 2026-07-14 with T-960's +## road_graph/settlements populated) decoded through the actual client path — +## the strongest check that protocol.gd's decode matches the server's wire +## encoding, not just a hand-authored Dictionary the client wrote itself. +func test_atlas_response_ready_fixture_decodes_road_graph_and_settlements() -> void: + var path := "res://tests/fixtures/msgpack/atlas_response_ready.msgpack" + var f := FileAccess.open(path, FileAccess.READ) + assert_that(f).is_not_null() + var bytes := f.get_buffer(f.get_length()) + f.close() + + var decoded: Variant = Protocol.decode_atlas_layer_response(bytes) + assert_that(decoded).is_not_null() + var response: Dictionary = decoded + assert_str(response.get("body_id")).is_equal("GJ1c") + assert_str(response.get("status")).is_equal("Ready") + + var road_graph: Dictionary = response.get("road_graph") + assert_that(road_graph).is_not_null() + assert_int((road_graph.get("nodes", []) as Array).size()).is_equal(2) + var edges: Array = road_graph.get("edges", []) + assert_int(edges.size()).is_equal(1) + assert_str((edges[0] as Dictionary).get("maintenance")).is_equal("Administrative") + assert_bool((edges[0] as Dictionary).get("is_rail")).is_false() + + var settlements: Dictionary = response.get("settlements") + assert_that(settlements).is_not_null() + var entries: Array = settlements.get("settlements", []) + assert_int(entries.size()).is_equal(2) + var capital: Dictionary = entries[0] + assert_str(capital.get("name")).is_equal("Port Aldren") + assert_str(capital.get("size_class")).is_equal("Major") + assert_bool(capital.get("is_capital")).is_true() + var minor: Dictionary = entries[1] + assert_str(minor.get("name")).is_equal("Farmstead Rell") + assert_str(minor.get("size_class")).is_equal("Minor") + assert_bool(minor.get("is_capital")).is_false() + + +# ============================================================================= +# D-226 item 3: generation legend panel +# ============================================================================= + + +## Data-driven claim check: every legend entry must reference a real overlay +## id, so a typo or a stale entry can't silently produce a dead legend +## section that never appears. +func test_generation_legend_entries_reference_real_overlay_ids() -> void: + var overlay_ids: Array = [] + for d: Dictionary in AtlasViewer.OVERLAY_DEFS: + overlay_ids.append(d["id"]) + for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND: + assert_that(overlay_ids).contains([spec.get("overlay_id", "")]) + assert_bool((spec.get("rows", []) as Array).is_empty()).override_failure_message( + "legend entry '%s' has no rows" % spec.get("title", "?") + ).is_false() + + +## Every gen_* toggleable overlay should have at least one legend entry so +## toggling it on never shows silent, unexplained map content. +func test_every_gen_overlay_has_a_legend_entry() -> void: + var legend_overlay_ids: Array = [] + for spec: Dictionary in LegendPanelScript.GENERATION_LEGEND: + legend_overlay_ids.append(spec.get("overlay_id", "")) + for d: Dictionary in AtlasViewer.OVERLAY_DEFS: + var overlay_id: String = d["id"] + if overlay_id.begins_with("gen_"): + assert_that(legend_overlay_ids).override_failure_message( + "overlay '%s' has no GENERATION_LEGEND entry" % overlay_id + ).contains([overlay_id]) + + +## Full-lifecycle test (add_child fires _ready(), which builds the legend +## panel) — invisible at rest, since no generation overlay starts active. +func test_legend_panel_hidden_by_default() -> void: + var v: AtlasViewer = AtlasViewer.new() + add_child(v) + assert_bool(v._legend_panel.visible).override_failure_message( + "legend panel must be invisible when no generation overlay is active" + ).is_false() + v.queue_free() + + +## Toggling a gen_* overlay on must reveal the legend and populate its rows; +## toggling it back off must hide it again (invisible-when-not-needed, D-226). +func test_legend_panel_shows_active_overlay_and_hides_when_toggled_off() -> void: + var v: AtlasViewer = AtlasViewer.new() + add_child(v) + + v.set_overlay_visible("gen_l3_settlements", true) + assert_bool(v._legend_panel.visible).override_failure_message( + "legend panel must show once a generation overlay is toggled on" + ).is_true() + assert_int(v._legend_panel.get_implant_children().size()).override_failure_message( + "legend panel must have content rows once populated" + ).is_greater(0) + + v.set_overlay_visible("gen_l3_settlements", false) + assert_bool(v._legend_panel.visible).override_failure_message( + "legend panel must hide again once its only active overlay is toggled off" + ).is_false() + + v.queue_free() diff --git a/client/tests/test_system_index.gd b/client/tests/test_system_index.gd new file mode 100644 index 000000000..768b7cd47 --- /dev/null +++ b/client/tests/test_system_index.gd @@ -0,0 +1,117 @@ +## T-949 tests: SystemIndex — the cached StarMapResponse reader that replaces +## the direct star_map_data.json FileAccess read (atlas_app.gd's Reach screen, +## economics OverviewScreen). +## +## Fixtures use {"status": "Ready", "nodes": [...]} — the shape +## protocol.gd's star_map_response_from_raw hands to ingest() AFTER unwrapping +## the real StarMapResponse's status/data envelope (dudley-atlas-server +## contract, 2026-07-14); SystemIndex itself never sees "data" directly. +## +## SystemIndex's cache is process-global (static var — there is no per-call +## instance), so these tests are written to be order-independent: every test +## calls ingest() with its own "Ready" fixture first, which fully OVERWRITES +## the cache rather than appending, so prior test pollution can't leak into +## an assertion (see system_index.gd's ingest()). +class_name TestSystemIndex +extends GdUnitTestSuite + + +func test_ingest_sorts_by_proper_name() -> void: + SystemIndex.ingest( + { + "status": "Ready", + "nodes": + [ + {"system_id": "GJ 71", "proper_name": "Tau Ceti"}, + {"system_id": "GJ 0", "proper_name": "Sol"}, + {"system_id": "GJ 903", "proper_name": "Aldrin"}, + ] + } + ) + var sorted: Array = SystemIndex.get_sorted_systems() + assert_int(sorted.size()).is_equal(3) + assert_str(sorted[0].get("proper_name")).is_equal("Aldrin") + assert_str(sorted[1].get("proper_name")).is_equal("Sol") + assert_str(sorted[2].get("proper_name")).is_equal("Tau Ceti") + + +func test_ingest_falls_back_to_system_id_when_proper_name_missing() -> void: + SystemIndex.ingest( + { + "status": "Ready", + "nodes": + [ + {"system_id": "GJ 999"}, + {"system_id": "GJ 001"}, + ] + } + ) + var sorted: Array = SystemIndex.get_sorted_systems() + assert_int(sorted.size()).is_equal(2) + assert_str(sorted[0].get("system_id")).is_equal("GJ 001") + assert_str(sorted[1].get("system_id")).is_equal("GJ 999") + + +func test_ingest_drops_nodes_with_empty_system_id() -> void: + SystemIndex.ingest( + { + "status": "Ready", + "nodes": + [ + {"system_id": "GJ 71", "proper_name": "Tau Ceti"}, + {"proper_name": "No id"}, + {"system_id": "", "proper_name": "Empty id"}, + ] + } + ) + var sorted: Array = SystemIndex.get_sorted_systems() + assert_int(sorted.size()).is_equal(1) + assert_str(sorted[0].get("system_id")).is_equal("GJ 71") + + +func test_ingest_marks_loaded() -> void: + SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]}) + assert_bool(SystemIndex.is_loaded()).is_true() + + +func test_ingest_overwrites_not_appends() -> void: + SystemIndex.ingest( + {"status": "Ready", "nodes": [{"system_id": "GJ 1"}, {"system_id": "GJ 2"}]} + ) + assert_int(SystemIndex.get_sorted_systems().size()).is_equal(2) + SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 3"}]}) + # A second StarMapResponse (e.g. a reconnect) replaces the cache wholesale + # rather than accumulating duplicates. + assert_int(SystemIndex.get_sorted_systems().size()).is_equal(1) + assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 3") + + +func test_ingest_ignores_non_ready_status() -> void: + # Seed a known-good cache first... + SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 1"}]}) + # ...an Error response (protocol.gd already reduces it to nodes=[]) must + # NOT wipe the cache down to empty — the last good data survives. + SystemIndex.ingest({"status": "Error", "error": "db unavailable", "nodes": []}) + assert_int(SystemIndex.get_sorted_systems().size()).override_failure_message( + "a non-Ready StarMapResponse must not overwrite the cache" + ).is_equal(1) + assert_str(SystemIndex.get_sorted_systems()[0].get("system_id")).is_equal("GJ 1") + + +func test_request_refresh_retries_after_a_failed_ingest() -> void: + # request_refresh() is a no-op in test mode either way (SimBridge has no + # live connection), so this only proves the _requested/_loaded bookkeeping + # doesn't get stuck: a failed ingest must clear _requested so a later + # request_refresh() is willing to ask again (not just silently no-op + # forever because a prior request was already "in flight"). + SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 1"}]}) + assert_bool(SystemIndex.is_loaded()).is_true() + SystemIndex.request_refresh() # no-op — already loaded + + +func test_request_refresh_does_not_crash_when_already_loaded() -> void: + SystemIndex.ingest({"status": "Ready", "nodes": [{"system_id": "GJ 71"}]}) + # Idempotent no-op once loaded — must not error (SimBridge is in + # test_mode, so request_star_map() would no-op regardless). + SystemIndex.request_refresh() + assert_bool(SystemIndex.is_loaded()).is_true() diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 8b5bdbf83..85fa9a3ec 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -22,6 +22,10 @@ func _ready() -> void: func on_install() -> void: _load_system_data() + # T-949: the star map now arrives over the bridge, possibly after this app + # is already installed (or before the handshake completes at all) — refresh + # once it lands instead of assuming _load_system_data's first call has data. + SimBridge.star_map_received.connect(_on_star_map_received) var implant_theme = load("res://ui/implant/default_implant.tres") _reach_screen = ReachScreen.new() @@ -72,7 +76,10 @@ func _handle_key(event: InputEventKey) -> void: KEY_M: HudGroups.close_app() KEY_ESCAPE: - if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()): + var has_open_panel: bool = _system_screen and ( + _system_screen.has_body_panel_open() or _system_screen.has_station_panel_open() + ) + if current_screen_id() == "system" and has_open_panel: _system_screen.close_panels() elif current_screen_id() == "reach": HudGroups.close_app() @@ -134,6 +141,19 @@ func _forward_economics_link(system_id: String) -> void: economics_link_requested.emit(system_id) +## T-949: the star map arrived — cache it, rebuild the local system +## list/lookup, and push it into whichever screens already exist. set_systems() +## is safe to call again after initial setup (ReachScreen/SystemScreen both +## just recompute their layout from the new data). +func _on_star_map_received(response: Dictionary) -> void: + SystemIndex.ingest(response) + _load_system_data() + if _reach_screen: + _reach_screen.set_systems(_systems, _system_lookup) + if _system_screen: + _system_screen.set_systems(_systems) + + # ============================================================================= # Helpers # ============================================================================= @@ -147,6 +167,8 @@ func _system_idx_by_id(system_id: String) -> int: func _load_system_data() -> void: + SystemIndex.request_refresh() _systems = SystemIndex.get_sorted_systems() + _system_lookup.clear() for node: Dictionary in _systems: _system_lookup[node.get("system_id", "")] = node diff --git a/client/ui/implant/apps/atlas/atlas_legend_panel.gd b/client/ui/implant/apps/atlas/atlas_legend_panel.gd new file mode 100644 index 000000000..606049044 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_legend_panel.gd @@ -0,0 +1,161 @@ +extends ImplantPanel + +## Left-side generation-overlay legend (D-226 item 3, T-960) — the shape/color +## key for the generation overlay group (attractor types, sub-biome colors, +## district morphology, road/rail authority + style, settlement size/capital). +## +## This script has no `class_name` on purpose, mirroring atlas_overlay_bar.gd +## (review #8 there): the owner (AtlasViewer) passes the viewer reference to +## _init(), and a `class_name` + required-arg _init() combo is a Godot editor +## footgun. Instance it via +## load("res://ui/implant/apps/atlas/atlas_legend_panel.gd").new(self). +## +## Data-driven (GENERATION_LEGEND below): one spec entry per generation +## overlay id — multiple entries may share an id (e.g. gen_l1_attractors has +## both a shape key and a color key, D-226's shape-vs-color separation taught +## the same way here as it is drawn on the map). Adding a future layer's +## legend is a new table row, never a layout change. refresh() shows only the +## sections whose overlay is currently toggled on, and hides the whole panel +## when none are active (invisible when not needed). + +const PANEL_MARGIN: float = 16.0 +const LEGEND_PANEL_WIDTH: float = 260.0 + +# Generation overlay colors (T-960) — kept in sync with the actual render +# values in atlas_marker_overlay.gd; duplicated rather than cross-referenced, +# matching the existing COLOR_ROAD/COLOR_RAIL/COLOR_CITY precedent shared +# between atlas_viewer.gd and atlas_marker_overlay.gd (each file owns its own +# reading of the palette: this one for the legend, the marker overlay for the +# draw calls). +const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85) +const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85) +const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85) +const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85) +const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65) +const COLOR_SETTLEMENT_GEN: Color = Color(0.94, 0.82, 0.38, 1.0) +const COLOR_SETTLEMENT_CAPITAL_GEN: Color = Color(1.0, 0.92, 0.55, 1.0) + +## One entry per generation-overlay id. "color": Color.TRANSPARENT means +## "shape/style carries the meaning here, let the theme's dim text color +## apply" — used for every row where color is NOT the encoded axis. +const GENERATION_LEGEND: Array = [ + { + "overlay_id": "gen_l1_rivers", + "title": "RIVERS — L1", + "rows": [ + {"glyph": "━", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "channel / confluence"}, + {"glyph": "◎", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "sea mouth"}, + ], + }, + { + "overlay_id": "gen_l1_basins", + "title": "DRAINAGE BASINS — L1", + "rows": [ + {"glyph": "▭", "color": Color(0.45, 0.65, 0.85, 0.70), "label": "basin fill + boundary"}, + ], + }, + { + "overlay_id": "gen_l1_attractors", + "title": "ATTRACTORS — L1 (shape = type)", + "rows": [ + {"glyph": "●", "color": Color.TRANSPARENT, "label": "river mouth"}, + {"glyph": "◑", "color": Color.TRANSPARENT, "label": "coastal access"}, + {"glyph": "◆", "color": Color.TRANSPARENT, "label": "river crossing"}, + {"glyph": "▼", "color": Color.TRANSPARENT, "label": "valley floor"}, + {"glyph": "▲", "color": Color.TRANSPARENT, "label": "pass entrance"}, + {"glyph": "○", "color": Color.TRANSPARENT, "label": "lake shore"}, + {"glyph": "■", "color": Color.TRANSPARENT, "label": "plain center"}, + ], + }, + { + "overlay_id": "gen_l1_attractors", + "title": "SUB-BIOME — L1 (color)", + "rows": [ + {"glyph": "●", "color": Color(0.25, 0.72, 0.65, 0.90), "label": "tropical / coastal"}, + {"glyph": "●", "color": Color(0.45, 0.68, 0.45, 0.90), "label": "temperate"}, + {"glyph": "●", "color": Color(0.78, 0.62, 0.35, 0.90), "label": "arid"}, + {"glyph": "●", "color": Color(0.52, 0.58, 0.72, 0.90), "label": "alpine"}, + {"glyph": "●", "color": Color(0.40, 0.60, 0.52, 0.90), "label": "wetland"}, + {"glyph": "●", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"}, + ], + }, + { + "overlay_id": "gen_district", + "title": "DISTRICT MORPHOLOGY — coarse", + "rows": [ + {"glyph": "▦", "color": Color.TRANSPARENT, "label": "terrain-colored fill (17 zones)"}, + ], + }, + { + "overlay_id": "gen_l2_roads", + "title": "ROADS/RAIL — L2 (color = authority)", + "rows": [ + {"glyph": "━", "color": COLOR_ROAD_ADMINISTRATIVE, "label": "administrative"}, + {"glyph": "━", "color": COLOR_ROAD_CORPORATE, "label": "corporate"}, + {"glyph": "━", "color": COLOR_ROAD_COMMUNAL, "label": "communal"}, + {"glyph": "━", "color": COLOR_ROAD_TRADE, "label": "trade"}, + {"glyph": "━", "color": COLOR_ROAD_ABANDONED, "label": "abandoned"}, + ], + }, + { + "overlay_id": "gen_l2_roads", + "title": "LINE STYLE (road vs rail)", + "rows": [ + {"glyph": "━", "color": Color.TRANSPARENT, "label": "road (solid)"}, + {"glyph": "┄", "color": Color.TRANSPARENT, "label": "rail (dashed)"}, + {"glyph": "◇", "color": Color.TRANSPARENT, "label": "junction (3+ ways)"}, + ], + }, + { + "overlay_id": "gen_l3_settlements", + "title": "SETTLEMENTS — L3 (size = population)", + "rows": [ + {"glyph": "●", "color": COLOR_SETTLEMENT_GEN, "label": "settlement"}, + {"glyph": "★", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"}, + ], + }, +] + +var _viewer = null # AtlasViewer (untyped to avoid cyclic ref) + + +func _init(viewer_ref = null) -> void: + _viewer = viewer_ref + custom_minimum_size.x = LEGEND_PANEL_WIDTH + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + + +func reposition() -> void: + position = Vector2(PANEL_MARGIN, 60.0) + + +## Rebuilds from GENERATION_LEGEND, showing only the sections whose overlay is +## currently toggled on — invisible when no generation overlay is active, +## updates every time AtlasViewer.set_overlay_visible() runs. +func refresh() -> void: + if _viewer == null: + return + var active_specs: Array = [] + for spec: Dictionary in GENERATION_LEGEND: + if _viewer.is_overlay_visible(str(spec.get("overlay_id", ""))): + active_specs.append(spec) + + clear() + visible = not active_specs.is_empty() + if active_specs.is_empty(): + return + + add_component( + ImplantHeader.new("GENERATION LEGEND", "%d layer(s) active" % active_specs.size()) + ) + add_component(ImplantSeparator.new()) + for i: int in range(active_specs.size()): + var spec: Dictionary = active_specs[i] + add_component(ImplantTextBlock.new(str(spec.get("title", "")))) + for row: Dictionary in spec.get("rows", []): + var text: String = "%s %s" % [str(row.get("glyph", "-")), str(row.get("label", ""))] + add_component(ImplantDataRow.new(text, row.get("color", Color.TRANSPARENT))) + if i < active_specs.size() - 1: + add_component(ImplantSeparator.new()) + reposition() diff --git a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd index 038d6c780..f12011a6f 100644 --- a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd @@ -16,6 +16,8 @@ extends Node2D ## corp_presence (toggleable) Tier 1 corp dots placeholder ## stockpile_weeks (locked) gated by corporate contact ## production_vs_baseline (locked) gated by insider access +## gen_l2_roads (toggleable) T-960 — road/rail graph, color = authority +## gen_l3_settlements (toggleable) T-960 — settlement placements, size = population const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0) const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14) @@ -41,6 +43,75 @@ const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06) const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45) const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15 +## Sub-biome → marker color (Araminta's palette, D-226). Grouped pairs share a +## color since they read the same on the map; see _sub_biome_color. +const SUB_BIOME_COLORS: Dictionary = { + "TropicalWet": Color(0.25, 0.72, 0.65, 0.90), # teal — coastal/tropical + "CoastalLowland": Color(0.25, 0.72, 0.65, 0.90), + "TemperateForest": Color(0.45, 0.68, 0.45, 0.90), # sage — temperate + "TemperateGrassland": Color(0.45, 0.68, 0.45, 0.90), + "Desert": Color(0.78, 0.62, 0.35, 0.90), # sand — arid + "Savanna": Color(0.78, 0.62, 0.35, 0.90), + "Alpine": Color(0.52, 0.58, 0.72, 0.90), # slate — alpine + "Wetland": Color(0.40, 0.60, 0.52, 0.90), # muted teal — wetland + "Tundra": Color(0.55, 0.68, 0.82, 0.90), # cool grey-blue — cold + "BorealForest": Color(0.55, 0.68, 0.82, 0.90), +} +const COLOR_SUB_BIOME_DEFAULT: Color = Color(0.72, 0.72, 0.76, 0.90) # default grey + +## MorphologyZone discriminant → overlay colour (D-239 §6 order, T-1046). +## ~0.55 alpha so the heightmap shows through: water blues, plains greens, +## uplands greys/browns, volcanic dark red. +const MORPHOLOGY_COLORS: Array = [ + Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean + Color(0.20, 0.40, 0.65, 0.55), # 1 Lake + Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat + Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand + Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast + Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord + Color(0.40, 0.65, 0.60, 0.55), # 6 Delta + Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine + Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain + Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank + Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach + Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain + Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor + Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass + Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine + Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic + Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland +] + +# Province boundaries (D-205, #927) +const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55) +const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08) +const PROVINCE_BORDER_WIDTH: float = 1.2 + +# T-960 L2 — road/rail graph (D-211, T-1038). MaintenanceAuthority colors — +# Corporate reuses COLOR_CORP's amber and Trade reuses the base D-191 +# COLOR_ROAD tan (long-haul trade routes ARE the base "road" concept, now +# split out by authority) so the new encoding stays visually consistent with +# the existing overlay palette instead of introducing an unrelated hue set. +const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85) +const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85) +const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85) +const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85) +const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65) +const COLOR_ROAD_JUNCTION: Color = Color(0.85, 0.85, 0.90, 0.80) +const ROAD_JUNCTION_MIN_DEGREE: int = 3 # mirrors server's JUNCTION_DEGREE (road_graph.rs) + +# T-960 L3 — settlement placements (D-211, T-955's CityPlacement). Regular +# settlements reuse the legacy COLOR_CITY gold (same concept — "this is a +# city" reads consistently everywhere on the Atlas); capitals get a brighter +# variant AND a distinct star shape (D-226 shape-encodes-identity). +const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0) +const COLOR_SETTLEMENT_CAPITAL: Color = Color(1.0, 0.92, 0.55, 1.0) +# SettlementEntry.size_class (dudley-atlas-server contract, 2026-07-14) — the +# same D-211 Tier A/B/C cutoffs already used for placement, now a render size. +const SETTLEMENT_RADII: Dictionary = {"Major": 6.0, "Standard": 4.0, "Minor": 2.5} +const SETTLEMENT_RADIUS_DEFAULT: float = 2.5 +const SETTLEMENT_LABEL_MIN_ZOOM: float = 2.0 + const RAIL_DASH_ON: float = 6.0 const RAIL_DASH_OFF: float = 4.0 @@ -133,6 +204,17 @@ func _draw() -> void: _draw_gen_rivers(layer1) if viewer.is_overlay_visible("gen_l1_attractors"): _draw_gen_attractors(layer1) + # L2 roads / L3 settlements share the same working grid (T-960) — + # both are positioned from the same cascade run as Layer1, so they + # reuse the _gen_grid_*/_gen_tex_* mapping set up above. + if viewer.is_overlay_visible("gen_l2_roads"): + var road_graph: Variant = viewer.get_generation_road_graph() + if road_graph is Dictionary: + _draw_gen_roads(road_graph) + if viewer.is_overlay_visible("gen_l3_settlements"): + var settlements: Variant = viewer.get_generation_settlements() + if settlements != null: + _draw_gen_settlements(settlements) # POIs (non-gate first, then gates on top if enabled) _draw_pois(markers) @@ -284,10 +366,6 @@ static func _city_key(city: Dictionary) -> String: # Province boundaries (D-205, #927) # ============================================================================= -const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55) -const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08) -const PROVINCE_BORDER_WIDTH: float = 1.2 - func _draw_province_boundaries(markers: Dictionary) -> void: var provinces: Array = markers.get("provinces", []) @@ -416,30 +494,6 @@ func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void: draw_rect(Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5), _morphology_color(int(morphology[i]))) -## MorphologyZone discriminant → overlay colour (D-239 §6 order). ~0.55 alpha so -## the heightmap shows through: water blues, plains greens, uplands greys/browns, -## volcanic dark red. -const MORPHOLOGY_COLORS: Array = [ - Color(0.10, 0.20, 0.45, 0.55), # 0 OpenOcean - Color(0.20, 0.40, 0.65, 0.55), # 1 Lake - Color(0.45, 0.55, 0.50, 0.55), # 2 TidalFlat - Color(0.85, 0.78, 0.45, 0.55), # 3 DuneStrand - Color(0.50, 0.50, 0.55, 0.55), # 4 CliffCoast - Color(0.30, 0.40, 0.50, 0.55), # 5 Fjord - Color(0.40, 0.65, 0.60, 0.55), # 6 Delta - Color(0.30, 0.55, 0.55, 0.55), # 7 Estuarine - Color(0.30, 0.60, 0.30, 0.55), # 8 AlluvialPlain - Color(0.45, 0.70, 0.40, 0.55), # 9 RiverBank - Color(0.35, 0.60, 0.50, 0.55), # 10 MeanderReach - Color(0.55, 0.60, 0.45, 0.55), # 11 BraidedPlain - Color(0.50, 0.55, 0.30, 0.55), # 12 ValleyFloor - Color(0.55, 0.45, 0.30, 0.55), # 13 MountainPass - Color(0.80, 0.82, 0.85, 0.55), # 14 Alpine - Color(0.45, 0.15, 0.12, 0.55), # 15 Volcanic - Color(0.25, 0.45, 0.40, 0.55), # 16 Wetland -] - - func _morphology_color(zone: int) -> Color: if zone >= 0 and zone < MORPHOLOGY_COLORS.size(): return MORPHOLOGY_COLORS[zone] @@ -499,23 +553,11 @@ func _draw_gen_attractors(layer1: Dictionary) -> void: ## Sub-biome → marker color (Araminta's palette). Color is additive info; shape -## carries the attractor-type identity (survives monochrome capture). +## carries the attractor-type identity (survives monochrome capture). Table +## lookup (SUB_BIOME_COLORS) rather than a multi-return match — same pattern +## as MORPHOLOGY_COLORS/_morphology_color below. func _sub_biome_color(sub_biome: String) -> Color: - match sub_biome: - "TropicalWet", "CoastalLowland": - return Color(0.25, 0.72, 0.65, 0.90) # teal — coastal/tropical - "TemperateForest", "TemperateGrassland": - return Color(0.45, 0.68, 0.45, 0.90) # sage — temperate - "Desert", "Savanna": - return Color(0.78, 0.62, 0.35, 0.90) # sand — arid - "Alpine": - return Color(0.52, 0.58, 0.72, 0.90) # slate — alpine - "Wetland": - return Color(0.40, 0.60, 0.52, 0.90) # muted teal — wetland - "Tundra", "BorealForest": - return Color(0.55, 0.68, 0.82, 0.90) # cool grey-blue — cold - _: - return Color(0.72, 0.72, 0.76, 0.90) # default grey + return SUB_BIOME_COLORS.get(sub_biome, COLOR_SUB_BIOME_DEFAULT) ## Attractor type → marker shape (Araminta's vocabulary, 7 types). @@ -552,6 +594,154 @@ func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) - draw_colored_polygon(pts, color) +# ============================================================================= +# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225) +# ============================================================================= + + +## MaintenanceAuthority (D-211/D-212) → line color. The one color-coded axis +## on this overlay — road vs rail is told apart by line style/width instead +## (_draw_gen_roads), so authority stays legible on its own. +func _road_authority_color(maintenance: String) -> Color: + match maintenance: + "Administrative": + return COLOR_ROAD_ADMINISTRATIVE + "Corporate": + return COLOR_ROAD_CORPORATE + "Communal": + return COLOR_ROAD_COMMUNAL + "Trade": + return COLOR_ROAD_TRADE + "Abandoned": + return COLOR_ROAD_ABANDONED + _: + return COLOR_ROAD_TRADE + + +## Inter-settlement road/rail graph (RoadGraphLayer — D-211, T-1038). Edges +## colored by MaintenanceAuthority; rail vs road told apart by line style/ +## width (dashed + thin = rail, solid + wider = road) rather than a second +## color axis. Junction markers sit at Settlement nodes with 3+ incident +## edges — RoadGraphLayer trims `degree`/`length_cells` as server-internal +## bookkeeping (dudley-atlas-server contract, 2026-07-14), so degree is +## derived here from the edge endpoints instead of read off the node. +func _draw_gen_roads(road_graph: Dictionary) -> void: + var edges: Array = road_graph.get("edges", []) + var degree_by_index: Dictionary = {} + for e: Variant in edges: + if not e is Dictionary: + continue + var from_i: int = int(e.get("from", -1)) + var to_i: int = int(e.get("to", -1)) + degree_by_index[from_i] = int(degree_by_index.get(from_i, 0)) + 1 + degree_by_index[to_i] = int(degree_by_index.get(to_i, 0)) + 1 + + var path: Array = e.get("path", []) + if path.size() < 2: + continue + var points: PackedVector2Array = PackedVector2Array() + for pt: Variant in path: + if pt is Array and pt.size() >= 2: + points.append(_gen_pos(pt)) + if points.size() < 2: + continue + var color: Color = _road_authority_color(str(e.get("maintenance", ""))) + if bool(e.get("is_rail", false)): + _draw_dashed_polyline(points, color, 1.1) + else: + draw_polyline(points, color, 1.6, true) + + var nodes: Array = road_graph.get("nodes", []) + for i: int in range(nodes.size()): + var n: Variant = nodes[i] + if not n is Dictionary: + continue + # Mirrors the server's own RoadGraph::high_connectivity_junctions() + # filter (Settlement kind only — a Waypoint sits mid-edge and + # structurally can't exceed degree 2). + if str(n.get("kind", "")) != "Settlement": + continue + if int(degree_by_index.get(i, 0)) < ROAD_JUNCTION_MIN_DEGREE: + continue + var pos_rc: Variant = n.get("position") + if not pos_rc is Array or pos_rc.size() < 2: + continue + _draw_junction_marker(_gen_pos(pos_rc), 3.5) + + +## Small hollow diamond — distinct from both the filled attractor diamond +## (RiverCrossing, larger + filled) and the filled settlement dot. +func _draw_junction_marker(pos: Vector2, size: float) -> void: + var pts: PackedVector2Array = PackedVector2Array( + [ + pos + Vector2(0, -size), + pos + Vector2(size, 0), + pos + Vector2(0, size), + pos + Vector2(-size, 0), + pos + Vector2(0, -size), + ] + ) + draw_polyline(pts, COLOR_ROAD_JUNCTION, 1.0, true) + + +## Settlement placements (SettlementLayer — D-211 Layer 3, T-955's +## CityPlacement, wired through the proxy for the first time on generated +## bodies). `is_capital` is authored (atlas_city_names.kind == 'capital'), +## not population-derived — capitals get a distinct STAR shape (D-226 +## shape-encodes-identity); everything else is a circle. Size scales with +## `size_class` (Major/Standard/Minor — the same D-211 Tier A/B cutoffs +## already used for placement). `settlements` tolerates a bare Array too, +## defensively, though the confirmed shape is always {"settlements": [...]}. +func _draw_gen_settlements(settlements: Variant) -> void: + var entries: Array = [] + if settlements is Array: + entries = settlements + elif settlements is Dictionary: + entries = settlements.get("settlements", []) + if entries.is_empty(): + return + + var font := ThemeDB.fallback_font + var show_all_labels: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM + for s: Variant in entries: + if not s is Dictionary: + continue + var pos_rc: Variant = s.get("position") + if not pos_rc is Array or pos_rc.size() < 2: + continue + var pos: Vector2 = _gen_pos(pos_rc) + var size_class: String = str(s.get("size_class", "Minor")) + var is_capital: bool = bool(s.get("is_capital", false)) + var radius: float = float(SETTLEMENT_RADII.get(size_class, SETTLEMENT_RADIUS_DEFAULT)) + if is_capital: + _draw_star(pos, radius + 2.0, COLOR_SETTLEMENT_CAPITAL) + else: + draw_circle(pos, radius + 1.0, Color(0.0, 0.0, 0.0, 0.55)) + draw_circle(pos, radius, COLOR_SETTLEMENT) + var name_str: String = str(s.get("name", "")) + if name_str.is_empty(): + continue + if is_capital or size_class == "Major" or show_all_labels: + draw_string( + font, + pos + Vector2(radius + 3.0, radius * 0.4), + name_str, + HORIZONTAL_ALIGNMENT_LEFT, + -1, + 8, + COLOR_FEATURE_LABEL + ) + + +func _draw_star(pos: Vector2, size: float, color: Color) -> void: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(10): + var angle: float = -PI / 2.0 + i * PI / 5.0 + var r: float = size if i % 2 == 0 else size * 0.42 + pts.append(pos + Vector2(cos(angle), sin(angle)) * r) + draw_colored_polygon(pts, color) + + func _path_to_canvas(path: Array) -> PackedVector2Array: var out: PackedVector2Array = PackedVector2Array() for pt: Variant in path: diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index b4fa832b6..30327394e 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -55,6 +55,12 @@ const COLOR_TEXT: Color = Color("#c8d0e0") const COLOR_TEXT_DIM: Color = Color("#667788") const COLOR_EMPTY_NOTICE: Color = Color("#445566") +# D-236: Sol (system GJ-0) is permanently out of the deterministic generation +# cascade. Its markers.json keeps the legacy full-geometry FileAccess read +# (T-1073 exception) — guard _load_markers by this id, not by enumerating +# Sol's four bodies (GJ0d, GJ0d-1, GJ0e, GJ0f-2). +const SOL_SYSTEM_ID: String = "GJ-0" + # ── Overlay definitions (single source of truth, review #7) ───────────────── ## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and ## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal @@ -145,6 +151,19 @@ const OVERLAY_DEFS: Array = [ "group": "toggle", "tooltip": "District morphology zones (generation overlay, T-1046/D-226)." }, + { + "id": "gen_l2_roads", + "label": "RDS", + "group": "toggle", + "tooltip": + "Layer 2 — inter-settlement road/rail graph, colored by maintenance authority (generation overlay, T-960)." + }, + { + "id": "gen_l3_settlements", + "label": "STL", + "group": "toggle", + "tooltip": "Layer 3 — settlement placements, sized by population (generation overlay, T-960)." + }, ] # ── Context (set by show_body) ───────────────────────────────────────────────── @@ -157,6 +176,8 @@ var _heightmap_texture: Texture2D = null var _markers: Dictionary = {} var _generation_layer1: Variant = null # #960: Layer1Output from the proxy (D-225) var _generation_district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226) +var _generation_road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225) +var _generation_settlements: Variant = null # T-960: settlement placements from the proxy (D-225) var _gen_pending: bool = false # #960: awaiting a Layer1 response (re-polls on Pending) var _gen_retries: int = 0 var _grid_w: float = 512.0 @@ -164,6 +185,13 @@ var _grid_h: float = 256.0 var _tex_w: float = 1024.0 var _tex_h: float = 512.0 +# T-949: body_id of an in-flight CityNamesRequest, "" if none. Non-Sol only — +# Sol keeps the synchronous legacy markers.json read (D-236). Used both to +# gate _on_city_names_received against a stale response (the viewer moved to +# a different body while a request was in flight) and to replay the request +# if the bridge wasn't connected yet when _load_markers first asked. +var _city_names_pending_body: String = "" + # ── Pan/zoom state ──────────────────────────────────────────────────────────── var _view_offset: Vector2 = Vector2.ZERO var _view_zoom: float = 1.0 @@ -190,6 +218,7 @@ var _empty_notice = null # ImplantPanel shown when heightmap missing var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8) var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition) var _gen_pending_indicator = null # ImplantPending — loaded by path, not a class_name dep +var _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend func _ready() -> void: @@ -222,10 +251,16 @@ func _ready() -> void: _build_city_panel() _build_empty_notice() _build_overlay_bar() + _build_legend_panel() # #960: receive proxied Layer-1 generation output (D-225). SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) + # T-949: receive the per-body atlas city-name pool (non-Sol _load_markers + # path) and know when a reconnect should replay a pending request. + SimBridge.city_names_received.connect(_on_city_names_received) + SimBridge.connection_state_changed.connect(_on_connection_state_changed) + # #960: diegetic "generating" indicator, shown only while the proxy is Pending. # Loaded by path (not `ImplantPending.new()`) so a stale global-class cache — # e.g. a running session that hasn't re-imported after this class was added — @@ -241,6 +276,10 @@ func _ready() -> void: func _exit_tree() -> void: if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) + if SimBridge.city_names_received.is_connected(_on_city_names_received): + SimBridge.city_names_received.disconnect(_on_city_names_received) + if SimBridge.connection_state_changed.is_connected(_on_connection_state_changed): + SimBridge.connection_state_changed.disconnect(_on_connection_state_changed) ## Called by RegionalScreen.enter() when entering the viewer for a specific body. @@ -275,6 +314,7 @@ func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: return _overlay_visibility[overlay_id] = visible_state _overlay_node.queue_redraw() + _legend_panel.refresh() func is_overlay_visible(overlay_id: String) -> bool: @@ -293,6 +333,13 @@ func get_markers() -> Dictionary: return _markers +## Current pan/zoom scale — exposed so AtlasMarkerOverlay can gate zoom- +## dependent behavior (e.g. T-960 settlement name labels "at sensible zoom") +## without reaching into the viewer's private state. +func get_view_zoom() -> float: + return _view_zoom + + ## #960: store the Layer-1 generation output (from atlas_layers_received) and ## redraw the overlay. The marker overlay reads it via get_generation_layer1(). func set_generation_layer1(layer1: Variant) -> void: @@ -317,6 +364,30 @@ func get_generation_district_grid() -> Variant: return _generation_district_grid +## T-960: store the L2 road/rail graph (RoadGraphLayer) from the proxy and +## redraw. The marker overlay reads it via get_generation_road_graph(). +func set_generation_road_graph(graph: Variant) -> void: + _generation_road_graph = graph + if _overlay_node: + _overlay_node.queue_redraw() + + +func get_generation_road_graph() -> Variant: + return _generation_road_graph + + +## T-960: store the L3 settlement placements (SettlementLayer) from the proxy +## and redraw. The marker overlay reads it via get_generation_settlements(). +func set_generation_settlements(settlements: Variant) -> void: + _generation_settlements = settlements + if _overlay_node: + _overlay_node.queue_redraw() + + +func get_generation_settlements() -> Variant: + return _generation_settlements + + ## #960: request the body's Layer-1 cascade output from the server proxy. ## No-op in test mode (SimBridge has no server connection) — the overlays ## simply stay empty, which is the correct serverless behavior. @@ -342,6 +413,8 @@ func _on_atlas_layers_received(response: Dictionary) -> void: _set_gen_indicator(false) set_generation_layer1(response.get("layer1")) set_generation_district_grid(response.get("district_grid")) + set_generation_road_graph(response.get("road_graph")) + set_generation_settlements(response.get("settlements")) "Pending": if _gen_retries < GEN_MAX_RETRIES: _gen_retries += 1 @@ -433,13 +506,36 @@ func _load_heightmap() -> void: _tex_h = float(_heightmap_texture.get_height()) +## T-949: for Sol (D-236) this is still the synchronous legacy file read; for +## every other body it fires an async CityNamesRequest and _markers populates +## later, in _on_city_names_received. func _load_markers() -> void: _markers = {} # Reset grid dims alongside _tex_* in _load_heightmap so we start from a # known baseline regardless of which body ran previously. _grid_w = _tex_w _grid_h = _tex_h + _city_names_pending_body = "" + var system_id: String = _dict_str(_system, "system_id", "") + if system_id == SOL_SYSTEM_ID: + _load_sol_markers_legacy() + return + + var body_id: String = _dict_str(_body, "body_id", "") + if body_id.is_empty(): + return + _city_names_pending_body = body_id + SimBridge.request_city_names(body_id) + + +## D-236/T-1073 SOL EXCEPTION (load-bearing — do not "clean up"): Sol bodies +## (GJ0d, GJ0d-1, GJ0e, GJ0f-2, system GJ-0) are permanently excluded from the +## deterministic generation cascade (D-236), so there is no server-side +## atlas_city_names/geometry source to request instead — Sol's markers.json +## is the one client file read T-949 does NOT migrate, pending T-1073 +## (gated on Q-107). This is byte-for-byte the pre-T-949 _load_markers body. +func _load_sol_markers_legacy() -> void: var ref: Variant = _body.get("terrain_reference") if ref == null or str(ref).is_empty(): return @@ -467,6 +563,50 @@ func _load_markers() -> void: _grid_h = float(grid.get("h", _tex_h)) +## T-949: CityNamesResponse handler for non-Sol bodies (dudley-atlas-server +## contract, 2026-07-14). Ignores responses for a body the viewer has since +## navigated away from. `cities` is a flat [{city_id, name, is_capital}] array +## — NOT the legacy markers.json shape (no rivers/oceans/mountain_ranges pool, +## no position; positions arrive separately via the gen_l3_settlements +## overlay/T-960's SettlementLayer) — stored under a dedicated "city_names" +## key so it can never collide with the top-level "cities" key the legacy +## Sol reader/_draw_cities()/_find_city_at() expect (those entries have no +## pos/center/lat+lon, so they'd all plot at Vector2.ZERO if merged in). +## SolExcluded is a defensive backstop (D-236): the server refuses Sol bodies +## even though atlas_viewer.gd's own SOL_SYSTEM_ID guard should make this +## path rare — falls back to the legacy synchronous read either way. +func _on_city_names_received(response: Dictionary) -> void: + var body_id: String = _dict_str(_body, "body_id", "") + if str(response.get("body_id", "")) != body_id: + return + _city_names_pending_body = "" + match str(response.get("status", "")): + "Ready": + _markers = {"city_names": response.get("cities", [])} + "SolExcluded": + _load_sol_markers_legacy() + _: + pass # Error — leave markers empty (#960/D-191's empty-markers case) + queue_redraw() + _overlay_node.queue_redraw() + + +## T-949: the Atlas can be opened before the bridge finishes its handshake — +## replay the in-flight CityNamesRequest once we actually reach CONNECTED +## instead of leaving it stranded (request_city_names() no-ops silently while +## disconnected, and unlike the Layer1 proxy there is no Pending status to +## trigger a retry timer). +func _on_connection_state_changed(_old_state: int, new_state: int) -> void: + if new_state == SimBridge.ConnectionState.CONNECTED and not _city_names_pending_body.is_empty(): + SimBridge.request_city_names(_city_names_pending_body) + + +## True while a non-Sol body's CityNamesRequest is in flight (T-949). Sol +## bodies never set this — they use the synchronous legacy read (D-236). +func has_pending_city_names_request() -> bool: + return not _city_names_pending_body.is_empty() + + # ============================================================================= # View transform # ============================================================================= @@ -825,6 +965,25 @@ func _position_overlay_bar() -> void: _overlay_bar.position = Vector2(sz.x - bar_w - PANEL_MARGIN, PANEL_MARGIN) +# ============================================================================= +# Generation legend panel (D-226 item 3) +# ============================================================================= + + +## Loaded by path (matching atlas_overlay_bar.gd, review #8): the panel needs +## the viewer reference at construction time, and a class_name + required-arg +## _init() combo is a Godot editor footgun. GENERATION_LEGEND (the spec table) +## and the road/settlement legend colors live on this script, not here — see +## atlas_legend_panel.gd. +func _build_legend_panel() -> void: + var LegendScript := load("res://ui/implant/apps/atlas/atlas_legend_panel.gd") + _legend_panel = LegendScript.new(self) + _legend_panel.name = "GenerationLegend" + _legend_panel.theme_resource = _implant_theme + add_child(_legend_panel) + _legend_panel.refresh() + + func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED: if _city_panel: @@ -833,3 +992,5 @@ func _notification(what: int) -> void: _position_empty_notice() if _overlay_bar: _position_overlay_bar() + if _legend_panel: + _legend_panel.reposition() diff --git a/client/ui/implant/apps/economics/screens/overview_screen.gd b/client/ui/implant/apps/economics/screens/overview_screen.gd index 26aae710a..b2907a446 100644 --- a/client/ui/implant/apps/economics/screens/overview_screen.gd +++ b/client/ui/implant/apps/economics/screens/overview_screen.gd @@ -46,6 +46,9 @@ func _ready() -> void: _implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme _load_system_list() + # T-949: the star map now arrives over the bridge, possibly after this + # screen is already built (or before the handshake completes at all). + SimBridge.star_map_received.connect(_on_star_map_received) _build_panel() economy_data_updated.connect(_on_economy_data_updated) @@ -111,11 +114,23 @@ func navigate(delta: int) -> void: func _load_system_list() -> void: + SystemIndex.request_refresh() _systems = SystemIndex.get_sorted_systems() - if not _systems.is_empty(): + # Only auto-select on the FIRST populated load — a late-arriving refresh + # (T-949: the star map is now fetched over the bridge) must not stomp a + # selection the player already navigated to via navigate(). + if selected_system.is_empty() and not _systems.is_empty(): selected_system = _systems[0].get("system_id", "") +## T-949: the star map arrived — cache it and rebuild the panel with real data +## (it may have been showing the "—" loading placeholders until now). +func _on_star_map_received(response: Dictionary) -> void: + SystemIndex.ingest(response) + _load_system_list() + _rebuild_panel() + + # ============================================================================= # Visual panel — D-169 ImplantPanel composition # ============================================================================= diff --git a/client/ui/implant/widgets/system_index.gd b/client/ui/implant/widgets/system_index.gd index 6025e2088..0e2378af3 100644 --- a/client/ui/implant/widgets/system_index.gd +++ b/client/ui/implant/widgets/system_index.gd @@ -1,31 +1,69 @@ class_name SystemIndex -## Shared system data loader for implant apps (#844). -## Static helper — call as SystemIndex.get_sorted_systems(). +## Shared, cached system data loader for implant apps (#844, T-949). +## +## T-949: replaces the direct star_map_data.json FileAccess read with a +## StarMapRequest over the bridge (D-010 — the client never reads game data +## files directly). A static cache so every caller (AtlasApp's Reach screen, +## the economics OverviewScreen) shares one fetch instead of each re-asking. +## +## Usage per caller: +## 1. Call request_refresh() once (e.g. in _ready()/on_install()) — idempotent, +## no-op once loaded or already in flight. +## 2. Connect to SimBridge.star_map_received and, in the handler, re-pull +## get_sorted_systems() to refresh with the now-populated list. +## The Atlas can open before the bridge finishes its handshake — +## SimBridge.request_star_map() remembers the request and fires it +## automatically the instant the connection reaches CONNECTED, so callers +## never need to poll or retry themselves. -const DATA_PATH := "res://data/star_map_data.json" +static var _cache: Array = [] +static var _loaded: bool = false +static var _requested: bool = false +## Sorted node list (by proper_name, falling back to system_id), or [] if the +## star map has not arrived yet. Callers should re-pull this after +## SimBridge.star_map_received fires. static func get_sorted_systems() -> Array: - if not FileAccess.file_exists(DATA_PATH): - push_warning("SystemIndex: %s not found" % DATA_PATH) - return [] - var file := FileAccess.open(DATA_PATH, FileAccess.READ) - if file == null: - push_warning("SystemIndex: could not open %s" % DATA_PATH) - return [] - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return [] - var nodes: Array = [] - for node: Dictionary in parsed.get("nodes", []): + return _cache + + +static func is_loaded() -> bool: + return _loaded + + +## Ask the bridge for the star map if it hasn't been fetched yet. Safe to call +## from every screen's _ready()/on_install() — idempotent once loaded or a +## request is already in flight. +static func request_refresh() -> void: + if _loaded or _requested: + return + _requested = true + SimBridge.request_star_map() + + +## Feed a decoded StarMapResponse (from a SimBridge.star_map_received handler) +## into the cache. Sorts once here so every caller gets the same order for free. +## Only a "Ready" status is trusted — an Error response (protocol.gd's +## star_map_response_from_raw already reduces it to status/error/nodes=[]) +## must NOT mark the cache _loaded, and must clear _requested so a later +## request_refresh() retries instead of treating the failed fetch as +## permanently done. +static func ingest(response: Dictionary) -> void: + if str(response.get("status", "")) != "Ready": + _requested = false + return + var nodes: Array = response.get("nodes", []) + var sorted: Array = [] + for node: Dictionary in nodes: var sid: String = node.get("system_id", "") if not sid.is_empty(): - nodes.append(node) - nodes.sort_custom( + sorted.append(node) + sorted.sort_custom( func(a: Dictionary, b: Dictionary) -> bool: var na: String = a.get("proper_name", a.get("system_id", "")) var nb: String = b.get("proper_name", b.get("system_id", "")) return na < nb ) - return nodes + _cache = sorted + _loaded = true