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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:45:54 +02:00
co-authored by Claude Fable 5
parent 37881acba0
commit 845737617c
28 changed files with 1561 additions and 76 deletions
+23 -1
View File
@@ -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
# =============================================================================
+57 -19
View File
@@ -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