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:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
ƒ§body_id¥ghost¦status¨NotFound¦layer1À
|
||||
†§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ
|
||||
@@ -1 +1 @@
|
||||
�body_id二J1c存tatus判ending奸ayer1�
|
||||
�body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements�
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,178 @@
|
||||
## T-949 tests: atlas_viewer.gd's _load_markers Sol/non-Sol split and the
|
||||
## CityNamesResponse handler.
|
||||
##
|
||||
## Covers the D-236 Sol guard (legacy synchronous markers.json read, kept
|
||||
## byte-for-byte) and the async non-Sol path (a CityNamesRequest is queued
|
||||
## instead of a direct file read; _markers only populates once the response
|
||||
## arrives). Response shape pinned to dudley-atlas-server's contract
|
||||
## (2026-07-14): {body_id, status, cities: [{city_id, name, is_capital}]},
|
||||
## with a SolExcluded status as the server-side Sol backstop.
|
||||
class_name TestAtlasCityNames
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const SOL_BODY_ID := "GJ0d"
|
||||
const SOL_SYSTEM_ID := "GJ-0"
|
||||
const NON_SOL_BODY_ID := "GJ903b"
|
||||
const NON_SOL_SYSTEM_ID := "GJ-903"
|
||||
|
||||
|
||||
## A real Sol heightmap.png reference, resolved with the SAME formula
|
||||
## _load_sol_markers_legacy() uses for a relative terrain_reference — computed
|
||||
## once here (as an absolute path) so the test doesn't depend on hardcoding
|
||||
## this checkout's location, and doesn't re-derive/guess the resolution logic
|
||||
## separately from production.
|
||||
func _sol_heightmap_ref() -> 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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user