From 76d82a6a51e0aa4452d6a929714ffe6bf6bdc0af Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 09:32:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20PR=20#203=20review=20round=20?= =?UTF-8?q?=E2=80=94=20total=20retirement=20+=20bin=20decode=20+=20wired?= =?UTF-8?q?=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../scripts/protocol/step_canvas_protocol.gd | 70 +- client/tests/test_atlas_city_names.gd | 181 --- client/tests/test_atlas_descend_entry.gd | 523 --------- client/tests/test_atlas_overlays.gd | 541 --------- client/tests/test_atlas_view_api.gd | 71 -- client/tests/test_atlas_window_colors.gd | 114 +- client/tests/test_step_canvas_legend.gd | 70 ++ client/tests/test_step_canvas_protocol.gd | 34 + client/tests/test_step_canvas_viewer.gd | 227 +++- .../apps/atlas/atlas_descend_geometry.gd | 201 ---- .../apps/atlas/atlas_generation_proxy.gd | 126 --- .../apps/atlas/atlas_generation_state.gd | 105 -- .../implant/apps/atlas/atlas_legend_panel.gd | 192 ---- .../apps/atlas/atlas_marker_overlay.gd | 873 -------------- client/ui/implant/apps/atlas/atlas_viewer.gd | 1000 ----------------- .../atlas/step_canvas/step_canvas_viewer.gd | 31 +- 16 files changed, 528 insertions(+), 3831 deletions(-) delete mode 100644 client/tests/test_atlas_city_names.gd delete mode 100644 client/tests/test_atlas_descend_entry.gd delete mode 100644 client/tests/test_atlas_overlays.gd delete mode 100644 client/tests/test_atlas_view_api.gd create mode 100644 client/tests/test_step_canvas_legend.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_descend_geometry.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_generation_proxy.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_generation_state.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_legend_panel.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_marker_overlay.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_viewer.gd diff --git a/client/scripts/protocol/step_canvas_protocol.gd b/client/scripts/protocol/step_canvas_protocol.gd index afb91c1c8..37681d74c 100644 --- a/client/scripts/protocol/step_canvas_protocol.gd +++ b/client/scripts/protocol/step_canvas_protocol.gd @@ -97,19 +97,52 @@ static func _decode_status_field(status_raw: Variant) -> Dictionary: return {"status": "", "error": ""} -## Repack a decoded {"png_bytes": [...]} field into a PackedByteArray ready -## for Image.load_png_from_buffer(). `field_raw` is the raw decoded Variant -## for one of the six PNG-per-field planes — null/malformed input 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). +## Repack a decoded {"png_bytes": ...} field into a PackedByteArray ready for +## Image.load_png_from_buffer(). `field_raw` is the raw decoded Variant for +## one of the six PNG-per-field planes. +## +## **Two possible decoded shapes for `png_bytes`, both handled (PR #203 +## 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` 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: if not field_raw is Dictionary: return PackedByteArray() var bytes_raw: Variant = (field_raw as Dictionary).get("png_bytes") if not bytes_raw is Array: 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 @@ -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: if pair is Array and pair.size() >= 2: return Vector2i(int(pair[0]), int(pair[1])) diff --git a/client/tests/test_atlas_city_names.gd b/client/tests/test_atlas_city_names.gd deleted file mode 100644 index faf9d067d..000000000 --- a/client/tests/test_atlas_city_names.gd +++ /dev/null @@ -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() diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd deleted file mode 100644 index d67139c5e..000000000 --- a/client/tests/test_atlas_descend_entry.gd +++ /dev/null @@ -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) diff --git a/client/tests/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd deleted file mode 100644 index 8b3f408fc..000000000 --- a/client/tests/test_atlas_overlays.gd +++ /dev/null @@ -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 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/ - # Vec 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 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/Vec 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() diff --git a/client/tests/test_atlas_view_api.gd b/client/tests/test_atlas_view_api.gd deleted file mode 100644 index f4c30d784..000000000 --- a/client/tests/test_atlas_view_api.gd +++ /dev/null @@ -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)) diff --git a/client/tests/test_atlas_window_colors.gd b/client/tests/test_atlas_window_colors.gd index 0ddb20746..240e1abc3 100644 --- a/client/tests/test_atlas_window_colors.gd +++ b/client/tests/test_atlas_window_colors.gd @@ -1,7 +1,14 @@ ## T-1138 (D-226 T-1124 amendment §5): pure color-ramp tests for the ## regional-window base layer + toggle overlays. Every function under test -## lives on atlas_overlay_colors.gd (no class_name — preloaded by path, -## matching test_atlas_overlays.gd's own AtlasOverlayColors const). +## lives on atlas_overlay_colors.gd (no class_name — preloaded by path). +## +## 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 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). 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)) + + +# ============================================================================= +# 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") diff --git a/client/tests/test_step_canvas_legend.gd b/client/tests/test_step_canvas_legend.gd new file mode 100644 index 000000000..a8d73fd64 --- /dev/null +++ b/client/tests/test_step_canvas_legend.gd @@ -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)) diff --git a/client/tests/test_step_canvas_protocol.gd b/client/tests/test_step_canvas_protocol.gd index 558b5524c..74b2b3f1e 100644 --- a/client/tests/test_step_canvas_protocol.gd +++ b/client/tests/test_step_canvas_protocol.gd @@ -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( 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) diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 56528c6f3..409d4f7e8 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -1,10 +1,10 @@ ## T-1182 tests: StepCanvasViewer — the rung transport state machine ## (enter() lands on the Global opener, scroll steps through the ladder, -## overlay toggle wiring) and RegionalScreen's re-entry guard against the -## new viewer. test_mode (SimBridge default outside SR_LIVE=1) means -## request_step_canvas() is a silent no-op — these tests exercise -## client-side state only, matching test_atlas_view_api.gd's own -## no-live-server convention for viewer-internals tests. +## overlay toggle wiring, pan-edge re-request, edge-scroll) and +## RegionalScreen's re-entry guard against the new viewer. test_mode +## (SimBridge default outside SR_LIVE=1) means request_step_canvas() is a +## silent no-op — these tests exercise client-side state only, no live +## server needed. class_name TestStepCanvasViewer 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) +## 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: var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) 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": {}}) 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() diff --git a/client/ui/implant/apps/atlas/atlas_descend_geometry.gd b/client/ui/implant/apps/atlas/atlas_descend_geometry.gd deleted file mode 100644 index 63a965429..000000000 --- a/client/ui/implant/apps/atlas/atlas_descend_geometry.gd +++ /dev/null @@ -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 diff --git a/client/ui/implant/apps/atlas/atlas_generation_proxy.gd b/client/ui/implant/apps/atlas/atlas_generation_proxy.gd deleted file mode 100644 index 98d375e1d..000000000 --- a/client/ui/implant/apps/atlas/atlas_generation_proxy.gd +++ /dev/null @@ -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) diff --git a/client/ui/implant/apps/atlas/atlas_generation_state.gd b/client/ui/implant/apps/atlas/atlas_generation_state.gd deleted file mode 100644 index ca85dc939..000000000 --- a/client/ui/implant/apps/atlas/atlas_generation_state.gd +++ /dev/null @@ -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 diff --git a/client/ui/implant/apps/atlas/atlas_legend_panel.gd b/client/ui/implant/apps/atlas/atlas_legend_panel.gd deleted file mode 100644 index dd235499b..000000000 --- a/client/ui/implant/apps/atlas/atlas_legend_panel.gd +++ /dev/null @@ -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() diff --git a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd deleted file mode 100644 index 52e1f1635..000000000 --- a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd +++ /dev/null @@ -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 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 diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd deleted file mode 100644 index 5fa040412..000000000 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ /dev/null @@ -1,1000 +0,0 @@ -class_name AtlasViewer -extends Control - -## Atlas regional viewer — heightmap PNG with marker overlay (#835, D-191). -## -## Lives as a child of RegionalScreen (via show_body()). Emits back_pressed, -## economics_link_requested, district_descend_requested for RegionalScreen to route. -## Design notes: -## - Heightmap on a Node2D _canvas child; MarkerOverlay is a child of _canvas too. -## - markers.json schema (D-191 §8): cities, roads, rail, pois, rivers, oceans, ranges. -## - Empty markers: bare heightmap, no sidebar. Overlays (#836): _overlay_visibility dict. -## Navigation (FIXED, T-1142 bounds-gated): hover reticle · click descend/city · esc back - -signal back_pressed -signal economics_link_requested(system_id: String) -signal district_descend_requested(district_center: Vector2i) # T-1138 - -const MIN_ZOOM: float = 0.5 -const MAX_ZOOM: float = 8.0 - -const PANEL_WIDTH: float = 320.0 -const PANEL_MARGIN: float = 16.0 -# Width reserved for the top-left header block (title + hint lines) that the -# wrapping overlay bar must not overlap. -const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 - -const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") # T-1138 - -# ── Colors ──────────────────────────────────────────────────────────────────── -const COLOR_BG: Color = Color("#0d1117") -const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0) -const COLOR_CITY: Color = Color("#f0d060") -const COLOR_CITY_HOVER: Color = Color("#ffffff") -const COLOR_CITY_LABEL: Color = Color("#c8d0e0") -const COLOR_ROAD: Color = Color("#d89040") -const COLOR_RAIL: Color = Color("#7888a0") -const COLOR_POI: Color = Color("#70c0d8") -const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65) -const COLOR_GATE_MARKER: Color = Color("#b0e0ff") -const COLOR_POLITICAL_ZONE: Color = Color(0.25, 0.45, 0.65, 0.12) -const COLOR_TEXT: Color = Color("#c8d0e0") -const COLOR_TEXT_DIM: Color = Color("#667788") -const COLOR_EMPTY_NOTICE: Color = Color("#445566") - -# D-236: Sol (system GJ-0) is permanently out of the deterministic generation -# cascade. Its markers.json keeps the legacy full-geometry FileAccess read -# (T-1073 exception) — guard _load_markers by this id, not by enumerating -# Sol's four bodies (GJ0d, GJ0d-1, GJ0e, GJ0f-2). -const SOL_SYSTEM_ID: String = "GJ-0" - -# ── Overlay definitions (single source of truth, review #7) ───────────────── -## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and -## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal -## visibility ladder: always = public, toggle = observable, locked = semi- -## private / private. Keep this table in sync with D-191 §7. -const OVERLAY_DEFS: Array = [ - { - "id": "terrain", - "label": "TER", - "group": "always", - "tooltip": "Terrain — base heightmap. Always visible." - }, - { - "id": "infrastructure", - "label": "INF", - "group": "always", - "tooltip": "Infrastructure — roads + rail. Always visible." - }, - { - "id": "named_features", - "label": "NAM", - "group": "always", - "tooltip": "Named features — rivers, oceans, ranges. Always visible." - }, - { - "id": "gate_markers", - "label": "GAT", - "group": "always", - "tooltip": "Gate + spaceport markers. Always visible." - }, - { - "id": "political_zones", - "label": "POL", - "group": "always", - "tooltip": "Political zones — currency-zone bands. Always visible." - }, - { - "id": "population_density", - "label": "POP", - "group": "toggle", - "tooltip": "Population density (public signal)." - }, - { - "id": "production_zones", - "label": "PRD", - "group": "toggle", - "tooltip": "Production zones (observable — requires presence)." - }, - { - "id": "corp_presence", - "label": "CRP", - "group": "toggle", - "tooltip": "Corporate presence — Tier 1 only (observable)." - }, - { - "id": "stockpile_weeks", - "label": "STK", - "group": "locked", - "tooltip": "Stockpile weeks — LOCKED. Needs corporate contact." - }, - { - "id": "production_vs_baseline", - "label": "BSL", - "group": "locked", - "tooltip": "Production vs baseline — LOCKED. Needs insider access." - }, - { - "id": "gen_l1_rivers", - "label": "RVR", - "group": "toggle", - "tooltip": "River network (generation overlay)." - }, - { - "id": "gen_l1_basins", - "label": "BAS", - "group": "toggle", - "tooltip": "Drainage basins (generation overlay)." - }, - { - "id": "gen_l1_attractors", - "label": "ATR", - "group": "toggle", - "tooltip": "Geographic attractors (generation overlay)." - }, - { - "id": "gen_district", - "label": "MRPH", - "group": "toggle", - "tooltip": "District morphology zones (generation overlay)." - }, - { - "id": "gen_l2_roads", - "label": "RDS", - "group": "toggle", - "tooltip": - "Inter-settlement road/rail graph, colored by maintenance authority (generation overlay)." - }, - { - "id": "gen_l3_settlements", - "label": "STL", - "group": "toggle", - "tooltip": "Settlement placements, sized by population (generation overlay)." - }, - { - "id": "gen_region_grid", - "label": "TMP", - "group": "toggle", - "tooltip": "Region climate — mean temperature (generation overlay)." - }, - { - "id": "gen_l4_quarters", - "label": "QTR", - "group": "toggle", - "tooltip": "Quarter footprints — color is density, shape is district type (generation overlay)." - }, -] - -# ── Context (set by show_body) ───────────────────────────────────────────────── -var _body: Dictionary = {} -var _system: Dictionary = {} -var _implant_theme = null - -# ── Heightmap + markers ─────────────────────────────────────────────────────── -var _heightmap_texture: Texture2D = null -var _markers: Dictionary = {} -# Generation-cascade state (#960, D-225) is split into two companion objects, -# both extracted from this file (T-1118) to keep it under the gdlint -# max-file-lines cap: AtlasGenerationState owns the per-layer data -# (get/set_generation_*() accessors below delegate to it) and AtlasGenerationProxy -# owns the proxy-polling/retry/pending-indicator machinery. -# -# _gen_state is built as a FIELD DEFAULT (not in _ready()) — it is a pure -# RefCounted with no tree dependency, and get/set_generation_*() must work on -# a bare `AtlasViewer.new()` that never enters the scene tree (the -# established test pattern throughout this suite — auto_free(AtlasViewer.new()) -# with no add_child(), so _ready() never fires). _overlay_node isn't bound -# until _ready() (bind_overlay_node() below), so a redraw is a safe no-op -# until then — see atlas_generation_state.gd's _redraw() null-guard. -# -# _gen_proxy is a Node (needs get_tree() for its retry timer, owns the -# pending-indicator child Control) and is built in _ready() like every other -# child node on this class — nothing calls into it before the tree exists. -var _gen_state = load("res://ui/implant/apps/atlas/atlas_generation_state.gd").new() -var _gen_proxy = null -var _grid_w: float = 512.0 -var _grid_h: float = 256.0 -var _tex_w: float = 1024.0 -var _tex_h: float = 512.0 - -# T-949: body_id of an in-flight CityNamesRequest, "" if none. Non-Sol only — -# Sol keeps the synchronous legacy markers.json read (D-236). Used both to -# gate _on_city_names_received against a stale response (the viewer moved to -# a different body while a request was in flight) and to replay the request -# if the bridge wasn't connected yet when _load_markers first asked. -var _city_names_pending_body: String = "" - -# ── View state (T-1138: FIXED — set only by _fit_to_view()/set_view()) ───── -var _view_offset: Vector2 = Vector2.ZERO -var _view_zoom: float = 1.0 - -# ── Selection ───────────────────────────────────────────────────────────────── -var _selected_city: Dictionary = {} -var _hovered_city: Dictionary = {} -var _hover_screen_pos: Vector2 = Vector2.ZERO # T-1138 descent reticle cursor tracking -var _hover_active: bool = false - -# ── Overlay visibility (#836 plugs in here) ─────────────────────────────────── -## Runtime state derived from OVERLAY_DEFS in _ready() — always-on overlays -## start true, toggleables false, locked entries are tracked separately and -## can't be flipped by set_overlay_visible(). -var _overlay_visibility: Dictionary = {} -var _overlay_locked: Dictionary = {} - -# ── Child nodes ─────────────────────────────────────────────────────────────── -var _canvas: Node2D = null # transformed node holding heightmap + markers -var _overlay_node: AtlasMarkerOverlay = null # draws on top of heightmap -var _city_panel = null # ImplantPanel sidebar (city data) -var _empty_notice = null # ImplantPanel shown when heightmap missing -var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8) -var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition) -var _legend_panel = null # ImplantPanel — D-226 item 3, left-side generation-overlay legend - - -func _ready() -> void: - anchor_right = 1.0 - anchor_bottom = 1.0 - grow_horizontal = Control.GROW_DIRECTION_BOTH - grow_vertical = Control.GROW_DIRECTION_BOTH - mouse_filter = Control.MOUSE_FILTER_STOP - focus_mode = Control.FOCUS_ALL - - _implant_theme = load("res://ui/implant/default_implant.tres") - - for def: Dictionary in OVERLAY_DEFS: - var group: String = def["group"] - _overlay_visibility[def["id"]] = (group == "always") - if group == "locked": - _overlay_locked[def["id"]] = true - - # Build transformed canvas that holds heightmap + marker overlay - _canvas = Node2D.new() - _canvas.name = "AtlasCanvas" - add_child(_canvas) - - _overlay_node = AtlasMarkerOverlay.new() - _overlay_node.name = "MarkerOverlay" - _overlay_node.viewer = self - _canvas.add_child(_overlay_node) - - # _gen_state itself was already constructed as a field default (see the - # comment there) — bind its redraw target now that _overlay_node exists. - _gen_state.bind_overlay_node(_overlay_node) - - # #960: proxy-polling + diegetic "generating" indicator (D-225). Added as a - # child Node (not RefCounted) so it can own the indicator Control and use - # get_tree() for the retry timer; see atlas_generation_proxy.gd. - _gen_proxy = load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self) - _gen_proxy.name = "GenerationProxy" - add_child(_gen_proxy) - - _build_screen_header() - _build_city_panel() - _build_empty_notice() - _build_overlay_bar() - _build_legend_panel() - - # #960: receive proxied Layer-1 generation output (D-225). - SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) - - # T-949: receive the per-body atlas city-name pool (non-Sol _load_markers - # path) and know when a reconnect should replay a pending request. - SimBridge.city_names_received.connect(_on_city_names_received) - SimBridge.connection_state_changed.connect(_on_connection_state_changed) - - -func _exit_tree() -> void: - if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): - SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) - if SimBridge.city_names_received.is_connected(_on_city_names_received): - SimBridge.city_names_received.disconnect(_on_city_names_received) - if SimBridge.connection_state_changed.is_connected(_on_connection_state_changed): - SimBridge.connection_state_changed.disconnect(_on_connection_state_changed) - - -## Called by RegionalScreen.enter() when entering the viewer for a specific body. -func show_body(body: Dictionary, system: Dictionary) -> void: - _body = body - _system = system - _selected_city = {} - _hovered_city = {} - _load_heightmap() - _load_markers() - _fit_to_view() - _refresh_screen_header() - _city_panel.visible = false - _empty_notice.visible = (_heightmap_texture == null) - _gen_state.reset_layer1() - _gen_proxy.reset() - _gen_proxy.request(_dict_str(_body, "body_id", "")) - grab_focus() - queue_redraw() - _overlay_node.queue_redraw() - - -## #836 hook — toggle an overlay. Locked overlays are no-ops. -func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: - if _overlay_locked.get(overlay_id, false): - return - if not _overlay_visibility.has(overlay_id): - push_warning("AtlasViewer: unknown overlay id '%s'" % overlay_id) - return - _overlay_visibility[overlay_id] = visible_state - _overlay_node.queue_redraw() - _legend_panel.refresh() - - -func is_overlay_visible(overlay_id: String) -> bool: - return bool(_overlay_visibility.get(overlay_id, false)) - - -func is_overlay_locked(overlay_id: String) -> bool: - return bool(_overlay_locked.get(overlay_id, false)) - - -func get_heightmap_texture() -> Texture2D: - return _heightmap_texture - - -func get_markers() -> Dictionary: - return _markers - - -## Current pan/zoom scale — exposed so AtlasMarkerOverlay can gate zoom- -## dependent behavior (e.g. T-960 settlement name labels "at sensible zoom") -## without reaching into the viewer's private state. -func get_view_zoom() -> float: - return _view_zoom - - -## Current pan offset (canvas-space translation) — paired with get_view_zoom() -## so callers can read back the full view transform without reaching into -## private state (T-1120, same rationale as get_view_zoom()). -func get_view_offset() -> Vector2: - return _view_offset - - -## Programmatic view control (T-1120 capture harness) — survives T-1138's -## removal of user drag-pan/wheel-zoom; the harness still overrides the FIXED -## view deterministically. Clamps zoom to [MIN_ZOOM, MAX_ZOOM] like _fit_to_view(). -func set_view(zoom: float, offset: Vector2) -> void: - _view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM) - _view_offset = offset - _apply_transform() - - -## Per-layer generation accessors (#960, D-225; region_grid T-1118, quarter_footprints -## T-1119) — thin delegates onto AtlasGenerationState (see atlas_generation_state.gd -## for what each layer is and its store-then-redraw contract). Same public method -## names as before the T-1118 extraction; callers (atlas_marker_overlay.gd, -## test_atlas_overlays.gd) are unaffected. -func set_generation_layer1(layer1: Variant) -> void: - _gen_state.set_layer1(layer1) - - -func get_generation_layer1() -> Variant: - return _gen_state.get_layer1() - - -func set_generation_district_grid(grid: Variant) -> void: - _gen_state.set_district_grid(grid) - - -func get_generation_district_grid() -> Variant: - return _gen_state.get_district_grid() - - -func set_generation_road_graph(graph: Variant) -> void: - _gen_state.set_road_graph(graph) - - -func get_generation_road_graph() -> Variant: - return _gen_state.get_road_graph() - - -func set_generation_settlements(settlements: Variant) -> void: - _gen_state.set_settlements(settlements) - - -func get_generation_settlements() -> Variant: - return _gen_state.get_settlements() - - -func set_generation_region_grid(grid: Variant) -> void: - _gen_state.set_region_grid(grid) - - -func get_generation_region_grid() -> Variant: - return _gen_state.get_region_grid() - - -func set_generation_quarter_footprints(footprints: Variant) -> void: - _gen_state.set_quarter_footprints(footprints) - - -func get_generation_quarter_footprints() -> Variant: - return _gen_state.get_quarter_footprints() - - -## #960: forward a Layer-1 response to AtlasGenerationProxy, which handles -## staleness, retry, indicator, and dispatch to AtlasGenerationState (T-1118 -## extraction — see atlas_generation_proxy.gd). -func _on_atlas_layers_received(response: Dictionary) -> void: - _gen_proxy.on_response(response, _dict_str(_body, "body_id", "")) - - -## Read-only accessors AtlasGenerationProxy needs (it is added as a child, not -## a subclass, so it reaches viewer state through the same public surface any -## other caller would use). -func get_implant_theme() -> Variant: - return _implant_theme - - -func get_body_id() -> String: - return _dict_str(_body, "body_id", "") - - -func get_hovered_city() -> Dictionary: - return _hovered_city - - -func get_selected_city() -> Dictionary: - return _selected_city - - -func get_overlay_defs() -> Array: - return OVERLAY_DEFS - - -# ============================================================================= -# Data loading -# ============================================================================= - - -func _load_heightmap() -> void: - # Reset to sentinel defaults so a body with no heightmap (or that fails to - # load) doesn't inherit the previous body's dimensions — otherwise - # grid_to_canvas would project markers using the prior texture size - # (review #2). - _heightmap_texture = null - _tex_w = 1024.0 - _tex_h = 512.0 - var ref: Variant = _body.get("terrain_reference") - if ref == null or str(ref).is_empty(): - return - # terrain_reference points at /heightmap.png — now the canonical - # 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the - # color reliefmap.png that sits beside it. - var path: String = str(ref).get_base_dir() + "/reliefmap.png" - if not path.begins_with("/"): - var project_root: String = ( - ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() - ) - path = project_root + "/" + path - if not FileAccess.file_exists(path): - push_warning("AtlasViewer: reliefmap not found at %s" % path) - return - var img := Image.load_from_file(path) - if img != null: - _heightmap_texture = ImageTexture.create_from_image(img) - _tex_w = float(_heightmap_texture.get_width()) - _tex_h = float(_heightmap_texture.get_height()) - - -## T-949: for Sol (D-236) this is still the synchronous legacy file read; for -## every other body it fires an async CityNamesRequest and _markers populates -## later, in _on_city_names_received. -func _load_markers() -> void: - _markers = {} - # Reset grid dims alongside _tex_* in _load_heightmap so we start from a - # known baseline regardless of which body ran previously. - _grid_w = _tex_w - _grid_h = _tex_h - _city_names_pending_body = "" - - var system_id: String = _dict_str(_system, "system_id", "") - if system_id == SOL_SYSTEM_ID: - _load_sol_markers_legacy() - return - - var body_id: String = _dict_str(_body, "body_id", "") - if body_id.is_empty(): - return - _city_names_pending_body = body_id - SimBridge.request_city_names(body_id) - - -## D-236/T-1073 SOL EXCEPTION (load-bearing — do not "clean up"): Sol bodies -## (GJ0d, GJ0d-1, GJ0e, GJ0f-2, system GJ-0) are permanently excluded from the -## deterministic generation cascade (D-236), so there is no server-side -## atlas_city_names/geometry source to request instead — Sol's markers.json -## is the one client file read T-949 does NOT migrate, pending T-1073 -## (gated on Q-107). This is byte-for-byte the pre-T-949 _load_markers body. -func _load_sol_markers_legacy() -> void: - var ref: Variant = _body.get("terrain_reference") - if ref == null or str(ref).is_empty(): - return - var hm_path: String = str(ref) - if not hm_path.begins_with("/"): - var project_root: String = ( - ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() - ) - hm_path = project_root + "/" + hm_path - var dir: String = hm_path.get_base_dir() - var markers_path: String = dir + "/markers.json" - - if not FileAccess.file_exists(markers_path): - return - var f := FileAccess.open(markers_path, FileAccess.READ) - if f == null: - return - var parsed: Variant = JSON.parse_string(f.get_as_text()) - f.close() - if not (parsed is Dictionary): - return - _markers = parsed - var grid: Dictionary = _markers.get("grid", {}) - _grid_w = float(grid.get("w", _tex_w)) - _grid_h = float(grid.get("h", _tex_h)) - - -## T-949: CityNamesResponse handler for non-Sol bodies (dudley-atlas-server -## contract, 2026-07-14). Ignores responses for a body the viewer has since -## navigated away from. `cities` is a flat [{city_id, name, is_capital}] array -## — NOT the legacy markers.json shape (no rivers/oceans/mountain_ranges pool, -## no position; positions arrive separately via the gen_l3_settlements -## overlay/T-960's SettlementLayer) — stored under a dedicated "city_names" -## key so it can never collide with the top-level "cities" key the legacy -## Sol reader/_draw_cities()/_find_city_at() expect (those entries have no -## pos/center/lat+lon, so they'd all plot at Vector2.ZERO if merged in). -## SolExcluded is a defensive backstop (D-236): the server refuses Sol bodies -## even though atlas_viewer.gd's own SOL_SYSTEM_ID guard should make this -## path rare — falls back to the legacy synchronous read either way. -func _on_city_names_received(response: Dictionary) -> void: - var body_id: String = _dict_str(_body, "body_id", "") - if str(response.get("body_id", "")) != body_id: - return - _city_names_pending_body = "" - match str(response.get("status", "")): - "Ready": - _markers = {"city_names": response.get("cities", [])} - "SolExcluded": - _load_sol_markers_legacy() - _: - pass # Error — leave markers empty (#960/D-191's empty-markers case) - queue_redraw() - _overlay_node.queue_redraw() - - -## T-949: the Atlas can be opened before the bridge finishes its handshake — -## replay the in-flight CityNamesRequest once we actually reach CONNECTED -## instead of leaving it stranded (request_city_names() no-ops silently while -## disconnected, and unlike the Layer1 proxy there is no Pending status to -## trigger a retry timer). -func _on_connection_state_changed(_old_state: int, new_state: int) -> void: - if new_state == SimBridge.ConnectionState.CONNECTED and not _city_names_pending_body.is_empty(): - SimBridge.request_city_names(_city_names_pending_body) - - -## True while a non-Sol body's CityNamesRequest is in flight (T-949). Sol -## bodies never set this — they use the synchronous legacy read (D-236). -func has_pending_city_names_request() -> bool: - return not _city_names_pending_body.is_empty() - - -# ============================================================================= -# View transform -# ============================================================================= - - -func _fit_to_view() -> void: - if _heightmap_texture == null: - _view_zoom = 1.0 - _view_offset = Vector2.ZERO - _canvas.position = _view_offset - _canvas.scale = Vector2(_view_zoom, _view_zoom) - return - var sz: Vector2 = get_rect().size - if sz == Vector2.ZERO: - sz = Vector2(1280.0, 720.0) - var avail: Vector2 = sz - Vector2(0, 60) # leave space for header - var fit_x: float = avail.x / _tex_w - var fit_y: float = avail.y / _tex_h - _view_zoom = clampf(minf(fit_x, fit_y) * 0.92, MIN_ZOOM, MAX_ZOOM) - var scaled: Vector2 = Vector2(_tex_w, _tex_h) * _view_zoom - _view_offset = (sz - scaled) * 0.5 + Vector2(0, 20) - _apply_transform() - - -func _apply_transform() -> void: - _canvas.position = _view_offset - _canvas.scale = Vector2(_view_zoom, _view_zoom) - queue_redraw() - _overlay_node.queue_redraw() - - -## Convert grid coordinates (from markers.json) to canvas-space (texture pixels). -func grid_to_canvas(grid_point: Vector2) -> Vector2: - if _grid_w <= 0.0 or _grid_h <= 0.0: - return Vector2.ZERO - return Vector2(grid_point.x / _grid_w * _tex_w, grid_point.y / _grid_h * _tex_h) - - -func canvas_to_screen(canvas_point: Vector2) -> Vector2: - return canvas_point * _view_zoom + _view_offset - - -func screen_to_canvas(screen_point: Vector2) -> Vector2: - if _view_zoom == 0.0: - return Vector2.ZERO - return (screen_point - _view_offset) / _view_zoom - - -# ============================================================================= -# Drawing (background + header) -# ============================================================================= - - -func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) - if _should_draw_descend_reticle(): - _draw_descend_reticle() - - -func _should_draw_descend_reticle() -> bool: # hidden over a city — _try_click_city wins the click - var base_ok: bool = _hover_active and _heightmap_texture != null and _hovered_city.is_empty() - return base_ok and _is_screen_pos_on_texture(_hover_screen_pos) - - -func _is_screen_pos_on_texture(screen_pos: Vector2) -> bool: # T-1142: ONE bounds-gate helper - return AtlasDescendGeometry.is_on_texture(screen_to_canvas(screen_pos), _tex_w, _tex_h) - - -func _draw_descend_reticle() -> void: # draw calls only — geometry in atlas_descend_geometry.gd - var center: Vector2 = _hover_screen_pos - for seg: Array in AtlasDescendGeometry.reticle_segments(center): - draw_line(seg[0], seg[1], AtlasDescendGeometry.COLOR_DESCEND_RETICLE, 1.5) - var label: Dictionary = AtlasDescendGeometry.reticle_label(center) - draw_string( - ThemeDB.fallback_font, - label["position"], - label["text"], - HORIZONTAL_ALIGNMENT_LEFT, - -1, - 10, - AtlasDescendGeometry.COLOR_DESCEND_RETICLE - ) - - -func _build_screen_header() -> void: - # D-169: compose the title + hint from ImplantHeader so the implant theme - # drives fonts/colors. Review #4 flagged the draw_string() approach as a - # theme-swap invariant violation. - _screen_header = ImplantHeader.new() - _screen_header.position = Vector2(PANEL_MARGIN, 16.0) - _screen_header.custom_minimum_size.x = 320.0 - _screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_screen_header) - if _implant_theme: - _screen_header.apply_implant_theme(_implant_theme) - - -func _refresh_screen_header() -> void: - if _screen_header == null: - return - var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) - var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", "—")) - var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()] - var hint: String = "click descend · click city data · esc back" - _screen_header.set_content(title, hint) - - -## Safely extract a string field from a dict, falling back when missing or null. -static func _dict_str(d: Dictionary, key: String, fallback: String) -> String: - var v: Variant = d.get(key) - if v == null: - return fallback - var s: String = str(v) - if s.is_empty(): - return fallback - return s - - -# ============================================================================= -# Input -# ============================================================================= - - -func _is_over_ui(pos: Vector2) -> bool: - # The overlay bar is deliberately NOT checked here (PR #186 review): its - # container spans the full header-adjacent width for row wrapping but is - # MOUSE_FILTER_IGNORE — chip Buttons STOP their own events (they never - # reach this viewer), and the empty flow area left of the right-aligned - # chips must stay interactive map surface. Checking the wide bar rect - # would re-create the ~700px dead strip the review caught. - if _city_panel and _city_panel.visible and _city_panel.get_global_rect().has_point(pos): - return true - return false - - -func _gui_input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and not event.is_echo(): - _handle_key(event as InputEventKey) - return - - if _heightmap_texture == null: - return - - if ( - event is InputEventMouseButton - and _is_over_ui((event as InputEventMouseButton).global_position) - ): - return - - if event is InputEventMouseButton: - var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: - if not _try_click_city(mb.position): - _descend_at(mb.position) - elif event is InputEventMouseMotion: - if _is_over_ui((event as InputEventMouseMotion).global_position): - return - _update_hover((event as InputEventMouseMotion).position) - - -func _handle_key(event: InputEventKey) -> void: - match event.keycode: - KEY_ESCAPE: - if _selected_city.size() > 0: - _selected_city = {} - _city_panel.visible = false - queue_redraw() - else: - back_pressed.emit() - KEY_R: - _fit_to_view() - - -func _try_click_city(screen_pos: Vector2) -> bool: # T-1138: a city's hit-radius wins over descent - var city: Dictionary = _find_city_at(screen_pos) - if city.is_empty(): - return false - _selected_city = city - _rebuild_city_panel() - _city_panel.visible = true - queue_redraw() - return true - - -func _descend_at(screen_pos: Vector2) -> void: # T-1142: inert off-texture (letterbox) - if not _is_screen_pos_on_texture(screen_pos): - return - district_descend_requested.emit(_district_pos_at(screen_pos)) - - -func _update_hover(screen_pos: Vector2) -> void: - var new_hover: Dictionary = _find_city_at(screen_pos) - if new_hover != _hovered_city: - _hovered_city = new_hover - _hover_screen_pos = screen_pos - _hover_active = true - _overlay_node.queue_redraw() - - -func _find_city_at(screen_pos: Vector2) -> Dictionary: - var cities: Array = _markers.get("cities", []) - if cities.is_empty(): - return {} - var hit_radius: float = 12.0 - for c: Dictionary in cities: - var canvas_pt: Vector2 = city_canvas_pos(c) - var screen_pt: Vector2 = canvas_to_screen(canvas_pt) - if screen_pos.distance_to(screen_pt) <= hit_radius: - return c - return {} - - -## Extract canvas-space position for a city marker. Supports `pos: [x,y]`, -## `lat`/`lon` (equirectangular), and legacy `center: [row,col]`. Public so -## AtlasMarkerOverlay doesn't need to reach into private state (review #5). -func city_canvas_pos(city: Dictionary) -> Vector2: - if city.has("pos") and city["pos"] is Array and city["pos"].size() >= 2: - return grid_to_canvas(Vector2(float(city["pos"][0]), float(city["pos"][1]))) - if city.has("lat") and city.has("lon"): - var lon: float = float(city["lon"]) - var lat: float = float(city["lat"]) - return Vector2((lon + 180.0) / 360.0 * _tex_w, (90.0 - lat) / 180.0 * _tex_h) - if city.has("center") and city["center"] is Array and city["center"].size() >= 2: - # center is [row, col] in grid space - return grid_to_canvas(Vector2(float(city["center"][1]), float(city["center"][0]))) - return Vector2.ZERO - - -func _district_pos_at(screen_pos: Vector2) -> Vector2i: # T-1138, geometry: atlas_descend_geometry.gd - var canvas_pt: Vector2 = screen_to_canvas(screen_pos) - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - return AtlasDescendGeometry.district_pos_at(canvas_pt, _tex_w, _tex_h, radius_km) - - -# ============================================================================= -# City data panel (sidebar) -# ============================================================================= - - -func _build_city_panel() -> void: - _city_panel = ImplantPanel.new() - _city_panel.name = "CityPanel" - _city_panel.theme_resource = _implant_theme - _city_panel.custom_minimum_size.x = PANEL_WIDTH - _city_panel.mouse_filter = Control.MOUSE_FILTER_STOP - _city_panel.visible = false - add_child(_city_panel) - _position_city_panel() - - -func _position_city_panel() -> void: - var sz: Vector2 = get_rect().size - if sz == Vector2.ZERO: - sz = Vector2(1280.0, 720.0) - _city_panel.position = Vector2(sz.x - PANEL_WIDTH - PANEL_MARGIN, 60.0) - - -func _rebuild_city_panel() -> void: - if not _city_panel: - return - _city_panel.clear() - _position_city_panel() - - if _selected_city.is_empty(): - return - - var c: Dictionary = _selected_city - var name_str: String = _dict_str(c, "name", "(unnamed)") - var tier: int = int(c.get("population_tier", 0)) - var func_label: String = _dict_str(c, "primary_function", "").replace("_", " ") - var is_gate: bool = bool(c.get("gate_terminal", false)) - - var sys_label: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", "")) - var body_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "")) - _city_panel.add_component(ImplantHeader.new(name_str, sys_label + " · " + body_label)) - _city_panel.add_component(ImplantSeparator.new()) - - if tier > 0: - _city_panel.add_component(ImplantDataRow.new("pop tier %d" % tier)) - if not func_label.is_empty(): - _city_panel.add_component(ImplantDataRow.new("function " + func_label)) - if is_gate: - _city_panel.add_component(ImplantDataRow.new("gate terminal")) - - # Cross-system facts - var czone: String = str(_system.get("currency_zone", "")).replace("_", " ") - if not czone.is_empty(): - _city_panel.add_component(ImplantDataRow.new("currency " + czone)) - var commission: bool = bool(c.get("commission_presence", false)) - _city_panel.add_component( - ImplantDataRow.new("commission " + ("yes" if commission else "no")) - ) - var shadow: Variant = c.get("shadow_economy_zone") - if shadow != null and not str(shadow).is_empty(): - _city_panel.add_component(ImplantDataRow.new("shadow zone " + str(shadow))) - var gate_dist: Variant = c.get("gate_distance_hops") - if gate_dist != null: - _city_panel.add_component(ImplantDataRow.new("gate distance %d hops" % int(gate_dist))) - - _city_panel.add_component(ImplantSeparator.new()) - _city_panel.add_component(ImplantTextBlock.new("n open economics monitor for this system")) - _city_panel.add_component(ImplantTextBlock.new("esc close")) - - -func _unhandled_key_input(event: InputEvent) -> void: - if not visible: - return - if not (event is InputEventKey) or not event.pressed or event.is_echo(): - return - var ek := event as InputEventKey - if ek.keycode != KEY_N: - return - # Always consume KEY_N while the viewer is visible — otherwise it would - # fall through to main.gd's global economics monitor toggle and close the - # fullscreen atlas as a side effect (review #6). - get_viewport().set_input_as_handled() - if _selected_city.size() > 0: - var sys_id: String = _system.get("system_id", "") - if not sys_id.is_empty(): - economics_link_requested.emit(sys_id) - - -# ============================================================================= -# Empty-state notice -# ============================================================================= - - -func _build_empty_notice() -> void: - _empty_notice = ImplantPanel.new() - _empty_notice.name = "EmptyNotice" - _empty_notice.theme_resource = _implant_theme - _empty_notice.custom_minimum_size.x = 400.0 - _empty_notice.mouse_filter = Control.MOUSE_FILTER_IGNORE - _empty_notice.visible = false - add_child(_empty_notice) - _empty_notice.add_component( - ImplantHeader.new("TERRAIN DATA PENDING", "regional atlas unavailable") - ) - _empty_notice.add_component(ImplantSeparator.new()) - var notice_text: String = ( - "The server atlas pipeline has not yet populated terrain_reference for this body (#839). " - + "Once regenerated, the heightmap and markers will render here." - ) - _empty_notice.add_component(ImplantTextBlock.new(notice_text)) - _empty_notice.add_component(ImplantSeparator.new()) - _empty_notice.add_component(ImplantTextBlock.new("esc back to orbital view")) - _position_empty_notice() - - -func _position_empty_notice() -> void: - var sz: Vector2 = get_rect().size - if sz == Vector2.ZERO: - sz = Vector2(1280.0, 720.0) - _empty_notice.position = Vector2((sz.x - 400.0) * 0.5, sz.y * 0.35) - - -# ============================================================================= -# Overlay toggle bar (#836) -# ============================================================================= - - -func _build_overlay_bar() -> void: - var BarScript := load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd") - _overlay_bar = BarScript.new(self) - _overlay_bar.name = "OverlayBar" - add_child(_overlay_bar) - _position_overlay_bar() - - -func _position_overlay_bar() -> void: - var sz: Vector2 = get_rect().size - if sz == Vector2.ZERO: - sz = Vector2(1280.0, 720.0) - # Top-right, constrained to everything right of the header. The bar is an - # HFlowContainer with ALIGNMENT_END: give it the available width and it - # wraps into as many right-aligned rows as the chip count needs (17 chips - # overflowed the screen edge as a single HBox row — companion feedback, - # 2026-07-21). Height is Control-clamped to the flow's minimum, so it - # grows per row on its own. - var avail_w: float = maxf(sz.x - OVERLAY_BAR_HEADER_RESERVE - PANEL_MARGIN * 2.0, 200.0) - _overlay_bar.position = Vector2(sz.x - avail_w - PANEL_MARGIN, PANEL_MARGIN) - _overlay_bar.size = Vector2(avail_w, 0.0) - - -# ============================================================================= -# Generation legend panel (D-226 item 3) -# ============================================================================= - - -## Loaded by path (matching atlas_overlay_bar.gd, review #8): the panel needs -## the viewer reference at construction time, and a class_name + required-arg -## _init() combo is a Godot editor footgun. GENERATION_LEGEND (the spec table) -## and the road/settlement legend colors live on this script, not here — see -## atlas_legend_panel.gd. -func _build_legend_panel() -> void: - var LegendScript := load("res://ui/implant/apps/atlas/atlas_legend_panel.gd") - _legend_panel = LegendScript.new(self) - _legend_panel.name = "GenerationLegend" - _legend_panel.theme_resource = _implant_theme - add_child(_legend_panel) - _legend_panel.refresh() - - -func _notification(what: int) -> void: - if what == NOTIFICATION_RESIZED: - if _city_panel: - _position_city_panel() - if _empty_notice: - _position_empty_notice() - if _overlay_bar: - _position_overlay_bar() - if _legend_panel: - _legend_panel.reposition() - elif what == NOTIFICATION_MOUSE_EXIT and _hover_active: - _hover_active = false # T-1138: hide the reticle when the cursor leaves - _overlay_node.queue_redraw() diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index f4a87cdc8..976c560b9 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -264,13 +264,29 @@ func _active_toggle_overlay() -> String: ## < 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 ## 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: + 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( 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 _held_rung = StepCanvasTransport.rung_at_index(_rung_index) _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() +## 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 ## the canonical Global body-surface frame"). func _reset_to_global() -> void: - if _rung_index == 0: + if _rung_index == 0 and not _is_global_view_drifted(): return _rung_index = 0 _held_rung = StepCanvasTransport.RUNG_GLOBAL