fix(ui): PR #203 review round — total retirement + bin decode + wired reset

Tyre finding: the orphaned AtlasViewer cluster is now actually deleted
(atlas_viewer, atlas_marker_overlay, atlas_descend_geometry,
atlas_legend_panel — a sixth orphan found beyond the review list —
atlas_generation_proxy, atlas_generation_state; ~2,497 lines), with
reachability re-verified across preload/class_name/res:// strings,
every .tscn, and the standalone companion app. Test suites triaged,
not blanket-deleted: 5 pure AtlasOverlayColors tests relocated into
test_atlas_window_colors, the live type-identity regression guard
relocated into test_step_canvas_viewer, dead coverage deleted. A real
harness gap surfaced during diligence and RULED, not patched:
visual_scenarios/visual_capture golden shots call retired
continuous-zoom API — no shim (would resurrect what D-255 kills);
inventory recorded on re-scoped T-1157 (gate-invisible, manual
targets only).

Hoshe finding 1: decode_png_field now detects the [Error,
PackedByteArray] bin-shape from messagepack.gd explicitly — a genuine
msgpack bin payload decodes correctly instead of silently collapsing
to [0,0]; test built from a real round-tripped bin decode.

Hoshe finding 2: the hard zoom-out reset is wired — ascend at rung 0
with a drifted view triggers _reset_to_global (the restored HARD
condition), behavioral tests through the real input path.

Notes folded: refloat + edge-scroll test coverage, legend smoke suite,
Vector2i narrowing-safety comment with computed headroom.

gdlint clean on touched files; full client suite 3364/3364.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 09:32:52 +02:00
co-authored by Claude Fable 5
parent d7fc26bd2a
commit 76d82a6a51
16 changed files with 528 additions and 3831 deletions
@@ -97,19 +97,52 @@ static func _decode_status_field(status_raw: Variant) -> Dictionary:
return {"status": "", "error": ""} return {"status": "", "error": ""}
## Repack a decoded {"png_bytes": [...]} field into a PackedByteArray ready ## Repack a decoded {"png_bytes": ...} field into a PackedByteArray ready for
## for Image.load_png_from_buffer(). `field_raw` is the raw decoded Variant ## Image.load_png_from_buffer(). `field_raw` is the raw decoded Variant for
## for one of the six PNG-per-field planes — null/malformed input returns an ## one of the six PNG-per-field planes.
## empty PackedByteArray (the caller's Image decode then fails gracefully, ##
## same "skip the bad entry" posture the rest of this cluster uses rather ## **Two possible decoded shapes for `png_bytes`, both handled (PR #203
## than crashing on a malformed wire payload). ## review, Hoshe finding 1):**
## 1. **Array of ints** — the shape rmp_serde ACTUALLY produces today (no
## `serde_bytes` anywhere in server/src, confirmed by grep — see this
## file's own header doc): serde's blanket `Vec<u8>` impl serializes via
## `serialize_seq`, a msgpack ARRAY, which messagepack.gd's decoder turns
## into a plain GDScript `Array` of small ints. `PackedByteArray(array)`
## repacks this correctly.
## 2. **`[error: int, data: PackedByteArray]`** — the shape messagepack.gd's
## OWN `bin_8`/`bin_16`/`bin_32` decode path produces
## (`StreamPeerBuffer.get_partial_data()`'s documented `[Error,
## PackedByteArray]` pair), reachable the day the server adds
## `serde_bytes` to `png_bytes` (a `bin`-typed field is a strictly-smaller
## wire encoding for the same bytes, a plausible future optimization this
## codec must not silently mis-decode). **This shape is ALSO `is Array`**
## — it passed the old guard silently. Feeding it straight to
## `PackedByteArray(array)` does NOT error; it coerces each Array element
## to a byte, and a `PackedByteArray` element coerces to `0` (verified
## live: `PackedByteArray([0, PackedByteArray([1,2,3])])` ==
## `PackedByteArray([0, 0])`) — a silent, undetectable byte-loss that
## would corrupt every PNG plane the day this shape appears, with no
## error anywhere in the path (the corrupted bytes still often decode as
## SOME image, just the wrong one).
##
## The fix: detect shape 2 explicitly (`size() == 2`, first element an
## `int`, second a `PackedByteArray`) and return the inner bytes directly —
## checked BEFORE the generic Array repack, so shape 2 never reaches the
## byte-coercion path at all. Any other/malformed input (not a Dictionary,
## no `png_bytes` key, an Array that matches neither shape) returns an empty
## PackedByteArray — the caller's Image decode then fails gracefully, same
## "skip the bad entry" posture the rest of this cluster uses rather than
## crashing on a malformed wire payload.
static func decode_png_field(field_raw: Variant) -> PackedByteArray: static func decode_png_field(field_raw: Variant) -> PackedByteArray:
if not field_raw is Dictionary: if not field_raw is Dictionary:
return PackedByteArray() return PackedByteArray()
var bytes_raw: Variant = (field_raw as Dictionary).get("png_bytes") var bytes_raw: Variant = (field_raw as Dictionary).get("png_bytes")
if not bytes_raw is Array: if not bytes_raw is Array:
return PackedByteArray() return PackedByteArray()
return PackedByteArray(bytes_raw) var arr: Array = bytes_raw
if arr.size() == 2 and arr[0] is int and arr[1] is PackedByteArray:
return arr[1] # messagepack.gd's bin_8/16/32 [error, data] pair
return PackedByteArray(arr)
## Build an EncodedStepCanvas Dictionary (GDScript-side shape) from an ## Build an EncodedStepCanvas Dictionary (GDScript-side shape) from an
@@ -171,6 +204,29 @@ static func step_canvas_response_from_raw(raw: Variant) -> Variant:
} }
## Narrows the wire's `center: (i64, i64)` / `extent: (u32, u32)` pairs (both
## StepCanvasResponse fields, step_canvas.rs) into Godot's `Vector2i`, whose
## components are 32-bit signed ints (`int32_t`, range ±2,147,483,647 —
## confirmed against Godot's own `Vector2i` type, not assumed). This
## narrowing is safe under the two invariants both fields actually carry on
## this wire, neither of them the full i64/u32 range in practice:
## - `center` — world-metres. Bounded by the body's own circumference (a
## center coordinate is never meaningfully larger than the body's half
## circumference, `pi * body_radius_km * 1000`): even a Jupiter-class
## body (~70,000 km radius) caps out around 2.2e8 m, three orders of
## magnitude under the int32 ceiling. No real or plausible body in this
## project's scope (`server/data/systems.db`'s body_radius_km column)
## approaches the ~682,000 km radius that WOULD overflow int32 metres.
## - `extent` — canvas cells. Hard-clamped server-side to
## STEP_CANVAS_MAX_EXTENT_AXIS (3,840 per axis, step_canvas.rs's
## `clamp_step_canvas_extent()`) before it ever reaches the wire —
## trivially in range regardless of what a malicious/buggy sender might
## have originally requested (the CLAMPED value is what's echoed, per
## this file's own "extent ECHO rule" doc on StepCanvasResponse).
## A value outside either invariant (a malformed/adversarial frame) still
## narrows via GDScript's own int() truncation rather than erroring — no
## worse than any other wire field this codec trusts once decoded, and no
## production code path can produce one.
static func _vec_from_pair(pair: Variant) -> Vector2i: static func _vec_from_pair(pair: Variant) -> Vector2i:
if pair is Array and pair.size() >= 2: if pair is Array and pair.size() >= 2:
return Vector2i(int(pair[0]), int(pair[1])) return Vector2i(int(pair[0]), int(pair[1]))
-181
View File
@@ -1,181 +0,0 @@
## 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})
# Normalized string form — production handlers only ever see the output of
# Protocol.city_names_response_from_raw, which reduces {"Error": msg} to
# "Error" (review H5: the raw wire shape only passed by str() coincidence).
v._on_city_names_received(
{"body_id": NON_SOL_BODY_ID, "status": "Error", "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()
-523
View File
@@ -1,523 +0,0 @@
## T-1138 (D-226 T-1124 amendment §5 entry revision): tests for the planetary
## AtlasViewer's click-through descent — the fixed view (no drag-pan/wheel-
## zoom), the pixel-to-DistrictPos inverse mapping (atlas_descend_geometry.gd),
## and the city-click-wins disambiguation rule.
class_name TestAtlasDescendEntry
extends GdUnitTestSuite
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
# =============================================================================
# atlas_descend_geometry.gd — pure geometry (no scene tree needed)
# =============================================================================
## The default n=32 window's real footprint is 32 * 2.048 km = 65.536 km, so
## the label reads "~66 x 66 km" (rounded) — pins the number the amendment's
## "honest labeling" resolution depends on.
func test_reticle_label_extent_matches_district_window_default_n() -> void:
var label: Dictionary = AtlasDescendGeometry.reticle_label(Vector2(100.0, 100.0))
var expected_km: float = 32.0 * 2048.0 / 1000.0
assert_str(label["text"]).is_equal("~%.0f × %.0f km" % [expected_km, expected_km])
func test_reticle_label_position_offsets_from_center() -> void:
var center := Vector2(50.0, 60.0)
var label: Dictionary = AtlasDescendGeometry.reticle_label(center)
var pos: Vector2 = label["position"]
assert_float(pos.x).is_greater(center.x) # offset to the right, per §5's resolution
## Eight segments (four L-shaped bracket corners, two arms each) — every
## segment's "from" endpoint is exactly one of the four corner anchors, and no
## segment has zero length (a degenerate reticle would be invisible).
func test_reticle_segments_has_eight_nonzero_segments() -> void:
var segments: Array = AtlasDescendGeometry.reticle_segments(Vector2(200.0, 150.0))
assert_int(segments.size()).is_equal(8)
for seg: Array in segments:
assert_that(seg[0]).override_failure_message(
"a reticle segment must not be zero-length"
).is_not_equal(seg[1])
## The reticle's overall bounding box is centered on `center` and sized by
## DESCEND_RETICLE_SIZE — a regression guard against an off-center or
## mis-scaled bracket.
func test_reticle_segments_centered_on_input_point() -> void:
var center := Vector2(300.0, 300.0)
var segments: Array = AtlasDescendGeometry.reticle_segments(center)
var min_pt := Vector2(INF, INF)
var max_pt := Vector2(-INF, -INF)
for seg: Array in segments:
for p: Vector2 in seg:
min_pt.x = minf(min_pt.x, p.x)
min_pt.y = minf(min_pt.y, p.y)
max_pt.x = maxf(max_pt.x, p.x)
max_pt.y = maxf(max_pt.y, p.y)
var bbox_center: Vector2 = (min_pt + max_pt) * 0.5
assert_float(bbox_center.x).is_equal_approx(center.x, 0.01)
assert_float(bbox_center.y).is_equal_approx(center.y, 0.01)
# =============================================================================
# district_pos_at — pixel-to-DistrictPos inverse mapping
# =============================================================================
## No radius (tiny test body fallback, matches the server's own derive_district
## fallback): the district grid IS the heightmap grid 1:1, so a canvas point
## maps to the nearest integer district coordinate directly.
func test_district_pos_at_no_radius_is_1to1_pixel_mapping() -> void:
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(12.4, 7.6), 100.0, 100.0, 0.0
)
assert_that(pos).is_equal(Vector2i(12, 8))
func test_district_pos_at_zero_texture_size_is_safe() -> void:
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(Vector2(10.0, 10.0), 0.0, 0.0, 6371.0)
assert_that(pos).is_equal(Vector2i.ZERO)
## The equatorial center of the texture (px = tex_w/2) is district column
## district_cols/2 on a body with a radius (equator wraps at district (0,0)
## per the server's own doc: "district (0,0) sits at lon 0 / the equator").
##
## The TRUE equator pixel row is `(tex_h - 1) * 0.5`, NOT `tex_h * 0.5` (PR
## #187 review, Hoshe): the server's forward map (district_profile.rs:1436)
## divides by `ta.h.saturating_sub(1)`, so row 0 and row (h-1) are the real
## clamped pole endpoints and the midpoint between them is `(h-1)/2`. This
## test originally sampled `tex_h * 0.5`, which is NOT that midpoint for any
## finite tex_h — it happened to still round to district row 0 under BOTH the
## pre-fix buggy formula and the fix, at this specific radius/tex_h pair,
## which is exactly why this equator-only sample masked the ÷tex_h bug
## instead of catching it (see the off-equator round-trip tests below for the
## real regression coverage). Corrected here to the genuine equator pixel so
## this test asserts something true about the fixed formula, not a
## coincidence of the old one.
func test_district_pos_at_center_of_texture_is_near_equator_row_zero() -> void:
var radius_km := 6371.0
var tex_w := 1024.0
var tex_h := 512.0
var true_equator_py: float = (tex_h - 1.0) * 0.5
var pos: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(0.0, true_equator_py), tex_w, tex_h, radius_km
)
assert_int(pos.y).override_failure_message(
"the true equator pixel ((tex_h-1)/2) must map to row 0"
).is_equal(0)
## Panning across the texture's full width sweeps through the FULL district
## column range (not clamped to a tiny sub-range) — a coarse sanity check that
## district_cols is actually being derived from the body's circumference, not
## left at some degenerate default.
func test_district_pos_at_sweeps_full_column_range_across_texture_width() -> void:
var radius_km := 6371.0
var tex_w := 1024.0
var tex_h := 512.0
var left: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(0.0, tex_h * 0.5), tex_w, tex_h, radius_km
)
var right: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(tex_w - 1.0, tex_h * 0.5), tex_w, tex_h, radius_km
)
# An Earth-radius body has thousands of equatorial districts (circumference
# ~40,075 km / 2.048 km per district ≈ 19,568) — near-full-width should
# sweep a large fraction of that, not a handful of columns.
assert_int(absi(right.x - left.x)).is_greater(1000)
# =============================================================================
# Off-equator round-trip (PR #187 review, Hoshe — BLOCKING, live-repro'd bug):
# district_pos_at's row inverse divided by tex_h instead of (tex_h - 1),
# matching the WRONG symmetry with the column inverse (which correctly
# divides by the plain tex_w, since longitude wraps and has no edge case).
# The server's forward map (district_profile.rs:1436) uses
# ta.h.saturating_sub(1) for the SAME reason the ground-truth inverse
# (aliveness_probe.rs:511, `row / (ta_h - 1) - 0.5`) does: latitude CLAMPS at
# the poles, so row 0 / row (h-1) are real endpoints the division must land
# on exactly. This class of bug — a client-side twin of a server coordinate
# mapping silently drifting out of sync — is exactly what Tyre's C2 review
# criterion requires a round-trip drift guard for; that is what every test in
# this section is. The suite previously missed it because the only
# real-radius latitude sample was py = tex_h*0.5 (test above), the ONE point
## where the buggy (÷tex_h) and correct (÷(tex_h-1)) formulas round to the
# same integer district row — never exercising the asymmetry the fix targets.
# =============================================================================
## Forward-maps a district row to its heightmap pixel row using the server's
## OWN formula, transcribed here (district_profile.rs:1436):
## let lat_frac = (dy * DISTRICT_M / meridian_m).clamp(-0.5, 0.5);
## let py = (0.5 + lat_frac) * ta.h.saturating_sub(1);
## `dy` is a DistrictPos.y component (not a pixel) — the caller supplies the
## same `district_rows_half`-scaled value district_pos_at()'s inverse would
## need to recover, so this function and district_pos_at() are meant to be
## exact inverses of one another for any row within the clamped range.
static func _server_forward_map_row(district_row: int, tex_h: float, radius_km: float) -> float:
var meridian_m: float = PI * radius_km * 1000.0
var district_m: float = AtlasDescendGeometry.DISTRICT_M
var lat_frac: float = clampf((float(district_row) * district_m) / meridian_m, -0.5, 0.5)
return (0.5 + lat_frac) * (tex_h - 1.0)
## The actual regression: forward-map three off-equator district rows (one
## per hemisphere, plus a near-pole extreme) to pixel rows via the server's
## transcribed formula, then assert district_pos_at() recovers each EXACT
## row from that pixel. Fails outright under the pre-fix ÷tex_h formula for
## every row here except (coincidentally) very near the equator.
func test_district_pos_at_round_trips_off_equator_rows_against_server_forward_map() -> void:
var radius_km := 6238.4 # GJ380c (server/data/systems.db) — a real body, not a round number
var tex_w := 1024.0
var tex_h := 512.0
var col := 400 # arbitrary — this test is about the row axis only
# One row per hemisphere (moderate latitude) + one extreme near-pole row.
# meridian_m / DISTRICT_M ≈ 9,569 for this radius, so district_rows_half
# ≈ 4,785 — these rows sit well inside that range without saturating the
# clamp (except the "near pole" case, deliberately close to the edge).
var test_rows: Array = [-1200, 900, -4700]
for district_row: int in test_rows:
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(float(col), forward_py), tex_w, tex_h, radius_km
)
assert_int(recovered.y).override_failure_message(
(
"row round-trip failed: district_row=%d -> forward pixel py=%.4f -> "
+ "district_pos_at recovered row=%d (drift=%d)"
) % [district_row, forward_py, recovered.y, recovered.y - district_row]
).is_equal(district_row)
## Same round-trip, restated as an explicit non-equator drift guard: the
## pre-fix bug produced a WRONG but still-integer row (Hoshe's live repro:
## forward row 50, buggy inverse returned 45 — a 5-district, ~10.2 km drift)
## — a coarse "is it roughly right" tolerance would have passed that. This
## asserts EXACT equality specifically at a moderate off-equator row, not an
## approximate one, so a reintroduced ÷tex_h regression fails loudly again.
func test_district_pos_at_off_equator_row_is_not_off_by_a_few_districts() -> void:
var radius_km := 6238.4
var tex_w := 1024.0
var tex_h := 512.0
var district_row := 2500
var forward_py: float = _server_forward_map_row(district_row, tex_h, radius_km)
var recovered: Vector2i = AtlasDescendGeometry.district_pos_at(
Vector2(512.0, forward_py), tex_w, tex_h, radius_km
)
assert_int(recovered.y).is_equal(district_row)
# =============================================================================
# T-1142 item 1: is_on_texture() — the letterbox bounds gate
# =============================================================================
func test_is_on_texture_true_for_a_point_inside_the_texture() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 250.0), 1024.0, 512.0)).is_true()
func test_is_on_texture_true_at_the_top_left_origin() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2.ZERO, 1024.0, 512.0)).is_true()
## Half-open range [0, tex_w) x [0, tex_h) — the last valid pixel is tex_w-1 /
## tex_h-1, NOT tex_w/tex_h themselves (canvas_pt.x == tex_w is one pixel
## PAST the texture, the classic off-by-one a naive <= bound would miss).
func test_is_on_texture_false_exactly_at_the_texture_width_bound() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1024.0, 250.0), 1024.0, 512.0)).is_false()
func test_is_on_texture_false_exactly_at_the_texture_height_bound() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, 512.0), 1024.0, 512.0)).is_false()
## Jeroen's exact repro shape: a letterbox click lands far PAST the texture
## width in canvas space (a wide viewport around a 2:1-fitted heightmap).
func test_is_on_texture_false_for_a_letterbox_point_past_texture_width() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(1400.0, 250.0), 1024.0, 512.0)).is_false()
func test_is_on_texture_false_for_negative_coordinates() -> void:
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(-10.0, 250.0), 1024.0, 512.0)).is_false()
assert_bool(AtlasDescendGeometry.is_on_texture(Vector2(500.0, -10.0), 1024.0, 512.0)).is_false()
# =============================================================================
# T-1142 item 6a: canonicalize_district_center() — column wraps, row clamps
# =============================================================================
## Jeroen's own repro column (12276) on a body whose circumference works out
## to ~11236 districts (body_radius_km chosen so district_extent().cols ==
## 11236 as closely as the rounding allows) wraps down into range — the exact
## scenario the letterbox click hit before the bounds gate (item 1) made it
## unreachable via the UI, but canonicalization is still the correct backstop
## for any out-of-range center this or a future path constructs.
func test_canonicalize_wraps_a_column_past_the_circumference() -> void:
var radius_km := 6371.0 # -> district_extent().cols ~= 19,568 (Earth-like)
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var cols: int = int(extent["cols"])
var out_of_range := Vector2i(cols + 100, 0)
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
out_of_range, radius_km
)
assert_int(canonical.x).is_equal(100)
assert_int(canonical.y).is_equal(0)
## A column ONE past the wrap seam (cols) canonicalizes to column 0 — its
## "twin" on the other side of the antimeridian. This is the exact identity
## AtlasWindowCache.make_key() depends on for item 6b (a full-circumnavigation
## pan hits cache, not a fresh derive) — tested directly on the cache in
## test_atlas_window_viewer.gd; this pins the canonicalization half alone.
func test_canonicalize_one_column_past_the_seam_matches_its_twin() -> void:
var radius_km := 6371.0
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var cols: int = int(extent["cols"])
var past_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
Vector2i(cols, 50), radius_km
)
var at_seam: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
Vector2i(0, 50), radius_km
)
assert_that(past_seam).override_failure_message(
"column `cols` and column 0 are the same antimeridian-adjacent point"
).is_equal(at_seam)
## Negative columns wrap too (Euclidean, not truncating) — a pan that crosses
## the seam going WEST must land in [0, cols), not go negative.
func test_canonicalize_wraps_a_negative_column() -> void:
var radius_km := 6371.0
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var cols: int = int(extent["cols"])
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
Vector2i(-5, 0), radius_km
)
assert_int(canonical.x).is_equal(cols - 5)
## Rows CLAMP, never wrap — a row past +rows_half pins to +rows_half exactly
## (the pole), matching the server's normalize_window_center() clamp
## disposition (latitude terminates, it does not wrap around).
func test_canonicalize_clamps_a_row_past_the_pole() -> void:
var radius_km := 6371.0
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var rows_half: int = int(extent["rows_half"])
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
Vector2i(0, rows_half + 500), radius_km
)
assert_int(canonical.y).is_equal(rows_half)
var canonical_south: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
Vector2i(0, -rows_half - 500), radius_km
)
assert_int(canonical_south.y).is_equal(-rows_half)
## An already-in-range center is a no-op (identity) — canonicalization must
## never perturb a legitimate, already-valid request.
func test_canonicalize_is_identity_for_an_in_range_center() -> void:
var radius_km := 6371.0
var in_range := Vector2i(500, 100)
var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
in_range, radius_km
)
assert_that(canonical).is_equal(in_range)
## No-radius bodies (tiny test bodies) are identity — matching
## normalize_window_center()'s own no-radius disposition (no periodicity
## concept at the DistrictPos level for a body with no radius).
func test_canonicalize_no_radius_is_identity() -> void:
var anything := Vector2i(99999, -99999)
assert_that(AtlasDescendGeometry.canonicalize_district_center(anything, 0.0)).is_equal(anything)
# =============================================================================
# AtlasViewer — fixed view (no drag-pan/wheel-zoom) + click-through descent
# =============================================================================
## T-1120 capture API (set_view/get_view_offset/get_view_zoom) must survive
## the removal of user pan/zoom — this is the ticket's own explicit note, and
## the visual-golden harness depends on it.
func test_set_view_still_works_after_pan_zoom_removal() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v.set_view(3.0, Vector2(50.0, -20.0))
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
assert_that(v.get_view_offset()).is_equal(Vector2(50.0, -20.0))
## Clicking (with no heightmap loaded — the guard AtlasViewer's _gui_input
## checks first) must not emit a descend request — there is nothing to
## descend into yet.
func test_no_descend_signal_without_a_loaded_heightmap() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
var received: Array = []
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
# _heightmap_texture stays null (no show_body() call) — _gui_input's
# early-return guard should prevent any click handling at all.
var mb := InputEventMouseButton.new()
mb.button_index = MOUSE_BUTTON_LEFT
mb.pressed = true
mb.position = Vector2(100.0, 100.0)
v._gui_input(mb)
assert_int(received.size()).is_equal(0)
## T-1182 (D-255 stepped Atlas ladder): RegionalScreen no longer wraps
## AtlasViewer or forwards district_descend_requested — the "regional" nav
## entry opens the stepped six-rung ladder (StepCanvasViewer) directly at the
## Global opener (rung 0) — see regional_screen.gd's own doc. This
## regression-guards the wiring: entering "regional" reaches
## StepCanvasViewer, not AtlasViewer.
func test_regional_screen_wraps_step_canvas_viewer_not_atlas_viewer() -> void:
var screen: RegionalScreen = auto_free(RegionalScreen.new())
add_child(screen)
assert_object(screen._viewer).override_failure_message(
"RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182,"
+ " not the retired AtlasViewer heightmap-texture display"
).is_instanceof(StepCanvasViewer)
# =============================================================================
# PR #187 review (Araminta — broken promise): the descend reticle must be
# hidden while hovering a city marker — _hovered_city already flares white as
# the "click here for city data" signal, and _try_click_city wins the click
# over descent, so drawing the reticle at the same time promised a descent
# the click would never perform. State-level tests (per the review's own
# "state-level is fine" allowance): assert _should_draw_descend_reticle()'s
# condition directly against _hover_active/_hovered_city/_heightmap_texture,
# rather than a pixel-diff on the actual draw call.
# =============================================================================
static func _one_pixel_texture() -> ImageTexture:
var img := Image.create(1, 1, false, Image.FORMAT_RGB8)
return ImageTexture.create_from_image(img)
## Hovering EMPTY map (no city under the cursor) -> reticle SHOWS.
func test_descend_reticle_shows_when_hovering_empty_map() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = true
v._hovered_city = {}
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
"reticle must show when hovering the open map (no city under cursor)"
).is_true()
## Hovering a CITY marker -> reticle HIDES, even though _hover_active is true
## (this is the exact bug: pre-fix, _hovered_city was never consulted here).
func test_descend_reticle_hides_when_hovering_a_city() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = true
v._hovered_city = {"name": "Ridgeback", "pos": [10, 20]}
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
"reticle must hide while hovering a city — the click would open city"
+ " data (_try_click_city wins), not descend"
).is_false()
## Not hovering at all (_hover_active false) -> reticle HIDES regardless of
## _hovered_city — the pre-existing base condition, guarded here so the city
## fix can't accidentally invert it.
func test_descend_reticle_hides_when_not_hovering() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = false
v._hovered_city = {}
assert_bool(v._should_draw_descend_reticle()).is_false()
# =============================================================================
# T-1142 item 1: the letterbox bounds gate, wired into AtlasViewer's own
# reticle guard and click fall-through (fields default to _tex_w=1024/
# _tex_h=512, _view_zoom=1.0, _view_offset=ZERO — screen_to_canvas() is
# therefore the identity transform in these tests, so a screen point maps
# 1:1 to the same canvas point, matching this file's own established
## _one_pixel_texture() convention above (the LOADED texture's real
# dimensions don't matter here — only _tex_w/_tex_h do).
# =============================================================================
## An on-map screen point (well inside [0, 1024) x [0, 512)) shows the
## reticle — the ordinary, expected case.
func test_descend_reticle_shows_for_an_on_texture_point() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = true
v._hovered_city = {}
v._hover_screen_pos = Vector2(500.0, 250.0)
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
"reticle must show for a point on the heightmap texture"
).is_true()
## Jeroen's exact repro shape: a letterbox point (canvas x >= tex_w, i.e. past
## the right edge of a fitted 2:1 heightmap in a wider viewport) HIDES the
## reticle — the affordance must never promise a descent the click can't
## honestly perform.
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_width() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = true
v._hovered_city = {}
v._hover_screen_pos = Vector2(1400.0, 250.0) # past _tex_w=1024
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
"reticle must hide for a letterbox point past the texture's right edge"
).is_false()
## Same shape, Y axis — a letterbox point above/below a fitted heightmap
## (narrow-viewport case) must also hide the reticle.
func test_descend_reticle_hides_for_a_letterbox_point_past_texture_height() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v._heightmap_texture = _one_pixel_texture()
v._hover_active = true
v._hovered_city = {}
v._hover_screen_pos = Vector2(500.0, 900.0) # past _tex_h=512
assert_bool(v._should_draw_descend_reticle()).override_failure_message(
"reticle must hide for a letterbox point past the texture's bottom edge"
).is_false()
## The click fall-through mirrors the reticle exactly (item 1's "one truth"
## requirement) — an on-texture click DOES emit district_descend_requested.
func test_descend_at_emits_for_an_on_texture_click() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
var received: Array = []
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
v._descend_at(Vector2(500.0, 250.0))
assert_int(received.size()).override_failure_message(
"an on-texture click must emit district_descend_requested"
).is_equal(1)
## A letterbox click is INERT — no signal at all, matching the reticle never
## having shown a promise there. This is Jeroen's exact repro: a letterbox
## click must never derive a DistrictPos, on-texture or off.
func test_descend_at_is_inert_for_a_letterbox_click() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
var received: Array = []
v.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
v._descend_at(Vector2(1400.0, 250.0))
assert_int(received.size()).override_failure_message(
"a letterbox click must be inert — no district_descend_requested at all"
).is_equal(0)
-541
View File
@@ -1,541 +0,0 @@
## Tests for the Phase-4 generation overlays in the Atlas viewer (#960, D-225).
## Referencing AtlasViewer forces both it and AtlasMarkerOverlay to compile, so
## this also guards against parse errors in the overlay rendering code.
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")
# T-1118/T-1119 pure color-ramp/shape-selection helpers (no class_name, same
# rationale as LegendPanelScript above) — see atlas_overlay_colors.gd.
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
func test_generation_overlays_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(
["gen_l1_rivers", "gen_l1_basins", "gen_l1_attractors", "gen_district"]
)
## T-1046: the district/morphology grid round-trips through the viewer and the
## protocol decode surfaces it; referencing AtlasMarkerOverlay compiles the draw.
func test_district_grid_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_district_grid()).is_null()
var grid := {
"cols": 2,
"rows": 1,
"morphology": PackedByteArray([8, 14]),
"elev_q": PackedByteArray([10, 90]),
}
v.set_generation_district_grid(grid)
assert_that(v.get_generation_district_grid()).is_equal(grid)
# The decoded response dict carries the district_grid key (protocol.gd, T-1046).
var decoded: Variant = Protocol.atlas_response_from_raw(
{"body_id": "GJ1c", "status": "Ready", "district_grid": grid}
)
assert_that((decoded as Dictionary).get("district_grid")).is_equal(grid)
func test_generation_data_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_layer1()).is_null()
var mock := {
"body_id": "GJ1c",
"river_network": {"river_cells": [], "confluences": [], "mouths": []},
"drainage_basins": [],
"attractors": [],
}
v.set_generation_layer1(mock)
assert_that(v.get_generation_layer1()).is_equal(mock)
func test_implant_pending_start_stop() -> void:
var p: ImplantPending = auto_free(ImplantPending.new())
p.start("GENERATING LAYER 1")
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)
# =============================================================================
# T-1118: gen_region_grid overlay (region climate grid, mean-temp channel)
# =============================================================================
func test_region_grid_overlay_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(["gen_region_grid"])
## RegionGridLayer shape pinned to server/src/atlas/layer_proxy.rs (T-1113):
## dense row-major, mean_temp_dc is deci-degC with REGION_TEMP_NONE_DC
## (i16::MIN) the airless sentinel.
func test_region_grid_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_region_grid()).is_null()
var grid := {
"cols": 2,
"rows": 1,
"season": [0, 3],
"weather": [0, 2],
"mean_temp_dc": [123, AtlasOverlayColors.REGION_TEMP_NONE_DC],
"moisture_q": [80, 5],
}
v.set_generation_region_grid(grid)
assert_that(v.get_generation_region_grid()).is_equal(grid)
var decoded: Variant = Protocol.atlas_response_from_raw(
{"body_id": "GJ1c", "status": "Ready", "region_grid": grid}
)
assert_that((decoded as Dictionary).get("region_grid")).is_equal(grid)
## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel
## this ticket ships (mean temp only; season/weather/moisture deferred).
## Compares components with is_equal_approx() rather than whole-Color
## is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b (confirmed
## empirically — the two print identically but == is false at the ULP
## level), so an exact Color equality check is the wrong tool at a lerp
## boundary regardless of whether the ramp math itself is correct.
func test_region_temp_color_ramp() -> void:
# Cold end clamps to pure cold color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC),
AtlasOverlayColors.COLOR_REGION_TEMP_COLD
)
# Hot end clamps to pure hot color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC),
AtlasOverlayColors.COLOR_REGION_TEMP_HOT
)
# Midpoint (0.0 C) lands on the mid color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID
)
# Out-of-band readings clamp rather than extrapolate past the endpoints.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD
)
_assert_color_approx(
AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT
)
func _assert_color_approx(actual: Color, expected: Color) -> void:
assert_float(actual.r).is_equal_approx(expected.r, 0.0001)
assert_float(actual.g).is_equal_approx(expected.g, 0.0001)
assert_float(actual.b).is_equal_approx(expected.b, 0.0001)
assert_float(actual.a).is_equal_approx(expected.a, 0.0001)
## The airless sentinel is a SKIP-CELL disposition (documented on
## REGION_TEMP_NONE_DC and _draw_gen_region_grid) — the renderer never calls
## region_temp_color() for it at all, so there is no "sentinel color" to
## assert on. This test instead pins the sentinel's numeric identity, which
## is what _draw_gen_region_grid's equality check depends on.
func test_region_temp_none_sentinel_is_i16_min() -> void:
assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768)
# =============================================================================
# T-1119: gen_l4_quarters overlay (quarter-footprint glyph, D-226 T-1112 amendment)
# =============================================================================
func test_quarter_footprints_overlay_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(["gen_l4_quarters"])
## QuarterFootprintLayer shape pinned to server/src/atlas/layer_proxy.rs
## (D-226 T-1112 amendment SS1): entries keyed by city_id (BTreeMap<u64,_> on
## the wire, decodes to a Dictionary with int keys), five scalar u8/enum
## fields per entry, no per-block detail (SS2 hard ceiling).
func test_quarter_footprints_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_quarter_footprints()).is_null()
var footprints := {
"entries":
{
1:
{
"city_id": 1,
"density_avg_pct": 62,
"dominant_district_type": "Commercial",
"dominant_zoning": "Commercial",
"landmark_count": 3,
"corridor_count": 4,
},
2:
{
"city_id": 2,
"density_avg_pct": 18,
"dominant_district_type": "Residential",
"dominant_zoning": "Residential",
"landmark_count": 0,
"corridor_count": 1,
},
},
}
v.set_generation_quarter_footprints(footprints)
assert_that(v.get_generation_quarter_footprints()).is_equal(footprints)
var decoded: Variant = Protocol.atlas_response_from_raw(
{"body_id": "GJ1c", "status": "Ready", "quarter_footprints": footprints}
)
assert_that((decoded as Dictionary).get("quarter_footprints")).is_equal(footprints)
## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and
## forced to minimum when the zoom gate (show_notch) is off regardless of
## density (D-226 T-1112 amendment SS3: "below [the threshold], draws at
## minimum size with color only").
func test_quarter_glyph_size() -> void:
# 0% density, notch shown -> base size.
assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx(
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
)
# 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap).
assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001)
# Below the zoom gate: always minimum, regardless of density.
assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx(
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
)
# Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown.
assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater(
AtlasOverlayColors.quarter_glyph_size(18, true)
)
## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the
## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment
## SS3's literal legend colors. Approx compare (see test_region_temp_color_ramp's
## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every
## color pair, and exact equality shouldn't depend on which pair happens to
## round losslessly.
func test_quarter_glyph_color_ramp() -> void:
_assert_color_approx(
AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY
)
_assert_color_approx(
AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY
)
## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap
## (D-226 T-1112 amendment SS3: "a coarse skeleton read, not a legend of
## every DistrictType"). Every other DistrictType — including an unknown/
## empty string — falls through to "plain".
func test_quarter_notch_kind_selection() -> void:
assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial")
assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial")
assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative")
for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]:
assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message(
"DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other
).is_equal("plain")
assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain")
assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain")
## Tier-2 replay: a REAL server-generated msgpack blob (server/tests/gen_fixtures.rs
## generate_atlas_layer_response_fixtures, regenerated 2026-07-18 with T-960's
## road_graph/settlements, T-1118's region_grid, and T-1119's
## quarter_footprints all 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()
# T-1118: region_grid, a 2x1 grid (dudley-depth's fixture literal,
# 2026-07-18) — col0 Summer/Clear/12.3C(123 deci-C)/moisture 80, col1
# Winter/Snow/airless-sentinel/moisture 5. season/weather/mean_temp_dc/
# moisture_q are all "dense array of ints" fields (rmp_serde Vec<u8>/
# Vec<i16> with no serde_bytes, so NOT bin_8/16/32 on the wire) — read
# element-wise via int() rather than asserting a specific Godot container
# type, since the addon decodes a plain msgpack array as Array, not
# PackedByteArray (see _dense_int helper below).
var region_grid: Dictionary = response.get("region_grid")
assert_that(region_grid).is_not_null()
assert_int(int(region_grid.get("cols", 0))).is_equal(2)
assert_int(int(region_grid.get("rows", 0))).is_equal(1)
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 0)).is_equal(123)
assert_int(_dense_int(region_grid.get("mean_temp_dc"), 1)).is_equal(
AtlasOverlayColors.REGION_TEMP_NONE_DC
)
assert_int(_dense_int(region_grid.get("moisture_q"), 0)).is_equal(80)
assert_int(_dense_int(region_grid.get("moisture_q"), 1)).is_equal(5)
# T-1119: quarter_footprints, keyed by city_id (BTreeMap<u64,_> on the
# wire -> Dictionary with int keys). city_id 1 (Port Aldren, capital) gets
# a "rich" entry; city_id 2 (Farmstead Rell, minor) gets a deliberately
# sparse one (zero landmarks, one corridor) — both present, per
# dudley-depth's fixture literal, exercising the subset-safety contract
# (entries can legitimately be a SUBSET of settlements — not tested here
# since both happen to be present in this fixture, but the accessor path
# must not assume 1:1).
var quarters: Dictionary = response.get("quarter_footprints")
assert_that(quarters).is_not_null()
var qf_entries: Dictionary = quarters.get("entries", {})
assert_int(qf_entries.size()).is_equal(2)
var rich: Dictionary = qf_entries[1]
assert_int(int(rich.get("density_avg_pct", -1))).is_equal(62)
assert_str(str(rich.get("dominant_district_type", ""))).is_equal("Commercial")
assert_str(str(rich.get("dominant_zoning", ""))).is_equal("Commercial")
assert_int(int(rich.get("landmark_count", -1))).is_equal(3)
assert_int(int(rich.get("corridor_count", -1))).is_equal(4)
var sparse: Dictionary = qf_entries[2]
assert_int(int(sparse.get("density_avg_pct", -1))).is_equal(18)
assert_str(str(sparse.get("dominant_district_type", ""))).is_equal("Residential")
assert_str(str(sparse.get("dominant_zoning", ""))).is_equal("Residential")
assert_int(int(sparse.get("landmark_count", -1))).is_equal(0)
assert_int(int(sparse.get("corridor_count", -1))).is_equal(1)
## pending/not_found fixtures carry region_grid/quarter_footprints as None too
## (mirrors every other Option field's "unrun layer" treatment) — a quick
## sanity check that the new fields don't silently break the OTHER two
## fixtures' decode (they were regenerated in the same batch).
func test_atlas_response_pending_and_not_found_fixtures_have_no_new_layers() -> void:
for fixture_name in ["atlas_response_pending", "atlas_response_not_found"]:
var path := "res://tests/fixtures/msgpack/%s.msgpack" % fixture_name
var f := FileAccess.open(path, FileAccess.READ)
assert_that(f).override_failure_message("missing fixture %s" % fixture_name).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_that(response.get("region_grid")).override_failure_message(
"%s should carry no region_grid" % fixture_name
).is_null()
assert_that(response.get("quarter_footprints")).override_failure_message(
"%s should carry no quarter_footprints" % fixture_name
).is_null()
## Reads element `i` from a decoded "dense numeric array" field regardless of
## whether the messagepack addon produced a PackedByteArray (bin_8/16/32) or a
## plain Array (fixarray/array_16/array_32) — rmp_serde without serde_bytes
## encodes Vec<u8>/Vec<i16> as the latter, so this is the defensively-correct
## read for region_grid's season/weather/mean_temp_dc/moisture_q.
static func _dense_int(arr: Variant, i: int) -> int:
if arr is Array or arr is PackedByteArray:
return int(arr[i])
return 0
# =============================================================================
# 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()
## PR #186 regression: the overlay bar spans the full header-adjacent width
## so rows can wrap (ALIGNMENT_END right-aligns the chips), which leaves a
## wide EMPTY strip inside the container rect on wide screens. That strip
## must never eat map input: the container is MOUSE_FILTER_IGNORE (chip
## Buttons STOP their own events) and _is_over_ui() must not treat the bar
## rect as UI — both are pinned here because no other test exercises them.
func test_overlay_bar_empty_area_does_not_block_map_input() -> void:
var v: AtlasViewer = AtlasViewer.new()
add_child(v)
v.size = Vector2(1920.0, 1080.0)
v._position_overlay_bar()
await get_tree().process_frame
assert_int(v._overlay_bar.mouse_filter).override_failure_message(
"overlay bar container must be MOUSE_FILTER_IGNORE — STOP turns the"
+ " empty flow area into a dead strip that swallows map clicks"
).is_equal(Control.MOUSE_FILTER_IGNORE)
# A point just inside the bar's top-left is empty flow area (chips are
# right-aligned and occupy well under the full width at 1920px).
var strip_point: Vector2 = v._overlay_bar.global_position + Vector2(8.0, 8.0)
assert_bool(v._is_over_ui(strip_point)).override_failure_message(
"_is_over_ui must not claim the overlay bar's empty strip — map"
+ " pan/click near the top edge would silently die there"
).is_false()
v.queue_free()
-71
View File
@@ -1,71 +0,0 @@
## T-1120: tests for AtlasViewer's public view-transform API (set_view() /
## get_view_offset(), paired with the pre-existing get_view_zoom()).
##
## Added for the Atlas screenshot capture matrix harness, which needs to set
## deterministic zoom/pan without simulating mouse wheel events against
## viewer internals (_view_zoom/_view_offset were previously private-only,
## review flagged reaching into them from visual_capture.gd as the wrong
## layer to poke at — this is the public surface instead).
class_name TestAtlasViewApi
extends GdUnitTestSuite
func test_set_view_applies_zoom_and_offset() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v) # _canvas resolves in _ready(); a bare .new() crashes _apply_transform
v.set_view(3.0, Vector2(100.0, -40.0))
assert_that(v.get_view_zoom()).is_equal_approx(3.0, 0.001)
assert_that(v.get_view_offset()).is_equal(Vector2(100.0, -40.0))
## MIN_ZOOM/MAX_ZOOM are 0.5/8.0 (atlas_viewer.gd) — set_view() must clamp the
## same way _zoom_at()/_fit_to_view() do, so a caller can't push the view into
## a range the rest of the viewer doesn't expect.
func test_set_view_clamps_zoom_to_min_max() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v.set_view(0.01, Vector2.ZERO)
assert_that(v.get_view_zoom()).is_equal_approx(AtlasViewer.MIN_ZOOM, 0.001)
v.set_view(1000.0, Vector2.ZERO)
assert_that(v.get_view_zoom()).is_equal_approx(AtlasViewer.MAX_ZOOM, 0.001)
## Getter round-trip: what set_view() writes is exactly what get_view_zoom()/
## get_view_offset() read back (within the clamp), across a few values —
## guards against an off-by-transform bug (e.g. accidentally reading
## _canvas.scale/_canvas.position instead of the cached _view_zoom/_view_offset).
func test_view_getters_round_trip_after_set_view() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
var cases: Array = [
[1.0, Vector2.ZERO],
[2.0, Vector2(50.0, 25.0)],
[4.0, Vector2(-200.0, 300.0)],
[AtlasViewer.MIN_ZOOM, Vector2(10.0, 10.0)],
[AtlasViewer.MAX_ZOOM, Vector2(-10.0, -10.0)],
]
for c: Array in cases:
var zoom: float = c[0]
var offset: Vector2 = c[1]
v.set_view(zoom, offset)
assert_that(v.get_view_zoom()).override_failure_message(
"zoom round-trip failed for input %.2f" % zoom
).is_equal_approx(zoom, 0.001)
assert_that(v.get_view_offset()).override_failure_message(
"offset round-trip failed for input %s" % offset
).is_equal(offset)
## set_view() must drive the same _canvas transform _apply_transform() drives
## for mouse-wheel zoom/drag pan — otherwise the harness would set state the
## getters report correctly but the viewport never actually renders.
func test_set_view_updates_canvas_transform() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
add_child(v)
v.set_view(2.5, Vector2(15.0, -5.0))
var canvas: Node2D = v.get_node("AtlasCanvas")
assert_that(canvas.scale).is_equal(Vector2(2.5, 2.5))
assert_that(canvas.position).is_equal(Vector2(15.0, -5.0))
+112 -2
View File
@@ -1,7 +1,14 @@
## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the ## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the
## regional-window base layer + toggle overlays. Every function under test ## regional-window base layer + toggle overlays. Every function under test
## lives on atlas_overlay_colors.gd (no class_name — preloaded by path, ## lives on atlas_overlay_colors.gd (no class_name — preloaded by path).
## matching test_atlas_overlays.gd's own AtlasOverlayColors const). ##
## T-1182 PR #203 review (Tyre finding — the AtlasViewer cluster orphan
## retirement): the region_temp/quarter_glyph/quarter_notch sections below
## were RELOCATED here from the deleted test_atlas_overlays.gd — that suite
## tested atlas_viewer.gd/atlas_legend_panel.gd round-trips (dead once the
## orphaned cluster was deleted), but five of its tests exercised pure
## AtlasOverlayColors functions that survive the retirement untouched. Moved
## verbatim, not rewritten — same assertions, same rationale comments.
class_name TestAtlasWindowColors class_name TestAtlasWindowColors
extends GdUnitTestSuite extends GdUnitTestSuite
@@ -168,3 +175,106 @@ func _assert_color_approx(actual: Color, expected: Color) -> void:
## it would just add a constant offset with no signal). ## it would just add a constant offset with no signal).
static func _color_distance(a: Color, b: Color) -> float: static func _color_distance(a: Color, b: Color) -> float:
return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b)) return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b))
# =============================================================================
# Relocated from test_atlas_overlays.gd (T-1182 PR #203 review) — region
# temperature ramp (T-1118, region_grid overlay's one visual channel).
# =============================================================================
## AtlasOverlayColors.region_temp_color() pure ramp — the one visual channel
## the region_grid overlay ships (mean temp only; season/weather/moisture
## deferred). Compares components with is_equal_approx() rather than whole-
## Color is_equal(): Godot's Color.lerp(a, b, 1.0) is NOT bit-exact to b
## (confirmed empirically — the two print identically but == is false at the
## ULP level), so an exact Color equality check is the wrong tool at a lerp
## boundary regardless of whether the ramp math itself is correct.
func test_region_temp_color_ramp() -> void:
# Cold end clamps to pure cold color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MIN_DC),
AtlasOverlayColors.COLOR_REGION_TEMP_COLD
)
# Hot end clamps to pure hot color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(AtlasOverlayColors.REGION_TEMP_MAX_DC),
AtlasOverlayColors.COLOR_REGION_TEMP_HOT
)
# Midpoint (0.0 C) lands on the mid color.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(0), AtlasOverlayColors.COLOR_REGION_TEMP_MID
)
# Out-of-band readings clamp rather than extrapolate past the endpoints.
_assert_color_approx(
AtlasOverlayColors.region_temp_color(-9999), AtlasOverlayColors.COLOR_REGION_TEMP_COLD
)
_assert_color_approx(
AtlasOverlayColors.region_temp_color(9999), AtlasOverlayColors.COLOR_REGION_TEMP_HOT
)
## The airless sentinel is a SKIP-CELL disposition — a renderer never calls
## region_temp_color() for it at all, so there is no "sentinel color" to
## assert on. This pins the sentinel's numeric identity instead, which is
## what a caller's equality check depends on.
func test_region_temp_none_sentinel_is_i16_min() -> void:
assert_int(AtlasOverlayColors.REGION_TEMP_NONE_DC).is_equal(-32768)
# =============================================================================
# Relocated from test_atlas_overlays.gd (T-1182 PR #203 review) — quarter
# glyph size/color ramp + notch-kind mapping (T-1119, D-226 T-1112 amendment).
# =============================================================================
## AtlasOverlayColors.quarter_glyph_size() — density-scaled, clamped, and
## forced to minimum when the zoom gate (show_notch) is off regardless of
## density (D-226 T-1112 amendment §3: "below [the threshold], draws at
## minimum size with color only").
func test_quarter_glyph_size() -> void:
# 0% density, notch shown -> base size.
assert_float(AtlasOverlayColors.quarter_glyph_size(0, true)).is_equal_approx(
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
)
# 100% density, notch shown -> max size (4.0 + 1.0*6.0 = 10.0, under the 12.0 cap).
assert_float(AtlasOverlayColors.quarter_glyph_size(100, true)).is_equal_approx(10.0, 0.001)
# Below the zoom gate: always minimum, regardless of density.
assert_float(AtlasOverlayColors.quarter_glyph_size(100, false)).is_equal_approx(
AtlasOverlayColors.QUARTER_GLYPH_MIN_SIZE, 0.001
)
# Fixture literals: city 1 (62%) and city 2 (18%), both notch-shown.
assert_float(AtlasOverlayColors.quarter_glyph_size(62, true)).is_greater(
AtlasOverlayColors.quarter_glyph_size(18, true)
)
## AtlasOverlayColors.quarter_glyph_color() — single-hue ramp within the
## settlement-gold family, endpoints pinned to the D-226 T-1112 amendment
## §3's literal legend colors. Approx compare (see test_region_temp_color_ramp's
## comment) — a lerp(a, b, 1.0) isn't guaranteed bit-exact to b for every
## color pair, and exact equality shouldn't depend on which pair happens to
## round losslessly.
func test_quarter_glyph_color_ramp() -> void:
_assert_color_approx(
AtlasOverlayColors.quarter_glyph_color(0), AtlasOverlayColors.COLOR_QUARTER_LOW_DENSITY
)
_assert_color_approx(
AtlasOverlayColors.quarter_glyph_color(100), AtlasOverlayColors.COLOR_QUARTER_HIGH_DENSITY
)
## AtlasOverlayColors.quarter_notch_kind() — the 3-variant-plus-plain cap
## (D-226 T-1112 amendment §3: "a coarse skeleton read, not a legend of every
## DistrictType"). Every other DistrictType — including an unknown/empty
## string — falls through to "plain".
func test_quarter_notch_kind_selection() -> void:
assert_str(AtlasOverlayColors.quarter_notch_kind("Commercial")).is_equal("commercial")
assert_str(AtlasOverlayColors.quarter_notch_kind("Industrial")).is_equal("industrial")
assert_str(AtlasOverlayColors.quarter_notch_kind("Administrative")).is_equal("administrative")
for other in ["LogisticsHub", "Residential", "Entertainment", "MixedUse", "Transit", "Specialized"]:
assert_str(AtlasOverlayColors.quarter_notch_kind(other)).override_failure_message(
"DistrictType '%s' should read as plain (not one of the 3 marked variants)" % other
).is_equal("plain")
assert_str(AtlasOverlayColors.quarter_notch_kind("")).is_equal("plain")
assert_str(AtlasOverlayColors.quarter_notch_kind("SomeUnrecognizedFutureVariant")).is_equal("plain")
+70
View File
@@ -0,0 +1,70 @@
## T-1182 tests: StepCanvasLegend — smoke coverage (PR #203 review, Hoshe
## note). Not the retired atlas_window_legend.gd's own richer suite (that
## file never had a dedicated test — its coverage lived entirely inside
## AtlasWindowViewer's own suites, now retired); this pins the panel's basic
## lifecycle/content contract directly.
class_name TestStepCanvasLegend
extends GdUnitTestSuite
const LegendScript := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd")
func test_legend_starts_hidden_before_refresh() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
var legend = LegendScript.new(v)
auto_free(legend)
assert_bool(legend.visible).is_false()
func test_refresh_makes_the_legend_visible_and_populates_content() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
var legend = LegendScript.new(v)
add_child(legend)
legend.refresh()
assert_bool(legend.visible).is_true()
assert_int(legend.get_implant_children().size()).override_failure_message(
"refresh() must populate at least the always-on morphology/glaciation sections"
).is_greater(0)
## refresh() is a no-op (does not crash) when the legend has no viewer —
## defensive guard against a construction-order mistake.
func test_refresh_without_a_viewer_does_not_crash() -> void:
var legend = LegendScript.new(null)
auto_free(legend)
legend.refresh()
assert_bool(legend.visible).is_false()
## Toggling a gen_* overlay on changes the legend's content (the active
## toggle section appears) without crashing — the same "no active toggle by
## default, one section per active toggle" contract the retired
## atlas_window_legend.gd's own refresh() doc described.
func test_refresh_reflects_the_active_overlay_toggle() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
var legend = LegendScript.new(v)
add_child(legend)
legend.refresh()
var rows_before: int = legend.get_implant_children().size()
v.set_overlay_visible("gen_dw_temp", true)
legend.refresh()
var rows_after: int = legend.get_implant_children().size()
assert_int(rows_after).override_failure_message(
"activating a toggle overlay must add its own legend section"
).is_greater(rows_before)
func test_reposition_sets_a_fixed_panel_margin_position() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
var legend = LegendScript.new(v)
auto_free(legend)
legend.reposition()
assert_that(legend.position).is_equal(Vector2(LegendScript.PANEL_MARGIN, 60.0))
+34
View File
@@ -210,3 +210,37 @@ func test_decode_png_field_malformed_input_returns_empty_packed_byte_array() ->
assert_that(Protocol._scp().decode_png_field({"png_bytes": "not an array"})).is_equal( assert_that(Protocol._scp().decode_png_field({"png_bytes": "not an array"})).is_equal(
PackedByteArray() PackedByteArray()
) )
## PR #203 review (Hoshe finding 1) — the genuine msgpack `bin_8`/`bin_16`/
## `bin_32` decode path. messagepack.gd's own decoder returns
## `StreamPeerBuffer.get_partial_data()`'s `[Error, PackedByteArray]` pair for
## a bin-typed field — which passes decode_png_field()'s `is Array` guard
## just as readily as the real (today's) array-of-ints shape, so this must be
## exercised with the REAL bin-shaped decode output, not a hand-built
## Dictionary, to prove the fix actually detects it. Built by round-tripping
## a genuine PackedByteArray value through Messagepack.encode()/decode()
## directly (bypassing step_canvas_protocol.gd's own encoder, which never
## sends a PackedByteArray for png_bytes today) — this is exactly the shape
## a future serde_bytes-annotated server would produce.
func test_decode_png_field_handles_the_genuine_bin_type_shape() -> void:
var real_png_bytes := PackedByteArray([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
var encoded = Messagepack.encode(real_png_bytes)
assert_that(encoded.status).is_null()
var decoded = Messagepack.decode(encoded.value)
assert_that(decoded.status).is_null()
# Sanity: this really is the [error, data] pair shape, not the plain-array
# shape the rest of this suite exercises — if messagepack.gd's own
# behavior ever changes, this assertion fails loudly instead of the test
# silently exercising the wrong code path.
var bin_shape: Variant = decoded.value
assert_bool(bin_shape is Array).is_true()
assert_int((bin_shape as Array).size()).is_equal(2)
assert_bool((bin_shape as Array)[0] is int).is_true()
assert_bool((bin_shape as Array)[1] is PackedByteArray).is_true()
var result: PackedByteArray = Protocol._scp().decode_png_field({"png_bytes": bin_shape})
assert_that(result).override_failure_message(
"bin-shaped png_bytes must decode to the REAL inner bytes, not silently"
+ " collapse to [0, 0] via PackedByteArray([int, PackedByteArray]) coercion"
).is_equal(real_png_bytes)
+222 -5
View File
@@ -1,10 +1,10 @@
## T-1182 tests: StepCanvasViewer — the rung transport state machine ## T-1182 tests: StepCanvasViewer — the rung transport state machine
## (enter() lands on the Global opener, scroll steps through the ladder, ## (enter() lands on the Global opener, scroll steps through the ladder,
## overlay toggle wiring) and RegionalScreen's re-entry guard against the ## overlay toggle wiring, pan-edge re-request, edge-scroll) and
## new viewer. test_mode (SimBridge default outside SR_LIVE=1) means ## RegionalScreen's re-entry guard against the new viewer. test_mode
## request_step_canvas() is a silent no-op — these tests exercise ## (SimBridge default outside SR_LIVE=1) means request_step_canvas() is a
## client-side state only, matching test_atlas_view_api.gd's own ## silent no-op — these tests exercise client-side state only, no live
## no-live-server convention for viewer-internals tests. ## server needed.
class_name TestStepCanvasViewer class_name TestStepCanvasViewer
extends GdUnitTestSuite extends GdUnitTestSuite
@@ -55,6 +55,47 @@ func test_reset_to_global_returns_from_a_deep_rung() -> void:
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL) assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
## PR #203 review (Hoshe finding 2): the hard full-zoom-out reset — a
## scroll-out gesture while ALREADY at Global (rung 0), with the view
## drifted from the canonical un-panned frame, must snap the view back to
## center (Jeroen's explicit HARD condition, carried from the retired
## viewer's own _maybe_reset_to_canonical_frame()). Behavioral, through the
## real input entry point (_scroll_rung with direction=-1), not a direct
## _reset_to_global() call — this is what would have caught the dead-code
## regression (scroll_step() clamping at index 0 meant _scroll_rung()
## returned before ever reaching a reset call).
func test_scroll_out_at_global_after_a_pan_resets_the_view() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
v._apply_pan_delta(Vector2(1.0, 0.0), 1.0) # drift the view off-center
assert_bool(v._is_global_view_drifted()).override_failure_message(
"test setup: a pan at Global must actually drift the view"
).is_true()
v._scroll_rung(-1, Vector2(400.0, 300.0)) # scroll OUT — already at rung 0
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
assert_bool(v._is_global_view_drifted()).override_failure_message(
"a scroll-out past the top of the ladder must hard-reset the drifted"
+ " Global view back to its canonical (centered) frame"
).is_false()
## The inverse guard: scrolling out while ALREADY at the canonical
## (un-drifted) Global frame must stay a no-op — the reset is edge-triggered
## on genuine drift, not a per-scroll unconditional reset.
func test_scroll_out_at_undrifted_global_is_a_no_op() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(-1, Vector2(400.0, 300.0))
assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL)
assert_bool(v._is_global_view_drifted()).is_false()
func test_overlay_visibility_defaults_to_off_for_every_toggle() -> void: func test_overlay_visibility_defaults_to_off_for_every_toggle() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v) add_child(v)
@@ -108,3 +149,179 @@ func test_regional_screen_different_body_still_re_enters() -> void:
screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}}) screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}})
assert_str(screen._viewer.get_body_id()).is_equal("OtherBody") assert_str(screen._viewer.get_body_id()).is_equal("OtherBody")
## Relocated from test_atlas_descend_entry.gd (T-1182 PR #203 review — the
## AtlasViewer cluster orphan retirement, Tyre finding). This is the one live
## regression guard from that suite: RegionalScreen must wrap StepCanvasViewer
## (the stepped ladder), never fall back to the now-deleted AtlasViewer
## heightmap-texture display — a direct type-identity check, distinct from
## the behavioral tests above (which would only fail indirectly, via a
## missing method, if this ever regressed).
func test_regional_screen_wraps_step_canvas_viewer_not_atlas_viewer() -> void:
var screen: RegionalScreen = auto_free(RegionalScreen.new())
add_child(screen)
assert_object(screen._viewer).override_failure_message(
"RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182,"
+ " not the retired AtlasViewer heightmap-texture display"
).is_instanceof(StepCanvasViewer)
# =============================================================================
# PR #203 review (Hoshe notes): pan-edge re-request (_maybe_refloat) and
# edge-scroll pan — previously untested. _maybe_refloat() only does anything
# once the terrain layer holds a real texture (get_footprint_px() is
# ZERO/inert until then), so these tests drive StepCanvasTerrainLayer.
# rebuild_from_canvas() directly (bypassing the network — a decoded canvas
# dict is all it needs) to put the viewer into the "holding a real canvas"
# state _maybe_refloat's early-out guards against.
# =============================================================================
static func _synthetic_canvas(width: int, height: int) -> Dictionary:
return {
"width": width,
"height": height,
"morphology": null,
"elev_q": null,
"moisture_q": null,
"vegetation": null,
"glaciation": null,
"temp_dc": [],
"settlement_id": [],
"courses": [],
"cliffs": [],
}
func test_maybe_refloat_is_inert_before_any_canvas_has_arrived() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District — footprint still ZERO, nothing arrived
var world_center_before: Vector2 = v._world_center
v._maybe_refloat()
assert_that(v._world_center).is_equal(world_center_before)
## A small pan (well under half the canvas footprint) must NOT re-float —
## the held canvas keeps drawing, no re-request (§4/§5's "only when a pan
## carries the view past the held window's edge").
func test_maybe_refloat_does_not_refloat_on_a_small_pan() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0) # a real viewport size — _maybe_refloat's
# drift math is relative to get_rect().size's own center; leaving this at
# the default ZERO would make screen_center ZERO too, so even a tiny
# view_offset reads as "drifted past the canvas's own half-footprint"
# (drift = view_offset + half, threshold = half*0.5) — a test-harness
# artifact, not the behavior under test.
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
# _scroll_rung() re-centers view_offset to ZERO on arrival, which under a
# real 800x600 viewport already puts the canvas center near screen center
# (both small relative to the viewport) — center the canvas explicitly so
# "small pan" starts from a known-centered baseline.
v._view_offset = v.size * 0.5 - v._terrain_layer.get_footprint_px() * 0.5
var world_center_before: Vector2 = v._world_center
v._view_offset += Vector2(2.0, 0.0) # tiny drift, far under half the footprint
v._maybe_refloat()
assert_that(v._world_center).override_failure_message(
"a small pan must not re-float the held canvas"
).is_equal(world_center_before)
## A large pan (past half the canvas footprint) DOES re-float — new
## world_center, view_offset reset to ZERO (the canvas re-centers under the
## new request).
func test_maybe_refloat_refloats_once_the_pan_crosses_the_edge_threshold() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._scroll_rung(1, Vector2(400.0, 300.0)) # District
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(64, 64), v.get_held_rung(), "")
var world_center_before: Vector2 = v._world_center
var footprint: Vector2 = v._terrain_layer.get_footprint_px()
v._view_offset = Vector2(footprint.x, 0.0) # far past half the footprint
v._maybe_refloat()
assert_that(v._world_center).override_failure_message(
"a pan past the edge threshold must re-float (new world_center)"
).is_not_equal(world_center_before)
assert_that(v._view_offset).override_failure_message(
"re-floating resets view_offset to ZERO (the canvas re-centers)"
).is_equal(Vector2.ZERO)
## Global never re-floats on pan (D-255(a): its canvas is the whole body,
## no edge to cross) — even with a real texture held and a huge drift.
func test_maybe_refloat_is_a_no_op_at_global_rung() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
v._terrain_layer.rebuild_from_canvas(_synthetic_canvas(200, 100), v.get_held_rung(), "")
var world_center_before: Vector2 = v._world_center
v._view_offset = Vector2(9_999.0, 9_999.0)
v._maybe_refloat()
assert_that(v._world_center).is_equal(world_center_before)
# =============================================================================
# Edge-scroll: suppression conditions + direction.
# =============================================================================
func test_edge_scroll_suppressed_without_application_focus() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = false
v._last_mouse_pos = Vector2(2.0, 300.0) # well inside the edge margin
assert_bool(v._is_cursor_edge_scrolling()).is_false()
func test_edge_scroll_suppressed_when_cursor_has_never_moved_over_the_control() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
# _last_mouse_pos defaults to (-1, -1) — an impossible in-bounds position,
# so edge-scroll never fires before the mouse has moved over the control
# at least once (matches the retired viewer's own documented contract).
assert_bool(v._is_cursor_edge_scrolling()).is_false()
func test_edge_scroll_active_near_the_left_edge() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(2.0, 300.0)
assert_bool(v._is_cursor_edge_scrolling()).is_true()
var direction: Vector2 = v._edge_scroll_direction()
assert_float(direction.x).is_less(0.0)
assert_float(direction.y).is_equal(0.0)
func test_edge_scroll_active_near_the_right_edge() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(798.0, 300.0)
var direction: Vector2 = v._edge_scroll_direction()
assert_float(direction.x).is_greater(0.0)
func test_edge_scroll_inactive_well_inside_the_viewport() -> void:
var v: StepCanvasViewer = auto_free(StepCanvasViewer.new())
add_child(v)
v.size = Vector2(800.0, 600.0)
v._app_has_focus = true
v._last_mouse_pos = Vector2(400.0, 300.0) # dead center — far from any edge
assert_bool(v._is_cursor_edge_scrolling()).is_false()
@@ -1,201 +0,0 @@
extends RefCounted
## Pure geometry helpers for AtlasViewer's T-1138 descent affordance (D-226
## T-1124 amendment §5 entry revision) — factored out to keep atlas_viewer.gd
## under gdlint's max-file-lines cap, same rationale/shape as
## atlas_overlay_colors.gd's split from atlas_marker_overlay.gd (draw_line()/
## draw_string() are CanvasItem instance methods called implicitly on `self`,
## so the actual draw calls stay on AtlasViewer — only the pure lookups/math
## that decide WHERE/WHAT to draw move here):
## const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
##
## D-243: 2,048 m per district side — the source-canonical unit the server's
## derive_district()/DISTRICT_WINDOW_DEFAULT_N (32) both key off of.
const DISTRICT_M: float = 2048.0
const DISTRICT_WINDOW_DEFAULT_N: int = 32
## Fixed on-screen reticle size (px) — deliberately NOT scaled to the
## window's true planetary footprint.
##
## The true footprint of a DISTRICT_WINDOW_DEFAULT_N=32 window is
## 32 * 2.048 km = ~65.5 km per side (~131 km at the n=64 cap) — at every
## zoom level AtlasViewer's _fit_to_view() ever produces for a whole-planet
## heightmap (MAX_ZOOM=8.0 on a texture that already spans the full body),
## that distance is on the order of a handful of PIXELS. A true-extent
## rectangle would therefore be visually indistinguishable from a dot
## regardless of zoom — not an honest representation, just an illegible one;
## the amendment explicitly rejects "implying more coverage than real" but a
## sub-pixel rectangle fails the OPPOSITE way (implying almost no coverage,
## which is equally dishonest about what a click actually captures).
##
## The resolution (D-226 T-1124 amendment §5's open design point, resolved
## here): a small FIXED-SIZE bracket reticle (reads clearly at any zoom, same
## idiom as the marker overlay's fixed-size POI glyphs elsewhere on this map)
## plus a text label giving the REAL extent in km — "honest" comes from the
## label's number, not from the reticle's pixel size pretending to be to
## scale. This is a reticle, explicitly not scaled to true size, with the
## real extent stated next to it — the amendment's second named option
## (chosen over a true-extent rectangle + zoom-in cut).
const DESCEND_RETICLE_SIZE: float = 28.0
const COLOR_DESCEND_RETICLE: Color = Color(0.70, 0.88, 1.0, 0.85) # matches COLOR_GATE_MARKER family
## The eight line segments (as [from, to] pairs) for the reticle's four
## L-shaped bracket corners — reads as "this is a bounded region", distinct
## from the circular city-marker glyphs and diamond gate markers already on
## this map (D-226's hue=type/shape=identity instinct applied to interaction
## affordances). Flat segment-pair array so the caller's draw_line() loop is
## a one-liner, not a struct AtlasViewer needs to know the shape of.
static func reticle_segments(center: Vector2) -> Array:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var arm: float = half * 0.5
var corners: Array = [
center + Vector2(-half, -half),
center + Vector2(half, -half),
center + Vector2(half, half),
center + Vector2(-half, half),
]
var h_dirs: Array = [Vector2(1, 0), Vector2(-1, 0), Vector2(-1, 0), Vector2(1, 0)]
var v_dirs: Array = [Vector2(0, 1), Vector2(0, 1), Vector2(0, -1), Vector2(0, -1)]
var segments: Array = []
for i in range(4):
segments.append([corners[i], corners[i] + h_dirs[i] * arm])
segments.append([corners[i], corners[i] + v_dirs[i] * arm])
return segments
## Label position (offset from the reticle center, to the right of it) + the
## real-extent text — "~65 x 65 km" for the default n=32 window.
static func reticle_label(center: Vector2) -> Dictionary:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var extent_km: float = float(DISTRICT_WINDOW_DEFAULT_N) * DISTRICT_M / 1000.0
return {
"position": center + Vector2(half + 6.0, 4.0),
"text": "~%.0f × %.0f km" % [extent_km, extent_km],
}
## Whole-body district extent (columns spanning the full equatorial
## circumference; the half-meridian row range, i.e. equator to either pole)
## for a body of `body_radius_km`. Shared by district_pos_at(),
## canonicalize_district_center(), and AtlasWindowViewer's pole-wall pan
## clamp — ONE formula, matching server/src/atlas/layer_proxy.rs's
## normalize_window_center() EXACTLY (T-1142 canonicalization, dudley's
## in-flight server counterpart): `districts_per_circumference =
## round(circumference_m / DISTRICT_M).max(1)`, `half_meridian_districts =
## round(meridian_m / DISTRICT_M / 2.0)`. The `.max(1)` floor on cols matters
## for canonicalization's rem_euclid (a zero modulus panics/undefined-behaves
## on the server; GDScript's `%` on 0 is likewise not safe to rely on) even
## though no real systems.db body is small enough to hit it.
static func district_extent(body_radius_km: float) -> Dictionary:
var circumference_m: float = TAU * body_radius_km * 1000.0
var meridian_m: float = PI * body_radius_km * 1000.0
return {
"cols": maxf(roundf(circumference_m / DISTRICT_M), 1.0),
"rows_half": roundf((meridian_m / DISTRICT_M) * 0.5),
}
## Inverse of the server's derive_district() pixel mapping
## (server/src/atlas/district_profile.rs) — a `true_district_of_pixel`-style
## function, per the amendment's §5 carry-over wording. The server's forward
## mapping (body has a radius) is:
## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w
## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * ta.h.saturating_sub(1)
## (district_profile.rs:1436 — note `.saturating_sub(1)`, NOT a bare `ta.h`;
## confirmed against the ground-truth inverse aliveness_probe.rs:511 too:
## `row / (ta_h - 1) - 0.5`). Columns and rows are DELIBERATELY asymmetric:
## longitude WRAPS (rem_euclid), so a column has no "last pixel" edge case and
## divides by the plain width; latitude CLAMPS at the poles, so row 0 and row
## (h-1) are real, distinct endpoints (the north/south pole pixels) and the
## division must land exactly on them — dividing by `tex_h` instead of
## `tex_h - 1` introduces a systematic drift that grows with |lat_frac|
## (worst at the poles, invisible only at the exact equator row, `tex_h*0.5`,
## where the two formulas round identically). This is why inverting is "pixel
## fraction * district count" for columns but NOT a symmetric operation for
## rows — the row inverse must undo the SAME -1 the forward map applied.
##
## The (wx/circumference_m, wy/meridian_m) fractions are linear scalings of
## the same world-metre quantity the equatorial/meridian district COUNT
## already IS (district_cols = round(circumference_m / DISTRICT_M), the same
## value build_district_grid()'s `cols` converges to for a body tiled
## edge-to-edge) — so inverting is "pixel fraction * district count", not a
## re-derivation of the server's geodesy. Self-contained: does NOT depend on
## district_grid (the whole-body layer) having arrived yet, so descent works
## immediately on entry even before that async layer resolves. The "no
## radius" branch (tiny test bodies, body_radius_km absent/<=0) mirrors the
## server's own fallback: the district grid IS the heightmap grid 1:1.
static func district_pos_at(
canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float
) -> Vector2i:
if tex_w <= 0.0 or tex_h <= 0.0:
return Vector2i.ZERO
if body_radius_km <= 0.0:
return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y))
var extent: Dictionary = district_extent(body_radius_km)
var col: int = roundi((canvas_pt.x / tex_w) * float(extent["cols"]))
# tex_h - 1.0, matching the forward map's ta.h.saturating_sub(1) — NOT a
# bare tex_h (see the docstring above; this was a live bug, T-1138 PR #187
# review, Hoshe: every off-equator click descended into the wrong district).
# Guarded the same way the server's own inverse (aliveness_probe.rs:510,
# `if ta_h > 1 { ... } else { 0.0 }`) guards the same division — a
# degenerate 1px-tall texture would otherwise divide by zero.
var lat_frac: float = ((canvas_pt.y / (tex_h - 1.0)) - 0.5) if tex_h > 1.0 else 0.0
var row: int = roundi(lat_frac * float(extent["rows_half"]) * 2.0)
return Vector2i(col, row)
## T-1142 addendum (Jeroen — pole-wall/east-west-wrap ruling): canonicalize a
## district-window center to the SAME range the server's
## normalize_window_center() (server/src/atlas/layer_proxy.rs) produces —
## column WRAPS (longitude is periodic; rem_euclid into [0, cols)), row
## CLAMPS (latitude terminates at the poles; clamp into [-rows_half,
## rows_half]). Load-bearing that this matches the server bit-for-bit: the
## server echoes back the NORMALIZED center in DistrictWindowLayer.center, so
## a client that requests a raw (un-normalized) center but compares against
## its own raw value in the §2 staleness guard would reject every legitimate
## response for an out-of-range request as "stale". Canonicalizing HERE,
## before the request is even sent, means the client's held `_center` already
## equals what the server will echo — no drift between the two sides'
## "canonical" concepts, and the cache key (built from the same canonicalized
## Vector2i) naturally de-dupes a full-circumnavigation pan back to a
## previously-fetched column.
##
## No-radius bodies (tiny test bodies, BodyParams' own doc) are identity —
## same fallback disposition as normalize_window_center()'s own no-radius
## branch (the forward map's no-radius path has no periodicity concept).
static func canonicalize_district_center(center: Vector2i, body_radius_km: float) -> Vector2i:
if body_radius_km <= 0.0:
return center
var extent: Dictionary = district_extent(body_radius_km)
var cols: int = int(extent["cols"])
var rows_half: int = int(extent["rows_half"])
# GDScript's % on negative operands follows sign-of-dividend (like Rust's
# %, NOT rem_euclid) — posmod() is Godot's rem_euclid equivalent, exactly
# what the server's DistrictPos.rem_euclid(districts_per_circumference) does.
var wrapped_col: int = posmod(center.x, cols)
var clamped_row: int = clampi(center.y, -rows_half, rows_half)
return Vector2i(wrapped_col, clamped_row)
## T-1142 (Jeroen's first hands-on click, PR #187 follow-up): true unless the
## canvas point lies ON the heightmap texture, [0, tex_w) x [0, tex_h). The
## fixed planetary view (T-1138) can letterbox a non-2:1-aspect viewport
## around the 2:1 heightmap — AtlasViewer's mouse-motion/click handlers see
## every screen point in the FULL Control rect, including the letterbox dead
## zone beside/above/below the actual map, and screen_to_canvas() has no
## opinion about whether the resulting canvas point is still ON the texture
## (it is a pure affine inverse — it happily returns x=1400 for a click at
## screen-x 1900 on a 1024px-wide fitted texture). Left unchecked, a letterbox
## click both (a) shows the descend reticle (a promise) and (b) derives a
## DistrictPos from an out-of-range canvas point — Jeroen's exact repro
## (clicked the letterbox, landed at column 12276 on a body whose max valid
## column is ~11236, and the server's clamped-sampling derive at that
## beyond-the-planet position produced uniform green).
##
## ONE named helper, used by BOTH the reticle-show guard and the descend
## click fall-through (never two independent bounds checks that could drift
## — the same "one truth" lesson T-1140's hover/reticle mismatch already
## taught: the visible affordance must always match what the click does).
static func is_on_texture(canvas_pt: Vector2, tex_w: float, tex_h: float) -> bool:
return canvas_pt.x >= 0.0 and canvas_pt.x < tex_w and canvas_pt.y >= 0.0 and canvas_pt.y < tex_h
@@ -1,126 +0,0 @@
extends Node
## Generation-cascade layer-stream proxy client for AtlasViewer (#960, D-225).
## Extracted from atlas_viewer.gd (T-1118) to keep that file under the gdlint
## max-file-lines cap — this is the whole "poll the proxy, retry on Pending,
## show the diegetic GENERATING indicator, dispatch each layer field to
## AtlasGenerationState on Ready" responsibility, self-contained apart from
## reading the owning viewer's `_body`/`size` and calling its
## set_generation_*() accessors.
##
## This script has no `class_name` on purpose, matching atlas_overlay_bar.gd/
## atlas_legend_panel.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. `extends Node` (not RefCounted) because
## this owns a child Control (the pending indicator) and needs get_tree() for
## the retry timer — added as a child via
## load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self).
# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and
# generates in the background (D-225); the client re-requests until Ready.
const GEN_RETRY_DELAY: float = 0.5
const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
var _pending: bool = false # awaiting a Layer1 response (re-polls on Pending)
var _retries: int = 0
var _indicator = null # ImplantPending — loaded by path, not a class_name dep
func _init(viewer_ref = null) -> void:
_viewer = viewer_ref
func _ready() -> void:
if _viewer == null:
return
# 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 — can't fail to parse AtlasViewer and break the atlas from opening
# (#960).
_indicator = load("res://ui/implant/implant_pending.gd").new()
_indicator.name = "GenPending"
_viewer.add_child(_indicator)
_indicator.apply_implant_theme(_viewer.get_implant_theme())
_viewer.resized.connect(reposition_indicator)
reposition_indicator()
## Reset polling state for a fresh show_body() call — the caller is
## responsible for also resetting the layer data itself
## (AtlasGenerationState.reset_layer1()).
func reset() -> void:
_pending = false
_retries = 0
if _indicator:
_indicator.stop()
## 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.
func request(body_id: String) -> void:
if body_id.is_empty():
return
_pending = true
SimBridge.request_atlas_layers(body_id)
## Handle a Layer-1 response. Ignores responses for a stale body (the user
## navigated away). On Pending the proxy is still generating, so we re-request
## after a short delay until Ready or the retry ceiling.
func on_response(response: Dictionary, current_body_id: String) -> void:
if str(response.get("body_id", "")) != current_body_id:
return
match str(response.get("status", "")):
"Ready":
_pending = false
_retries = 0
_set_indicator(false)
_viewer.set_generation_layer1(response.get("layer1"))
_viewer.set_generation_district_grid(response.get("district_grid"))
_viewer.set_generation_road_graph(response.get("road_graph"))
_viewer.set_generation_settlements(response.get("settlements"))
_viewer.set_generation_region_grid(response.get("region_grid"))
_viewer.set_generation_quarter_footprints(response.get("quarter_footprints"))
"Pending":
if _retries < GEN_MAX_RETRIES:
_retries += 1
_set_indicator(true)
_schedule_retry(current_body_id)
else:
_pending = false # gave up — overlays stay empty
_set_indicator(false)
_:
_pending = false # NotFound / Error — nothing to draw
_set_indicator(false)
func _schedule_retry(body_id: String) -> void:
var timer := get_tree().create_timer(GEN_RETRY_DELAY)
timer.timeout.connect(
func() -> void:
# Re-request only if still on the same body and still waiting.
if _pending and _viewer.get_body_id() == body_id:
SimBridge.request_atlas_layers(body_id)
)
## Show/hide the diegetic pending indicator. Starting only when not already
## visible avoids resetting the sweep on every re-poll.
func _set_indicator(on: bool) -> void:
if _indicator == null:
return
if on:
if not _indicator.visible:
reposition_indicator()
_indicator.start("GENERATING LAYER 1")
else:
_indicator.stop()
func reposition_indicator() -> void:
if _indicator == null or _viewer == null:
return
var sz: Vector2 = _viewer.size
_indicator.position = Vector2((sz.x - _indicator.DEFAULT_WIDTH) * 0.5, sz.y * 0.45)
@@ -1,105 +0,0 @@
extends RefCounted
## Generation-cascade layer state for AtlasViewer (#960, D-225).
##
## Extracted from atlas_viewer.gd (T-1118/T-1119) to keep that file under the
## gdlint max-file-lines cap while it keeps growing a new field+accessor pair
## per generation layer (layer1, district_grid, road_graph, settlements,
## region_grid, quarter_footprints — the AtlasLayerResponse growth-ceiling
## note in the D-226 T-1112 amendment names region_grid/quarter_footprints as
## the last two candidate sibling Option fields). One `_generation_*` field +
## one get/set pair per layer, all following the same "store + redraw the
## overlay" shape — collecting them here means that shape is written once
## (in _set) instead of once per accessor pair.
##
## AtlasViewer holds one instance (`_gen_state`) and exposes the SAME public
## get_generation_*()/set_generation_*() method names it always has —
## external callers (atlas_marker_overlay.gd, test_atlas_overlays.gd) are
## unaffected; only the storage moved. Consumed via explicit load() by path
## (no class_name), matching atlas_legend_panel.gd/atlas_overlay_bar.gd
## (review #8 there): AtlasViewer is itself referenced by class_name in
## other scripts, and a second global class_name in this cluster is an
## unnecessary addition to the global class cache.
var _overlay_node: Node2D = null # set once by AtlasViewer._ready() (the redraw target)
var _layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
var _district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
var _road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
var _settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
var _region_grid: Variant = null # T-1118: RegionGridLayer climate grid (D-226/T-1113)
var _quarter_footprints: Variant = null # T-1119: QuarterFootprintLayer (D-226 T-1112 amendment)
func bind_overlay_node(overlay_node: Node2D) -> void:
_overlay_node = overlay_node
func _redraw() -> void:
if _overlay_node:
_overlay_node.queue_redraw()
## Reset every layer to its pre-body-load default. Called from
## AtlasViewer.show_body() so a body switch doesn't inherit the previous
## body's generation data.
func reset_layer1() -> void:
_layer1 = null
func set_layer1(layer1: Variant) -> void:
_layer1 = layer1
_redraw()
func get_layer1() -> Variant:
return _layer1
func set_district_grid(grid: Variant) -> void:
_district_grid = grid
_redraw()
func get_district_grid() -> Variant:
return _district_grid
func set_road_graph(graph: Variant) -> void:
_road_graph = graph
_redraw()
func get_road_graph() -> Variant:
return _road_graph
func set_settlements(settlements: Variant) -> void:
_settlements = settlements
_redraw()
func get_settlements() -> Variant:
return _settlements
## T-1118: store the region climate grid (RegionGridLayer) from the proxy and
## redraw. The marker overlay reads it via AtlasViewer.get_generation_region_grid().
func set_region_grid(grid: Variant) -> void:
_region_grid = grid
_redraw()
func get_region_grid() -> Variant:
return _region_grid
## T-1119: store the L4 quarter-footprint aggregates (QuarterFootprintLayer)
## from the proxy and redraw. The marker overlay reads it via
## AtlasViewer.get_generation_quarter_footprints().
func set_quarter_footprints(footprints: Variant) -> void:
_quarter_footprints = footprints
_redraw()
func get_quarter_footprints() -> Variant:
return _quarter_footprints
@@ -1,192 +0,0 @@
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)
# T-1118 — region climate grid cold/hot ramp endpoints (matches
# atlas_overlay_colors.gd's COLOR_REGION_TEMP_COLD/COLOR_REGION_TEMP_HOT).
const COLOR_REGION_TEMP_COLD_GEN: Color = Color(0.25, 0.45, 0.85, 0.60)
const COLOR_REGION_TEMP_HOT_GEN: Color = Color(0.90, 0.25, 0.20, 0.60)
# T-1119 — quarter-footprint density ramp endpoints, D-226 T-1112 amendment
# SS3's literal legend spec (matches atlas_overlay_colors.gd's
# COLOR_QUARTER_LOW_DENSITY/COLOR_QUARTER_HIGH_DENSITY).
const COLOR_QUARTER_LOW_DENSITY_GEN: Color = Color(0.55, 0.48, 0.30, 0.6)
const COLOR_QUARTER_HIGH_DENSITY_GEN: Color = Color(0.94, 0.82, 0.38, 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_region_grid",
"title": "REGION CLIMATE — mean temperature (~205 km cells)",
"rows": [
{"glyph": "", "color": COLOR_REGION_TEMP_COLD_GEN, "label": "cold"},
{"glyph": "", "color": COLOR_REGION_TEMP_HOT_GEN, "label": "hot"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "airless — no reading (skipped)"},
],
},
{
"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"},
],
},
{
"overlay_id": "gen_l4_quarters",
"title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
"rows": [
{"glyph": "", "color": COLOR_QUARTER_LOW_DENSITY_GEN, "label": "low density"},
{"glyph": "", "color": COLOR_QUARTER_HIGH_DENSITY_GEN, "label": "high density"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "commercial (corner tab, top-right)"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "industrial (corner tab, bottom-right)"},
{"glyph": "", "color": Color.TRANSPARENT, "label": "administrative (diamond cutout)"},
],
},
]
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():
reset_to_content_size()
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()
reset_to_content_size()
@@ -1,873 +0,0 @@
class_name AtlasMarkerOverlay
extends Node2D
## Draws heightmap + markers for #835 AtlasViewer. Child of AtlasViewer._canvas
## so it inherits the pan/zoom transform. Draws in texture-native coordinates.
##
## Overlay layers (#836 toggles via viewer._overlay_visibility):
## terrain heightmap texture itself
## political_zones currency-zone color bands (coarse)
## infrastructure roads + railroads
## named_features labels for rivers, oceans, mountain ranges
## gate_markers gate-terminal POIs
## population_density (toggleable) density heatmap placeholder
## production_zones (toggleable) production zone outlines placeholder
## shadow_economy (toggleable) shadow-zone broad bands placeholder
## 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)
const COLOR_ROAD: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_RAIL: Color = Color(0.45, 0.55, 0.70, 0.85)
const COLOR_CITY: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_CITY_HOVER: Color = Color(1.0, 1.0, 1.0, 1.0)
const COLOR_CITY_SELECTED: Color = Color(1.0, 0.95, 0.60, 1.0)
const COLOR_GATE: Color = Color(0.70, 0.88, 1.0, 1.0)
const COLOR_POI: Color = Color(0.45, 0.75, 0.85, 0.90)
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
# Generation overlays (#960, D-225, Araminta's encoding).
# Rivers/mouths use the reliefmap's own water colour (sampled median ocean blue)
# so river lines blend seamlessly into the surface water bodies instead of
# reading as a distinct-coloured line flowing onto the sea.
const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
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
## Pure color-ramp/shape-selection helpers (morphology, sub-biome, road
## authority, region temp, quarter density/notch) — factored into
## atlas_overlay_colors.gd (T-1118) to stay under gdlint's max-file-lines,
## same rationale as atlas_format.gd's static-function + preload pattern.
## That file owns the actual constant tables; this file only keeps constants
## with no decision function wrapping them (e.g. COLOR_SETTLEMENT — used
## directly, not looked up).
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
# 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_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
const PRODUCTION_FUNCTIONS: Array = [
"industrial",
"industry",
"manufacturing",
"extraction",
"mining",
"refinery",
"foundry",
"shipyard",
"production",
]
var viewer = null # AtlasViewer (untyped to avoid cyclic ref)
# Generation-overlay coordinate mapping (#960): set in _draw before the gen
# layers are drawn. Positions are [row, col] in the Layer-1 working grid
# (_gen_grid_*), mapped onto the displayed heightmap texture (_gen_tex_*).
var _gen_grid_w: float = 0.0
var _gen_grid_h: float = 0.0
var _gen_tex_w: float = 0.0
var _gen_tex_h: float = 0.0
func _draw() -> void:
if viewer == null:
return
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var markers: Dictionary = viewer.get_markers()
# Terrain (heightmap) — always first
if viewer.is_overlay_visible("terrain"):
draw_texture_rect(
tex, Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), false, COLOR_HEIGHTMAP_TINT
)
else:
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
# Political zones — province boundaries from drainage analysis
if viewer.is_overlay_visible("political_zones"):
_draw_province_boundaries(markers)
# Infrastructure (roads + rail)
if viewer.is_overlay_visible("infrastructure"):
_draw_roads(markers)
_draw_rails(markers)
# Named features (rivers, oceans, mountain ranges)
if viewer.is_overlay_visible("named_features"):
_draw_named_features(markers)
# Toggleable placeholders — drawn only when populated
if viewer.is_overlay_visible("population_density"):
_draw_population_density(markers)
if viewer.is_overlay_visible("production_zones"):
_draw_production_zones(markers)
if viewer.is_overlay_visible("shadow_economy"):
_draw_shadow_economy(markers)
if viewer.is_overlay_visible("corp_presence"):
_draw_corp_presence(markers)
# Generation: region climate grid (T-1118, D-243) — the coarsest area fill
# (~205 km cells), drawn first among generation overlays so the district
# morphology grid and every finer layer below reads on top of it as
# background climate context.
if viewer.is_overlay_visible("gen_region_grid"):
var region_grid: Variant = viewer.get_generation_region_grid()
if region_grid is Dictionary:
_draw_gen_region_grid(region_grid, tex_w, tex_h)
# Generation: district morphology grid (T-1046, D-226) — coarse area fill,
# drawn under the Layer-1 line/point overlays.
if viewer.is_overlay_visible("gen_district"):
var grid: Variant = viewer.get_generation_district_grid()
if grid is Dictionary:
_draw_gen_district(grid, tex_w, tex_h)
# Generation-cascade layers (#960, D-225) — from the proxied Layer1Output.
# Draw order: basins (area) under rivers under attractors (point anchors).
var layer1: Variant = viewer.get_generation_layer1()
if layer1 is Dictionary:
# The Layer1Output carries the working-grid dims its positions live in
# (#960). Map from those, NOT the markers.json grid (_grid_w/_grid_h).
_gen_grid_w = float(layer1.get("grid_w", 0))
_gen_grid_h = float(layer1.get("grid_h", 0))
_gen_tex_w = tex_w
_gen_tex_h = tex_h
if _gen_grid_w > 0.0 and _gen_grid_h > 0.0:
if viewer.is_overlay_visible("gen_l1_basins"):
_draw_gen_basins(layer1)
if viewer.is_overlay_visible("gen_l1_rivers"):
_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)
# T-1119: drawn immediately after gen_l3_settlements so it reads
# as "on top of" the city dot it annotates (D-226 T-1112 SS3),
# and needs the SAME settlements payload for the city_id->
# position join (quarter footprints carry no position of
# their own).
if viewer.is_overlay_visible("gen_l4_quarters"):
var quarters: Variant = viewer.get_generation_quarter_footprints()
if quarters is Dictionary:
_draw_gen_quarter_footprints(quarters, settlements)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
# Gate markers
if viewer.is_overlay_visible("gate_markers"):
_draw_gate_markers(markers)
# Cities last so labels sit on top
_draw_cities(markers)
# =============================================================================
# Feature drawing
# =============================================================================
func _draw_roads(markers: Dictionary) -> void:
var roads: Array = markers.get("roads", [])
for r: Dictionary in roads:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
draw_polyline(points, COLOR_ROAD, 1.5, true)
func _draw_rails(markers: Dictionary) -> void:
var rails: Array = markers.get("railroads", [])
for r: Dictionary in rails:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
_draw_dashed_polyline(points, COLOR_RAIL, 1.0)
func _draw_named_features(markers: Dictionary) -> void:
var font := ThemeDB.fallback_font
var fs: int = 8
for ocean: Dictionary in markers.get("oceans", []):
var label: String = ocean.get("name", "") if ocean.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(ocean.get("center"))
draw_string(
font, p, label.to_upper(), HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL
)
for mtn: Dictionary in markers.get("mountain_ranges", []):
var label: String = mtn.get("name", "") if mtn.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(mtn.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
for river: Dictionary in markers.get("rivers", []):
var label: String = river.get("name", "") if river.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(river.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
func _draw_pois(markers: Dictionary) -> void:
var pois: Array = markers.get("pois", [])
for p: Dictionary in pois:
if bool(p.get("gate_terminal", false)):
continue # drawn by gate_markers layer
var pos: Vector2 = _poi_pos(p)
_draw_diamond(pos, 4.0, COLOR_POI)
func _draw_gate_markers(markers: Dictionary) -> void:
# Gates show up in both pois[] (kind="gate") and cities[].gate_terminal
for p: Dictionary in markers.get("pois", []):
if not bool(p.get("gate_terminal", false)):
continue
_draw_diamond(_poi_pos(p), 6.0, COLOR_GATE)
for c: Dictionary in markers.get("cities", []):
if not bool(c.get("gate_terminal", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(c)
draw_arc(pos, 9.0, 0.0, TAU, 18, COLOR_GATE, 1.2, true)
func _draw_cities(markers: Dictionary) -> void:
var cities: Array = markers.get("cities", [])
if cities.is_empty():
return
var font := ThemeDB.fallback_font
# Compare by stable key, not Dictionary.== — deep equality was O(fields)
# per city per redraw (review #13). `name` is unique per body in the
# D-191 §8 schema; fall back to `body_id` or the hash of the dict for
# un-named markers so the comparison still works in transitional data.
var hovered_key: String = _city_key(viewer.get_hovered_city())
var selected_key: String = _city_key(viewer.get_selected_city())
for c: Dictionary in cities:
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 2.5 + float(tier) * 0.8
var key: String = _city_key(c)
var is_selected: bool = not selected_key.is_empty() and key == selected_key
var is_hovered: bool = not hovered_key.is_empty() and key == hovered_key
var col: Color = COLOR_CITY
if is_selected:
col = COLOR_CITY_SELECTED
elif is_hovered:
col = COLOR_CITY_HOVER
draw_circle(pos, r + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, r, col)
var name_str: String = c.get("name", "") if c.get("name") else ""
if not name_str.is_empty() and (tier >= 3 or is_hovered or is_selected):
var lcolor: Color = (
COLOR_CITY_HOVER if is_hovered or is_selected else Color(0.88, 0.90, 0.96, 0.85)
)
draw_string(
font,
pos + Vector2(r + 2.0, r * 0.4),
name_str,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
8,
lcolor
)
static func _city_key(city: Dictionary) -> String:
if city.is_empty():
return ""
var name_val: Variant = city.get("name")
if name_val != null and not str(name_val).is_empty():
return "n:" + str(name_val)
var id_val: Variant = city.get("city_id")
if id_val != null and not str(id_val).is_empty():
return "i:" + str(id_val)
# Last resort: positional key from pos/lat/lon so two unnamed cities at
# different coordinates still compare unequal.
var pos: Variant = city.get("pos")
if pos != null:
return "p:" + str(pos)
if city.has("lat") and city.has("lon"):
return "ll:%s:%s" % [city["lat"], city["lon"]]
return "h:%d" % city.hash()
# =============================================================================
# Province boundaries (D-205, #927)
# =============================================================================
func _draw_province_boundaries(markers: Dictionary) -> void:
var provinces: Array = markers.get("provinces", [])
if provinces.is_empty():
draw_rect(
Rect2(
Vector2.ZERO,
Vector2(
viewer.get_heightmap_texture().get_width(),
viewer.get_heightmap_texture().get_height()
)
),
COLOR_POLITICAL
)
return
for prov: Dictionary in provinces:
var path: Array = prov.get("path", [])
if path.size() < 3:
continue
var points: PackedVector2Array = _province_path_to_canvas(path)
if points.size() >= 3:
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
return out
# =============================================================================
# Overlay placeholders (populated by server side signals eventually)
# =============================================================================
func _draw_population_density(markers: Dictionary) -> void:
# Placeholder: soft blobs at city positions scaled by population_tier
for c: Dictionary in markers.get("cities", []):
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 12.0 + float(tier) * 6.0
draw_circle(pos, r, COLOR_POP_HEAT)
# Review #3: derive overlays from existing cities[] fields. The original
# code read production_zones / shadow_zones / corp_presence from keys that
# are not in the D-191 §8 markers schema, so toggling was a silent no-op.
# When the server schema lands with explicit zone polygons we can promote
# these back to dedicated arrays; until then the MVP reads what's there.
func _draw_production_zones(markers: Dictionary) -> void:
for city: Dictionary in markers.get("cities", []):
var func_id: String = str(city.get("primary_function", "")).to_lower()
if not PRODUCTION_FUNCTIONS.has(func_id):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 10.0 + float(tier) * 4.0
draw_circle(pos, radius, COLOR_PRODUCTION)
func _draw_shadow_economy(markers: Dictionary) -> void:
# Broad bands around cities outside the Commission's reach. Intentionally
# soft + overlapping — D-181 treats shadow zones as "broad bands", not
# precise polygons.
for city: Dictionary in markers.get("cities", []):
var commission: bool = bool(city.get("commission_presence", false))
if commission:
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 18.0 + float(tier) * 6.0
draw_circle(pos, radius, COLOR_SHADOW)
func _draw_corp_presence(markers: Dictionary) -> void:
# Tier 1 corp presence — keyed off the Commission-presence flag on cities
# until D-191 §8 schema explicitly lists per-corp dots.
for city: Dictionary in markers.get("cities", []):
if not bool(city.get("commission_presence", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
draw_rect(Rect2(pos - Vector2(3, 3), Vector2(6, 6)), COLOR_CORP)
# =============================================================================
# Helpers
# =============================================================================
# =============================================================================
# Generation-cascade overlays (#960, D-225)
# =============================================================================
## Map a Layer-1 [row, col] position (in the Layer1Output working grid) onto the
## displayed heightmap texture. Uses the grid dims FROM THE DATA — NOT
## viewer.grid_to_canvas, whose _grid_w/_grid_h is the markers.json coordinate
## space. Conflating the two scaled the overlays off by the downsample ratio (#960).
func _gen_pos(rc: Variant) -> Vector2:
return Vector2(float(rc[1]) / _gen_grid_w * _gen_tex_w, float(rc[0]) / _gen_grid_h * _gen_tex_h)
## District morphology grid (T-1046, D-226). A coarse `cols × rows` area fill —
## each cell coloured by its MorphologyZone discriminant (D-239 §6), semi-
## transparent so the heightmap reads through. Planetary-scale map view (NOT the
## 2 km on-demand districts, which are Phase 5 in-world).
func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
var cols: int = int(grid.get("cols", 0))
var rows: int = int(grid.get("rows", 0))
if cols <= 0 or rows <= 0:
return
var morphology: Variant = grid.get("morphology")
if not morphology is PackedByteArray:
return
var cw: float = tex_w / float(cols)
var ch: float = tex_h / float(rows)
var n: int = morphology.size()
for ry in range(rows):
for rx in range(cols):
var i: int = ry * cols + rx
if i >= n:
continue
# +0.5 overdraw avoids hairline seams between adjacent cells.
draw_rect(
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
AtlasOverlayColors.morphology_color(int(morphology[i]))
)
## Region climate grid (T-1118, D-243 SS"region"). A coarse `cols x rows` area
## fill mirroring _draw_gen_district's SELF-CONTAINED coordinate mapping above
## (dims come from the layer dict itself, cw/ch = tex/cols|rows) — this is a
## dense row-major grid keyed by its OWN cols/rows, NOT a Layer-1 point
## position, so it does NOT use _gen_pos()/_gen_grid_w (that path is for
## Layer-1 river/basin/attractor points and the L2/L3 graphs, which share one
## working-grid coordinate space; the region grid is its own dense array with
## its own extent, exactly like district_grid).
##
## Channel: mean_temp_dc only (season/weather/moisture deferred to a future
## tooltip/variant overlay per the ticket scope — D-226 "one visual channel
## first"). Airless-body cells (REGION_TEMP_NONE_DC sentinel) are skipped
## entirely, not colored — see the REGION_TEMP_NONE_DC comment above.
func _draw_gen_region_grid(grid: Dictionary, tex_w: float, tex_h: float) -> void:
var cols: int = int(grid.get("cols", 0))
var rows: int = int(grid.get("rows", 0))
if cols <= 0 or rows <= 0:
return
var mean_temp_dc: Variant = grid.get("mean_temp_dc")
if not (mean_temp_dc is Array or mean_temp_dc is PackedByteArray):
return
var cw: float = tex_w / float(cols)
var ch: float = tex_h / float(rows)
var n: int = mean_temp_dc.size()
for ry in range(rows):
for rx in range(cols):
var i: int = ry * cols + rx
if i >= n:
continue
var temp_dc: int = int(mean_temp_dc[i])
if temp_dc == REGION_TEMP_NONE_DC:
continue # airless — no temperature to show
# +0.5 overdraw avoids hairline seams between adjacent cells.
draw_rect(
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
AtlasOverlayColors.region_temp_color(temp_dc)
)
func _draw_gen_rivers(layer1: Dictionary) -> void:
var rn: Variant = layer1.get("river_network")
if not rn is Dictionary:
return
for c: Variant in rn.get("river_cells", []):
if c is Array and c.size() >= 2:
draw_circle(_gen_pos(c), 1.2, COLOR_GEN_RIVER)
for cf: Variant in rn.get("confluences", []):
if cf is Array and cf.size() >= 2:
draw_circle(_gen_pos(cf), 3.0, COLOR_GEN_RIVER)
for m: Variant in rn.get("mouths", []):
if m is Array and m.size() >= 2:
var p: Vector2 = _gen_pos(m)
# Double ring = sea terminus (high-value anchor).
draw_arc(p, 5.0, 0.0, TAU, 18, COLOR_GEN_MOUTH, 1.5)
var halo := Color(COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, 0.30)
draw_arc(p, 8.0, 0.0, TAU, 22, halo, 1.0)
func _draw_gen_basins(layer1: Dictionary) -> void:
for b: Variant in layer1.get("drainage_basins", []):
if not b is Dictionary:
continue
var boundary: Array = b.get("boundary", [])
var pts: PackedVector2Array = PackedVector2Array()
for pt: Variant in boundary:
if pt is Array and pt.size() >= 2:
pts.append(_gen_pos(pt))
if pts.size() < 2:
continue
if pts.size() >= 3:
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
var loop: PackedVector2Array = pts.duplicate()
loop.append(pts[0])
draw_polyline(loop, COLOR_GEN_BASIN_LINE, 0.8, true)
func _draw_gen_attractors(layer1: Dictionary) -> void:
for a: Variant in layer1.get("attractors", []):
if not a is Dictionary:
continue
var strength: float = float(a.get("strength", 0.0))
if strength < GEN_ATTRACTOR_MIN_STRENGTH:
continue
var pos_rc: Variant = a.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var size: float = 5.0 + strength * 4.0
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
_draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color)
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void:
match atype:
"RiverMouth":
draw_circle(pos, size, color)
"CoastalAccess":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
draw_circle(pos, size * 0.5, color)
"RiverCrossing":
_draw_diamond(pos, size, color)
"ValleyFloor":
_draw_triangle(pos, size, color, true)
"PassEntrance":
_draw_triangle(pos, size, color, false)
"LakeShore":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
"PlainCenter":
draw_rect(Rect2(pos - Vector2(size, size) * 0.7, Vector2(size, size) * 1.4), color)
_:
draw_circle(pos, size, color)
func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -> void:
var dir: float = 1.0 if point_down else -1.0
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0.0, size * dir),
pos + Vector2(-size, -size * dir),
pos + Vector2(size, -size * dir),
]
)
draw_colored_polygon(pts, color)
# =============================================================================
# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225)
# =============================================================================
## 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 = AtlasOverlayColors.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)
# =============================================================================
# Generation-cascade overlays — L4 quarter footprints (T-1119, D-226 T-1112 amendment)
# =============================================================================
## Quarter-footprint aggregates (QuarterFootprintLayer — D-226 T-1112 amendment
## SS1/SS4), one density-scaled glyph per city_id anchored at the SAME position
## the L3 settlement dot already drew (`_gen_pos` on the settlement's
## `position` — quarter footprints carry no independent spatial position per
## the amendment, only a `city_id` join key). `entries` is keyed by city_id
## (BTreeMap<u64,_> on the wire, decodes to a Dictionary with int keys) and is
## a SUBSET of settlements — a placement can exist with no quarter entry yet
## (async per-city generation), so a missing city_id is silently skipped, not
## an error. `landmark_count`/`corridor_count` are tooltip-only per the D-226
## SS2 hard ceiling — never drawn here (city-click sidebar addition, out of
## this overlay's scope). Draw call MUST come from the same `settlements`
## payload gen_l3_settlements just drew, so this needs the raw settlements
## data threaded in from _draw() rather than re-fetching it here.
func _draw_gen_quarter_footprints(footprints: Variant, settlements: Variant) -> void:
var entries: Dictionary = {}
if footprints is Dictionary:
entries = footprints.get("entries", {})
if entries.is_empty():
return
var settlement_entries: Array = []
if settlements is Array:
settlement_entries = settlements
elif settlements is Dictionary:
settlement_entries = settlements.get("settlements", [])
if settlement_entries.is_empty():
return
var show_notch: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
for s: Variant in settlement_entries:
if not s is Dictionary:
continue
var city_id: int = int(s.get("city_id", -1))
if city_id < 0 or not entries.has(city_id):
continue
var entry: Dictionary = entries[city_id]
var pos_rc: Variant = s.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
_draw_quarter_glyph(_gen_pos(pos_rc), entry, show_notch)
## One density-scaled square glyph. Below SETTLEMENT_LABEL_MIN_ZOOM, draws at
## minimum size with color only (the notch is illegible at a few px anyway,
## per the amendment); at/above it, full size with the dominant-type notch.
## Size/color/shape decisions live in AtlasOverlayColors (unit tested there
## directly via test_atlas_overlays.gd) — this function is just the
## draw_rect/notch calls.
func _draw_quarter_glyph(pos: Vector2, entry: Dictionary, show_notch: bool) -> void:
var density_pct: int = int(entry.get("density_avg_pct", 0))
var side: float = AtlasOverlayColors.quarter_glyph_size(density_pct, show_notch)
var color: Color = AtlasOverlayColors.quarter_glyph_color(density_pct)
var half: Vector2 = Vector2(side, side) * 0.5
draw_rect(Rect2(pos - half, Vector2(side, side)), color)
if not show_notch:
return
var district_type: String = str(entry.get("dominant_district_type", ""))
_draw_quarter_notch(pos, half, AtlasOverlayColors.quarter_notch_kind(district_type))
## Draws the notch glyph for a resolved AtlasOverlayColors.quarter_notch_kind() result.
func _draw_quarter_notch(pos: Vector2, half: Vector2, kind: String) -> void:
var notch_color := Color(0.08, 0.08, 0.08, 0.55)
match kind:
"commercial":
# Corner tab, top-right.
var tab: float = half.x * 0.7
draw_rect(Rect2(pos + Vector2(half.x - tab, -half.y), Vector2(tab, tab)), notch_color)
"industrial":
# Corner tab, bottom-right.
var tab: float = half.x * 0.7
draw_rect(
Rect2(pos + Vector2(half.x - tab, half.y - tab), Vector2(tab, tab)), notch_color
)
"administrative":
# Small diamond cutout, center (civic/landmark read).
_draw_diamond(pos, half.x * 0.45, notch_color)
_:
pass # plain square — mixed / no clear dominant
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[0]), float(pt[1]))))
return out
func _center_to_canvas(center: Variant) -> Vector2:
# Legacy markers.json encodes center as [row, col]
if center is Array and center.size() >= 2:
return viewer.grid_to_canvas(Vector2(float(center[1]), float(center[0])))
return Vector2.ZERO
func _poi_pos(poi: Dictionary) -> Vector2:
if poi.has("pos") and poi["pos"] is Array and poi["pos"].size() >= 2:
return viewer.grid_to_canvas(Vector2(float(poi["pos"][0]), float(poi["pos"][1])))
return _center_to_canvas(poi.get("center"))
func _draw_diamond(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
]
)
draw_colored_polygon(pts, color)
func _draw_dashed_polyline(points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
# Simple per-segment dashing — good enough for the MVP.
for i: int in range(points.size() - 1):
var a: Vector2 = points[i]
var b: Vector2 = points[i + 1]
var seg: Vector2 = b - a
var seg_len: float = seg.length()
if seg_len <= 0.001:
continue
var dir: Vector2 = seg / seg_len
var t: float = 0.0
while t < seg_len:
var t_end: float = minf(t + RAIL_DASH_ON, seg_len)
draw_line(a + dir * t, a + dir * t_end, color, width, true)
t = t_end + RAIL_DASH_OFF
File diff suppressed because it is too large Load Diff
@@ -264,13 +264,29 @@ func _active_toggle_overlay() -> String:
## < 0 ascends. Cursor-anchored (D-255(a)/Stig round-1 §2): the world point ## < 0 ascends. Cursor-anchored (D-255(a)/Stig round-1 §2): the world point
## under the cursor becomes the new step's request center, so the player's ## under the cursor becomes the new step's request center, so the player's
## point of interest stays put across the crossing. ## point of interest stays put across the crossing.
##
## **Hard full-zoom-out reset (D-255(a), PR #203 review — Hoshe finding 2):**
## scrolling OUT while ALREADY at rung 0 (Global) is the "past the top of the
## ladder" gesture — `scroll_step()` clamps the rung INDEX at 0 (there is no
## rung -1 to request), but the CANONICAL Global frame is more than just "rung
## 0" — it is rung 0 AT THE UN-PANNED CENTER (world_center == ZERO, view_offset
## == ZERO). A player who has panned around the Global canvas and then scrolls
## out again expects the HARD reset the retired viewer's own
## _maybe_reset_to_canonical_frame() provided (Jeroen's explicit HARD
## condition), not a silent no-op. Detected here (not inside
## StepCanvasTransport.scroll_step(), which is deliberately a pure
## index-clamp with no view-state concept) by checking whether ANY view
## drift exists at rung 0 when an ascend gesture arrives with nowhere further
## to ascend.
func _scroll_rung(direction: int, cursor_local: Vector2) -> void: func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
if new_index == _rung_index:
if direction < 0 and _rung_index == 0 and _is_global_view_drifted():
_reset_to_global()
return
var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
cursor_local - _view_offset, _world_center, _held_rung, _held_extent cursor_local - _view_offset, _world_center, _held_rung, _held_extent
) )
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
if new_index == _rung_index:
return
_rung_index = new_index _rung_index = new_index
_held_rung = StepCanvasTransport.rung_at_index(_rung_index) _held_rung = StepCanvasTransport.rung_at_index(_rung_index)
_world_center = cursor_world if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO _world_center = cursor_world if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO
@@ -280,10 +296,17 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
queue_redraw() queue_redraw()
## True while at rung 0 but the view has drifted from the canonical
## un-panned Global frame (world_center/view_offset both ZERO) — the
## condition _scroll_rung()'s hard-reset gesture fires on.
func _is_global_view_drifted() -> bool:
return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to ## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
## the canonical Global body-surface frame"). ## the canonical Global body-surface frame").
func _reset_to_global() -> void: func _reset_to_global() -> void:
if _rung_index == 0: if _rung_index == 0 and not _is_global_view_drifted():
return return
_rung_index = 0 _rung_index = 0
_held_rung = StepCanvasTransport.RUNG_GLOBAL _held_rung = StepCanvasTransport.RUNG_GLOBAL