From d28d24fd266cc6235fb8ab063ea6ba8bf9ca39bb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 25 Jul 2026 08:53:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20step-canvas=20map=20component=20?= =?UTF-8?q?=E2=80=94=20RTT=20terrain=20+=20stepped=20zoom=20(D-255,=20T-11?= =?UTF-8?q?82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-layer client rebuild per D-255(a)(b)(e), replacing the _canvas.scale continuous-zoom model with one viewer, one path, all six rungs: - step_canvas_protocol.gd: StepCanvasRequest/Response codec against the T-1181 wire contract — incl. the discovered png_bytes subtlety (rmp_serde without serde_bytes emits a msgpack int-array, not bin; decode repacks via PackedByteArray before load_png_from_buffer) and the extent-echo rule (read the server-clamped extent, never assume the requested one). - step_canvas/ component: transport (six-rung ladder, cursor-anchored scroll steps, edge-scroll/WASD pan with re-request on edge crossing, hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize per the c1 measured ruling, texture.update reuse on step-cross, NEAREST coarse / LINEAR fine per rung), unscaled screen-space annotation sibling (courses + settlement markers at literal px), in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath), request lifecycle (pending retry, staleness gate, extent echo). - Full _canvas.scale retirement in the same change: the zoom-scaled canvas model, the _zs compensation family, select_rung / MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window two-path split, _view_zoom/_canonical_fit_zoom — 10 source files deleted; their 14 test suites deleted with them (T-1157 dead-goldens rule; replacement visual-capture coverage is re-scoped T-1157). - Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU shape (the ticket's named file atlas_window_tile_set.gd was the retiring orchestrator; the real LRU shape lives in atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay colors, legend/overlay-bar chrome, AtlasViewer descend geometry. Determinism boundary per D-255(e): the client interpolates only within the closed server-supplied input set. 7 new gdUnit suites (164 cases) incl. a real extent-echo bug caught by its own test during implementation. Full client suite green (exit 0) with the live-gated suites running against a worktree server build. Co-Authored-By: Claude Fable 5 --- client/scripts/autoloads/sim_bridge.gd | 31 + client/scripts/protocol/protocol.gd | 50 +- .../scripts/protocol/step_canvas_protocol.gd | 177 +++ client/tests/test_atlas_cold_start.gd | 311 ----- client/tests/test_atlas_descend_entry.gd | 16 +- client/tests/test_atlas_window_geometry.gd | 979 ---------------- .../test_atlas_window_geometry_nature.gd | 885 -------------- ...test_atlas_window_geometry_stroke_width.gd | 252 ---- .../tests/test_atlas_window_nature_overlay.gd | 579 ---------- ..._atlas_window_nature_overlay_draw_smoke.gd | 299 ----- client/tests/test_atlas_window_overlay.gd | 399 ------- .../test_atlas_window_overlay_draw_smoke.gd | 407 ------- client/tests/test_atlas_window_request.gd | 652 ----------- client/tests/test_atlas_window_tile_set.gd | 434 ------- client/tests/test_atlas_window_viewer.gd | 917 --------------- client/tests/test_atlas_window_water_clip.gd | 368 ------ client/tests/test_atlas_zoom_ladder.gd | 952 --------------- client/tests/test_regional_screen.gd | 90 -- .../test_step_canvas_annotation_layer.gd | 76 ++ client/tests/test_step_canvas_cache.gd | 123 ++ client/tests/test_step_canvas_colorize.gd | 128 +++ client/tests/test_step_canvas_protocol.gd | 212 ++++ client/tests/test_step_canvas_request.gd | 208 ++++ client/tests/test_step_canvas_transport.gd | 190 +++ client/tests/test_step_canvas_viewer.gd | 110 ++ client/ui/implant/apps/atlas/atlas_app.gd | 8 +- .../apps/atlas/atlas_window_geometry.gd | 727 ------------ .../atlas/atlas_window_geometry_nature.gd | 685 ----------- .../apps/atlas/atlas_window_nature_overlay.gd | 750 ------------ .../apps/atlas/atlas_window_overlay.gd | 600 ---------- .../apps/atlas/atlas_window_request.gd | 464 -------- .../apps/atlas/atlas_window_tile_set.gd | 203 ---- .../implant/apps/atlas/atlas_window_viewer.gd | 1017 ----------------- .../apps/atlas/atlas_window_water_clip.gd | 171 --- .../apps/atlas/screens/regional_screen.gd | 60 +- .../step_canvas_annotation_layer.gd | 178 +++ .../atlas/step_canvas/step_canvas_cache.gd | 107 ++ .../atlas/step_canvas/step_canvas_colorize.gd | 115 ++ .../step_canvas_legend.gd} | 63 +- .../atlas/step_canvas/step_canvas_request.gd | 194 ++++ .../step_canvas/step_canvas_terrain_layer.gd | 146 +++ .../step_canvas/step_canvas_transport.gd | 243 ++++ .../atlas/step_canvas/step_canvas_viewer.gd | 523 +++++++++ 43 files changed, 2858 insertions(+), 12241 deletions(-) create mode 100644 client/scripts/protocol/step_canvas_protocol.gd delete mode 100644 client/tests/test_atlas_cold_start.gd delete mode 100644 client/tests/test_atlas_window_geometry.gd delete mode 100644 client/tests/test_atlas_window_geometry_nature.gd delete mode 100644 client/tests/test_atlas_window_geometry_stroke_width.gd delete mode 100644 client/tests/test_atlas_window_nature_overlay.gd delete mode 100644 client/tests/test_atlas_window_nature_overlay_draw_smoke.gd delete mode 100644 client/tests/test_atlas_window_overlay.gd delete mode 100644 client/tests/test_atlas_window_overlay_draw_smoke.gd delete mode 100644 client/tests/test_atlas_window_request.gd delete mode 100644 client/tests/test_atlas_window_tile_set.gd delete mode 100644 client/tests/test_atlas_window_viewer.gd delete mode 100644 client/tests/test_atlas_window_water_clip.gd delete mode 100644 client/tests/test_atlas_zoom_ladder.gd delete mode 100644 client/tests/test_regional_screen.gd create mode 100644 client/tests/test_step_canvas_annotation_layer.gd create mode 100644 client/tests/test_step_canvas_cache.gd create mode 100644 client/tests/test_step_canvas_colorize.gd create mode 100644 client/tests/test_step_canvas_protocol.gd create mode 100644 client/tests/test_step_canvas_request.gd create mode 100644 client/tests/test_step_canvas_transport.gd create mode 100644 client/tests/test_step_canvas_viewer.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_geometry.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_overlay.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_request.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_tile_set.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_viewer.gd delete mode 100644 client/ui/implant/apps/atlas/atlas_window_water_clip.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd rename client/ui/implant/apps/atlas/{atlas_window_legend.gd => step_canvas/step_canvas_legend.gd} (55%) create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_request.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd create mode 100644 client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index f48038082..d8cb8a641 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -7,6 +7,7 @@ signal atlas_layers_received(response: Dictionary) signal star_map_received(response: Dictionary) # T-949: StarMapResponse signal city_names_received(response: Dictionary) # T-949: CityNamesResponse signal browse_response_received(response: Dictionary) # T-1131/T-1133: BrowseResponse +signal step_canvas_received(response: Dictionary) # T-1182, D-255(c): StepCanvasResponse signal handshake_complete signal handshake_failed(reason: String) @@ -568,6 +569,33 @@ func request_browse_detail(kind: String, entity_id: String) -> void: ) +## Request one step-canvas data canvas (T-1182, D-255(c)) — the stepped Atlas +## ladder's per-rung terrain/annotation payload. Live mode only; the response +## arrives via step_canvas_received. `rung` is one of the six bare-string rung +## tags (StepCanvasTransport.RUNG_*); `center`/`extent` are sent unconditionally +## even for Global (the server ignores them for that rung — see +## step_canvas_protocol.gd's own doc). No client-side polling loop here — the +## D-225 poll/cache/enqueue pattern means a Pending response is the caller's +## cue to retry, mirroring request_atlas_layers()'s own "fire and let the +## response routing decide" shape. +func request_step_canvas( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> void: + if test_mode or _bridge == null or state != ConnectionState.CONNECTED: + return + var bytes := Protocol.encode_step_canvas_request(body_id, rung, center, extent, min_wl_m) + if bytes.is_empty(): + return + var err: int = _bridge.send_message(bytes) + if err != OK: + push_error( + ( + "SimBridge: failed to send step canvas request for %s/%s: %s" + % [body_id, rung, error_string(err)] + ) + ) + + # Poll for snapshot from simulation. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: @@ -609,6 +637,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: if inbound.kind == "browse": browse_response_received.emit(inbound.value) return + if inbound.kind == "step_canvas": + step_canvas_received.emit(inbound.value) + return if inbound.kind != "snapshot": push_warning("SimBridge: undecodable frame (%d bytes)" % bytes.size()) return diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index b84d8f7ae..cfaed9a65 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -31,6 +31,12 @@ static func _amp(): return load("res://scripts/protocol/atlas_map_protocol.gd") +## StepCanvasRequest/StepCanvasResponse codec (T-1182, D-255(c)) — same +## load()-by-path rationale as _bp()/_amp() above. +static func _scp(): + return load("res://scripts/protocol/step_canvas_protocol.gd") + + # -- Decode: bytes from server → GDScript types -------------------------------- @@ -874,15 +880,41 @@ static func decode_browse_response(bytes: PackedByteArray) -> Variant: return browse_response_from_raw(decode_raw(bytes)) +## Encode a StepCanvasRequest (T-1182, D-255(c)) — the stepped Atlas ladder's +## data-canvas request. Delegates to step_canvas_protocol.gd — see that file +## for the full wire-shape rationale (the sixth Inbound discriminator, +## PNG-per-field decode note). +static func encode_step_canvas_request( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> PackedByteArray: + return _scp().encode_step_canvas_request(_mp(), body_id, rung, center, extent, min_wl_m) + + +## Build a StepCanvasResponse from an already-decoded raw value. See +## step_canvas_protocol.gd for the full field-by-field wire-shape rationale. +static func step_canvas_response_from_raw(raw: Variant) -> Variant: + return _scp().step_canvas_response_from_raw(raw) + + +## Decode a StepCanvasResponse from MessagePack bytes. See +## step_canvas_response_from_raw. +static func decode_step_canvas_response(bytes: PackedByteArray) -> Variant: + return step_canvas_response_from_raw(decode_raw(bytes)) + + ## Decode + classify one inbound frame (#960, D-225; T-949 adds starmap/ -## citynames; T-1131/T-1133 adds browse). Returns {kind, value}, kind one of -## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"unknown" — all msgpack -## maps, told apart by field, most-specific-first: StarMapResponse is the -## only kind with "data" and no "body_id"; CityNamesResponse the only one -## with "cities"; BrowseResponse the only one with its OWN "kind" field -## alongside "status"; anything else carrying "status" is AtlasLayerResponse. -## Lets receive_bytes decode the frame ONCE instead of double-decoding the -## 20 Hz snapshot path. +## citynames; T-1131/T-1133 adds browse; T-1182 adds step_canvas). Returns +## {kind, value}, kind one of +## "snapshot"|"atlas"|"starmap"|"citynames"|"browse"|"step_canvas"|"unknown" — +## all msgpack maps, told apart by field, most-specific-first: StarMapResponse +## is the only kind with "data" and no "body_id"; CityNamesResponse the only +## one with "cities"; BrowseResponse the only one with its OWN "kind" field +## alongside "status"; StepCanvasResponse the only one with "rung" (checked +## BEFORE the generic "status"-only AtlasLayerResponse fallback, since a +## step-canvas response also carries "status" and would otherwise be +## misrouted to "atlas"); anything else carrying "status" is +## AtlasLayerResponse. Lets receive_bytes decode the frame ONCE instead of +## double-decoding the 20 Hz snapshot path. static func decode_inbound(bytes: PackedByteArray) -> Dictionary: var raw = decode_raw(bytes) if not raw is Dictionary: @@ -895,6 +927,8 @@ static func decode_inbound(bytes: PackedByteArray) -> Dictionary: return {"kind": "citynames", "value": city_names_response_from_raw(raw)} if raw.has("kind") and raw.has("status"): return {"kind": "browse", "value": browse_response_from_raw(raw)} + if raw.has("rung") and raw.has("status"): + return {"kind": "step_canvas", "value": step_canvas_response_from_raw(raw)} if raw.has("status"): return {"kind": "atlas", "value": atlas_response_from_raw(raw)} return {"kind": "snapshot", "value": _decode_snapshot_from_raw(raw)} diff --git a/client/scripts/protocol/step_canvas_protocol.gd b/client/scripts/protocol/step_canvas_protocol.gd new file mode 100644 index 000000000..afb91c1c8 --- /dev/null +++ b/client/scripts/protocol/step_canvas_protocol.gd @@ -0,0 +1,177 @@ +class_name StepCanvasProtocol +## StepCanvasRequest/StepCanvasResponse wire codec (T-1182, D-255(c)) — the +## stepped Atlas ladder's tagged-envelope carrier, executing the D-225 +## migration server/src/atlas/step_canvas.rs already ships (T-1181, PR #201). +## Split out of protocol.gd (not folded in), same rationale as +## browse_protocol.gd/atlas_map_protocol.gd — stay under gdlint's +## max-file-lines, one file per wire-shape family. +## +## `step_canvas: true` is the mandatory discriminator field — the SIXTH +## `Inbound` shape server/src/bridge/mod.rs demuxes on (star_map/city_names/ +## browse/step_canvas, plus the two field-shape-disambiguated map probes). +## +## **Wire shapes (confirmed against server/src/atlas/step_canvas.rs, the +## authoritative source over any doc drift — T-1181's IMPLEMENTATION REPORT + +## WIRE SHAPE ADDENDUM, 2026-07-25):** +## StepCanvasRequest — a map: {step_canvas: true, body_id: String, +## rung: , center: [i64, i64], extent: [u32, u32], +## min_wl_m: u32}. `rung` is a unit-variant enum, encoded as its Rust +## variant NAME verbatim (rmp_serde's bare-enum convention, same as +## granularity_v2/RoadNodeKind/CourseTerminus elsewhere on this wire). +## `center`/`extent` are meaningless for Global (server ignores extent +## entirely, center is echoed but unused) but are still sent — the +## request shape carries them unconditionally, no per-rung omission (the +## server-side struct has no Option on either field). +## StepCanvasResponse — a map: {body_id, rung, center: [i64,i64], +## extent: [u32,u32], min_wl_m: u32, status, canvas: null | EncodedStepCanvas}. +## `extent` is the server-CLAMPED echo (PR #201 review) — ALWAYS read +## this back, never assume the requested extent; `(0,0)` for Global (the +## wire extent is never read for that rung, so there is no clamped value +## to report). `status` is the same bare-string-or-{"Error":"msg"} shape +## every other status enum on this wire uses (Ready|Pending|NotFound| +## Error(String)) — decoded via the shared _decode_status_field() shape +## (duplicated here per browse_protocol.gd's own "genuinely standalone" +## precedent, not shared via a Callable). +## EncodedStepCanvas — a map: {width, height, morphology, elev_q, temp_dc, +## moisture_q, vegetation, settlement_id, glaciation, flooded_q, courses, +## cliffs}. The six PNG-per-field dense planes (morphology/elev_q/ +## moisture_q/vegetation/glaciation/flooded_q) are each a map +## {"png_bytes": [...]} — png_bytes is a Rust `Vec` with NO +## serde_bytes annotation anywhere in this codebase (confirmed: grep for +## serde_bytes across server/src returns nothing), so serde's blanket +## Vec impl serializes it via serialize_seq — a msgpack ARRAY of +## small-integer elements, NOT a `bin` blob. messagepack.gd's decoder +## therefore returns a plain GDScript Array (one int per byte), which +## this codec repacks into a PackedByteArray via the direct +## PackedByteArray(array) constructor before handing it to +## Image.load_png_from_buffer() — decode_png_field() below is the one +## place this conversion happens. temp_dc/settlement_id are the OTHER +## two dense fields, shipped raw MessagePack-native (too wide for an +## 8-bit PNG plane per EncodedTempField/EncodedSettlementField's own +## server-side doc): {"values": [...]} — i16/u32 arrays respectively, +## passed through as plain Arrays, no PackedByteArray repack (their +## per-cell domain doesn't fit a byte anyway). courses/cliffs are sparse +## MessagePack-native lists, passed through unshaped (the annotation +## layer owns interpreting RiverCourse/CliffSegment's own per-entry +## shape, matching atlas_map_protocol.gd's existing "raw passthrough, +## caller reshapes" precedent for district_window's courses field). + + +## Encode a StepCanvasRequest. `mp` is the loaded messagepack.gd module +## (passed in rather than reloaded here, matching every other codec in this +## cluster). `rung` is one of the six bare-string rung tags +## (StepCanvasTransport.RUNG_* constants) — sent verbatim, no client-side +## validation (the server rejects an unrecognized variant name at decode +## time per step_canvas.rs's own doc: "Unknown -> rejected, never trusted +## from the wire"). +static func encode_step_canvas_request( + mp, body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> PackedByteArray: + var msg := { + "step_canvas": true, + "body_id": body_id, + "rung": rung, + "center": [center.x, center.y], + "extent": [extent.x, extent.y], + "min_wl_m": min_wl_m, + } + var result = mp.encode(msg) + if result.status != null: + push_error("StepCanvasProtocol: encode_step_canvas_request failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## Shared status-enum decode — bare string ("Ready"/"Pending"/"NotFound") or +## a single-key map ({"Error": "message"}) for the one data variant. Same +## shape browse_protocol.gd/atlas_map_protocol.gd already implement for their +## own status enums; duplicated here rather than shared via a Callable to +## keep this file genuinely standalone (browse_protocol.gd's own stated +## rationale for its copy). +static func _decode_status_field(status_raw: Variant) -> Dictionary: + if status_raw is String: + return {"status": status_raw, "error": ""} + if status_raw is Dictionary and status_raw.has("Error"): + return {"status": "Error", "error": str(status_raw["Error"])} + return {"status": "", "error": ""} + + +## 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). +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) + + +## Build an EncodedStepCanvas Dictionary (GDScript-side shape) from an +## already-decoded raw value. Returns null if `raw` isn't a plausible canvas +## (missing width/height). PNG fields are repacked to PackedByteArray here +## (once, at decode time) — every downstream consumer (the terrain layer) +## works with real PackedByteArray/Image objects, never re-touches the raw +## msgpack Array shape. +static func _decode_encoded_canvas(raw: Variant) -> Variant: + if not raw is Dictionary: + return null + var d: Dictionary = raw + if not d.has("width") or not d.has("height"): + return null + var temp_dc_raw: Variant = d.get("temp_dc") + var settlement_id_raw: Variant = d.get("settlement_id") + return { + "width": int(d.get("width", 0)), + "height": int(d.get("height", 0)), + "morphology": decode_png_field(d.get("morphology")), + "elev_q": decode_png_field(d.get("elev_q")), + "temp_dc": (temp_dc_raw as Dictionary).get("values", []) if temp_dc_raw is Dictionary else [], + "moisture_q": decode_png_field(d.get("moisture_q")), + "vegetation": decode_png_field(d.get("vegetation")), + "settlement_id": + ( + (settlement_id_raw as Dictionary).get("values", []) + if settlement_id_raw is Dictionary + else [] + ), + "glaciation": decode_png_field(d.get("glaciation")), + "flooded_q": decode_png_field(d.get("flooded_q")), + "courses": d.get("courses", []), + "cliffs": d.get("cliffs", []), + } + + +## Build a StepCanvasResponse Dictionary from an already-decoded raw value. +## Returns null unless it carries "rung" AND "status" — "rung" is the field +## no other decoded response on this wire has (atlas/citynames/browse all +## lack it), making it a safe, unambiguous discriminator for +## Protocol.decode_inbound()'s dispatch. +static func step_canvas_response_from_raw(raw: Variant) -> Variant: + if not raw is Dictionary or not raw.has("rung") or not raw.has("status"): + return null + var d: Dictionary = raw + var decoded_status := _decode_status_field(d.get("status")) + var center_raw: Variant = d.get("center", [0, 0]) + var extent_raw: Variant = d.get("extent", [0, 0]) + return { + "body_id": str(d.get("body_id", "")), + "rung": str(d.get("rung", "")), + "center": _vec_from_pair(center_raw), + "extent": _vec_from_pair(extent_raw), + "min_wl_m": int(d.get("min_wl_m", 0)), + "status": decoded_status["status"], + "error": decoded_status["error"], + "canvas": _decode_encoded_canvas(d.get("canvas")), + } + + +static func _vec_from_pair(pair: Variant) -> Vector2i: + if pair is Array and pair.size() >= 2: + return Vector2i(int(pair[0]), int(pair[1])) + return Vector2i.ZERO diff --git a/client/tests/test_atlas_cold_start.gd b/client/tests/test_atlas_cold_start.gd deleted file mode 100644 index 0275f837b..000000000 --- a/client/tests/test_atlas_cold_start.gd +++ /dev/null @@ -1,311 +0,0 @@ -## PR #192 cold-start dossier — coordinator's live repro against a freshly- -## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking -## seconds): BUG 1 (tile-mosaic paint never resolving), the legend-stacking -## half of BUG 2, and BUG 3 (the "DERIVING TERRAIN…" pending-state label, -## round 2). Split out of test_atlas_zoom_ladder.gd purely for file-length -## reasons (gdlint max-file-lines) — same instantiation/mock-response -## conventions as that file, not a different testing philosophy. -## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately -## in test_regional_screen.gd (a different layer — nav, not the viewer). -class_name TestAtlasColdStart -extends GdUnitTestSuite - -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors -## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the -## real wire value matters, not a convenient placeholder). -const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 - - -## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors -## test_atlas_zoom_ladder.gd's own _mock_window(). -static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -# ============================================================================= -# BUG 1 — tile-mosaic paint self-heal. -# ============================================================================= - - -## Counts real _draw() invocations — CanvasItem exposes no public -## "is a redraw pending" query in this Godot version, so the only reliable -## signal that queue_redraw() actually had an effect is the engine calling -## _draw() again on a subsequent frame. Subclasses the REAL AtlasWindowOverlay -## (not a duck-typed stub) so drawing still runs through the genuine -## production code path — this spy only adds counting, nothing else. -class _CountingOverlay extends AtlasWindowOverlay: - var draw_count := 0 - - func _draw() -> void: - draw_count += 1 - super._draw() - - -## On a cold server, a tile's window_ready can land well after entry's own -## paint window, and a live repro showed the mosaic staying black even with -## every tile held/textured — only resolving on an unrelated gesture. -## _process() must therefore queue a redraw on BOTH the viewer and the -## overlay every frame while any tile is still pending, regardless of -## whether the tile-arrival signal path painted correctly on its own. Proven -## here by swapping the REAL overlay for a _draw()-counting subclass right -## after entry (once the entry-time redraw has already resolved via a real -## frame), then calling _process() directly with NO input/gesture and -## confirming a further frame actually invokes _draw() again — revert- -## verified against a version of _process() with the self-heal removed -## (fails without it, since nothing else re-queues while idle). -func test_process_self_heals_the_overlay_redraw_while_tiles_are_pending() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - assert_bool(v.get_tile_set().has_pending_tiles()).override_failure_message( - "sanity: entry must leave every tile pending before any response arrives" - ).is_true() - - # Swap in the counting spy AFTER entry (so entry's own queue_redraw() - # calls don't pollute the baseline) but the OLD overlay is freed and the - # spy re-added under the same _canvas parent, matching _ready()'s own - # construction shape exactly. - var spy := _CountingOverlay.new() - spy.viewer = v - v._overlay_node.queue_free() - v._overlay_node = spy - v._canvas.add_child(spy) - - await get_tree().process_frame # let this frame settle with the spy in place - await get_tree().process_frame - var baseline: int = spy.draw_count - assert_int(baseline).override_failure_message( - "sanity: the spy must have been drawn at least once before the no-input" - + " frame below, or this test can't distinguish self-heal from a first draw" - ).is_greater(0) - - # No pan/zoom/gesture — the ONLY thing that should cause another _draw() - # is _process()'s own self-heal, since has_pending_tiles() is still true - # (no response has been delivered). - v._process(0.016) - await get_tree().process_frame - - assert_int(spy.draw_count).override_failure_message( - "_process() must queue a redraw every frame while has_pending_tiles()" - + " is true, with NO input/gesture — draw_count must have advanced past" - + " the baseline (%d), the cold-start self-heal" % baseline - ).is_greater(baseline) - - -## The self-heal must STOP once every tile has arrived — a redraw queued -## forever regardless of state would just be a disguised always-redraw, not -## a targeted fix for the pending window. -func test_process_stops_self_healing_once_every_tile_has_arrived() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - var tile_set = v.get_tile_set() - for tile: Dictionary in tile_set.get_tiles(): - var window: Dictionary = _mock_window(tile["center"]) - window["granularity_v2"] = "Region" - window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL - window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_bool(tile_set.has_pending_tiles()).override_failure_message( - "sanity: every tile must have arrived after this loop" - ).is_false() - - -# ============================================================================= -# BUG 2 — legend stacking (the viewer-level half; see test_regional_screen.gd -# for the nav-layer re-entry guard). -# ============================================================================= - - -## Coordinator's live scene dump: WindowLegend measured 260x2343 px, ~10 -## legends stacked — ImplantPanel.clear() used deferred queue_free(), so -## same-frame repeat refresh() calls piled new content onto STALE not-yet- -## freed children instead of replacing them. N refresh() calls in the SAME -## frame (no process_frame between them, matching how the actual trigger — -## RegionalScreen.enter() previously lacking its own re-entry guard — landed -## repeat enter_orbital() calls back to back) must leave exactly ONE legend's -## worth of children, not N stacked copies. -func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - var baseline_count: int = v._legend_panel.get_implant_children().size() - for _i in range(10): - v._legend_panel.refresh() - var after_count: int = v._legend_panel.get_implant_children().size() - - assert_int(after_count).override_failure_message( - ( - "10 same-frame refresh() calls must leave exactly ONE legend's worth of" - + " children (%d), not %d stacked copies — ImplantPanel.clear() must" - + " free immediately, not defer via queue_free()" - ) % [baseline_count, after_count] - ).is_equal(baseline_count) - - -## PR #192 cold-start round 2: the children-count fix above is necessary but -## not sufficient — the coordinator's live scene dump showed the CONTAINER -## itself measured 260x2343px even after that fix landed. Root cause turned -## out to be BROADER than "only after stacking": this panel is manually -## positioned under AtlasWindowViewer (not inside a parent Container), so -## `size` NEVER tracks a shrinking `get_minimum_size()` on its own at -## all — confirmed directly (instrumented and reverted) that even a -## completely FRESH, never-refreshed-twice legend shows `size` frozen at -## whatever it happened to be on its very first measurement, while -## get_minimum_size() reports the correct value the whole time. The -## regression here therefore compares `size.y` against the RELIABLE ground -## truth (`get_minimum_size().y`, confirmed correct in every trace) rather -## than an earlier `size.y` snapshot — comparing size-to-size would pass -## trivially if BOTH numbers were equally stuck at the same stale value, -## which is exactly what silently happened during earlier drafts of this -## test. Reproduces the stacking shape (N same-frame refresh() calls) for -## realism, matching the coordinator's own trigger — the coordinator's -## acceptance bar: "after N refreshes the panel rect height must be within -## one legend's height" (of the CORRECT single-legend height, i.e. the -## settled minimum size). -func test_legend_panel_shrinks_back_after_a_stacking_window() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - # Reproduce the stacking window directly (same-frame repeat refresh() - # calls, matching the pre-fix trigger shape) — this drives the panel's - # minimum size up the same way N repeat enter_orbital() calls did live. - for _i in range(10): - v._legend_panel.refresh() - await get_tree().process_frame - await get_tree().process_frame - - # A further, ordinary refresh() (the kind every real rung change already - # triggers) must leave the panel within one legend's height. - v._legend_panel.refresh() - # reset_to_content_size() is deferred (see its own doc — get_minimum_size() - # is momentarily wrong for RichTextLabel.fit_content children until the - # panel's real width has been laid out once) — a real frame must elapse - # for the deferred reset_size() call to actually run. - await get_tree().process_frame - await get_tree().process_frame - - var one_legend_height: float = v._legend_panel.get_minimum_size().y - assert_float(one_legend_height).override_failure_message( - "sanity: a single settled legend must have a real, non-zero measured minimum height" - ).is_greater(0.0) - assert_float(v._legend_panel.size.y).override_failure_message( - ( - "after a stacking window, the legend panel's rect height (%.1f) must" - + " shrink back to within one legend's height (%.1f, the panel's own" - + " correctly-settled get_minimum_size()) — reset_to_content_size()" - + " must actually collapse the Control back down, not just hold onto" - + " its previously-grown size" - ) % [v._legend_panel.size.y, one_legend_height] - ).is_less_equal(one_legend_height + 1.0) # +1.0: float rounding slack - - -# ============================================================================= -# BUG 3 (round 2) — "DERIVING TERRAIN…" label: the subtle per-tile -# COLOR_BORDER_FADE wash alone was invisible in a live cold capture. The -# viewer draws an unmistakable centered label while ZERO tiles have arrived, -# dropping it the instant even one lands. -# ============================================================================= - - -## The viewer must show the label exactly while is_tile_mode() is true AND -## has_any_tile_arrived() is false — the coordinator's "ZERO tiles have -## arrived" trigger condition, pinned directly against real tile-set state -## (not a mock) via a real enter_orbital() on a tiling body. Also exercises -## _draw()'s ACTUAL dispatch to _draw_deriving_terrain_label() through a -## real frame (queue_redraw() + await process_frame, matching the -## _CountingOverlay spy pattern from the BUG 1 self-heal tests above) — -## proving the draw call itself is reachable and doesn't error, not just -## that the underlying predicate is correct. -func test_deriving_terrain_label_condition_true_before_any_tile_arrives() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - assert_bool(v._tile_set.has_any_tile_arrived()).override_failure_message( - "sanity: entry must leave every tile unarrived before any response arrives" - ).is_false() - v.queue_redraw() - await get_tree().process_frame - - -## The instant even ONE tile lands, the label's own gate condition must flip -## off — per-tile washes alone are the right treatment once real content is -## visibly filling in (coordinator: "dropping to per-tile washes once the -## first tile lands"). -func test_deriving_terrain_label_condition_false_after_one_tile_arrives() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - var tile_set = v.get_tile_set() - var first_tile: Dictionary = tile_set.get_tiles()[0] - var window: Dictionary = _mock_window(first_tile["center"]) - window["granularity_v2"] = "Region" - window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL - window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - assert_bool(tile_set.has_any_tile_arrived()).override_failure_message( - "the label's own gate condition (NOT has_any_tile_arrived()) must flip" - + " false the instant a single tile lands, dropping the label" - ).is_true() - - -## Single-window mode (District/Quarter/small-body Region, not tile mode) -## never shows this label at all — it's a mosaic-specific cue for the -## "whole orbital rest state is still deriving" case, not every wait state -## (the single-window path already has its own COLOR_BORDER_FADE treatment, -## unchanged by this round). -func test_deriving_terrain_label_never_applies_outside_tile_mode() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: a District-rung enter() must never be tile mode" - ).is_false() - - -## centered_label_baseline() pure geometry: the X component is the -## VIEWPORT-CENTERED text block's left-anchor-adjusted X (viewport center -## minus half the text width — draw_string() itself does the final -## horizontal centering from there via HORIZONTAL_ALIGNMENT_CENTER, this -## only sets up where that alignment measures from); the Y component sits -## at viewport-center (a draw_string() baseline is the text's OWN vertical -## center here, by construction: center.y - text.y/2 + text.y/2 == center.y). -func test_centered_label_baseline_centers_a_symmetric_case() -> void: - var viewport_size := Vector2(1000.0, 800.0) - var text_size := Vector2(200.0, 40.0) - var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(viewport_size, text_size) - assert_that(baseline).is_equal(Vector2(400.0, 400.0)) - - -## A zero-size viewport (never laid out yet) must not crash — degenerate -## input, not a real scenario, but the function must stay total. -func test_centered_label_baseline_zero_viewport_does_not_crash() -> void: - var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline( - Vector2.ZERO, Vector2(100.0, 20.0) - ) - assert_that(baseline).is_equal(Vector2(-50.0, 0.0)) diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd index 5dbb4c2a1..d67139c5e 100644 --- a/client/tests/test_atlas_descend_entry.gd +++ b/client/tests/test_atlas_descend_entry.gd @@ -371,19 +371,19 @@ func test_no_descend_signal_without_a_loaded_heightmap() -> void: assert_int(received.size()).is_equal(0) -## T-1153 (D-226 T-1143-rulings amendment): RegionalScreen no longer wraps +## T-1182 (D-255 stepped Atlas ladder): RegionalScreen no longer wraps ## AtlasViewer or forwards district_descend_requested — the "regional" nav -## entry now opens the continuous zoom ladder (AtlasWindowViewer) directly at -## the canonical orbital frame, retiring the click-through as the sole entry -## (see regional_screen.gd's own doc). This regression-guards the NEW -## wiring: entering "regional" reaches AtlasWindowViewer, not AtlasViewer. -func test_regional_screen_wraps_atlas_window_viewer_not_atlas_viewer() -> void: +## 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 AtlasWindowViewer (the zoom ladder) since T-1153," + "RegionalScreen must wrap StepCanvasViewer (the stepped ladder) since T-1182," + " not the retired AtlasViewer heightmap-texture display" - ).is_instanceof(AtlasWindowViewer) + ).is_instanceof(StepCanvasViewer) # ============================================================================= diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd deleted file mode 100644 index b1e6b812a..000000000 --- a/client/tests/test_atlas_window_geometry.gd +++ /dev/null @@ -1,979 +0,0 @@ -## T-1142 (Jeroen's second/third hands-on findings): pure-function tests for -## AtlasWindowViewer's fit-and-center math (fit_window_view) and pole-wall -## pan clamp (clamp_pan_offset_to_pole_wall) — both extracted specifically so -## the "viewport + n -> zoom/offset" transform is unit-testable without a -## live Control tree. -class_name TestAtlasWindowGeometry -extends GdUnitTestSuite - -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") -const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") - -const MIN_ZOOM: float = 0.5 -const MAX_ZOOM: float = 8.0 -const CELL_PIXEL_SIZE: float = 16.0 - - -# ============================================================================= -# fit_window_view — the "postage stamp" fix (item 2) -# ============================================================================= - - -## n=32, cell_px=16 -> native composite is 512x512. T-1145 item 1: COVER -## fit derives zoom from the LARGER viewport dimension (1920, not 1080) with -## NO margin factor — zoom = 1920 / 512 = 3.75 — well inside [MIN_ZOOM, -## MAX_ZOOM], so the clamp is a no-op here. -func test_fit_window_view_computes_expected_zoom_for_a_wide_viewport() -> void: - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - var expected_zoom: float = 1920.0 / 512.0 - assert_float(fit["zoom"]).is_equal_approx(expected_zoom, 0.001) - - -## The composite must be CENTERED — offset.x/.y each leave an equal margin on -## both sides of the (n*cell_px*zoom)-sized composite (a NEGATIVE "margin" is -## fine and expected under cover — it just means the composite overhangs -## that axis, checked separately by test_fit_window_view_covers_with_no_gap). -func test_fit_window_view_centers_the_composite() -> void: - var viewport := Vector2(1920.0, 1080.0) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - var composite_scaled: float = 32.0 * CELL_PIXEL_SIZE * float(fit["zoom"]) - var offset: Vector2 = fit["offset"] - # The composite's right/bottom edge is offset + composite_scaled — the - # margin on the far side must equal the margin on the near side (offset). - var right_margin: float = viewport.x - (offset.x + composite_scaled) - var bottom_margin: float = viewport.y - (offset.y + composite_scaled) - assert_float(right_margin).is_equal_approx(offset.x, 0.01) - assert_float(bottom_margin).is_equal_approx(offset.y, 0.01) - - -## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): a wide viewport -## must show NO side margins — the composite's LONG axis (the one the cover -## zoom is derived from) must land EXACTLY at the viewport edges (offset ~= -## 0 on that axis), and the SHORT axis must OVERHANG past both edges -## (negative margin — the composite is bigger than the viewport there, -## exactly what "cover" means). This is the literal assertion the coordinator -## asked for: no side margins at 16:9. -func test_fit_window_view_covers_with_no_gap_on_the_long_axis() -> void: - var viewport := Vector2(1920.0, 1080.0) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - var composite_scaled: float = 32.0 * CELL_PIXEL_SIZE * float(fit["zoom"]) - var offset: Vector2 = fit["offset"] - # Long axis (X, 1920 > 1080): the composite must span EXACTLY the - # viewport width — zero margin on both sides. - assert_float(offset.x).override_failure_message( - "the long (cover) axis must have NO side margin — offset.x should be ~0" - ).is_equal_approx(0.0, 0.5) - var right_margin: float = viewport.x - (offset.x + composite_scaled) - assert_float(right_margin).override_failure_message( - "the long (cover) axis's far edge must have NO margin either" - ).is_equal_approx(0.0, 0.5) - # Short axis (Y, 1080 < 1920): the composite must OVERHANG (negative - # margin) past BOTH edges — this is the data that extends into pan-space. - assert_float(offset.y).override_failure_message( - "the short axis must OVERHANG past the top edge (negative offset)" - ).is_less(0.0) - - -## A TALL viewport (portrait) must cover the same way, just with the axes -## swapped — long axis (Y) gets zero margin, short axis (X) overhangs. -func test_fit_window_view_covers_a_tall_viewport_too() -> void: - var viewport := Vector2(1080.0, 1920.0) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - viewport, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - var offset: Vector2 = fit["offset"] - assert_float(offset.y).override_failure_message( - "the long (cover) axis (Y, portrait) must have NO side margin" - ).is_equal_approx(0.0, 0.5) - assert_float(offset.x).override_failure_message( - "the short axis (X, portrait) must overhang past the left edge" - ).is_less(0.0) - - -## A perfectly square viewport needs NO overhang on either axis — cover and -## contain agree exactly at a 1:1 aspect ratio (the degenerate case where -## "long" and "short" axis are the same). -func test_fit_window_view_square_viewport_has_no_overhang_either_axis() -> void: - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(1024.0, 1024.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_vector(fit["offset"]).is_equal_approx(Vector2.ZERO, Vector2(0.5, 0.5)) - - -## Jeroen's exact bug: an n=32 composite (512px native) in a real ~1920px -## viewport must NOT render at zoom=1.0 (the old, unfitted "postage stamp" -## behavior) — the fit must scale it up to fill (now: COVER) the viewport. -func test_fit_window_view_scales_up_a_small_composite_to_fill_the_viewport() -> void: - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(1920.0, 1080.0), 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_float(fit["zoom"]).override_failure_message( - "a 512px composite in a 1920x1080 viewport must be scaled UP, not left at 1.0" - ).is_greater(1.0) - - -## A huge n (e.g. n=64 at a tiny viewport) must clamp to MIN_ZOOM, never -## shrink the composite into illegibility below the floor. -func test_fit_window_view_clamps_to_min_zoom_for_a_tiny_viewport() -> void: - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(200.0, 150.0), 64, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_float(fit["zoom"]).is_equal_approx(MIN_ZOOM, 0.001) - - -## A small n (e.g. n=2) at a huge viewport must clamp to MAX_ZOOM, never -## scale past the ceiling. -func test_fit_window_view_clamps_to_max_zoom_for_a_tiny_composite() -> void: - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(3840.0, 2160.0), 2, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_float(fit["zoom"]).is_equal_approx(MAX_ZOOM, 0.001) - - -## Degenerate inputs (zero viewport, zero n) must not divide by zero — a safe -## fallback (zoom=1.0, offset=ZERO), never a crash or NaN. -func test_fit_window_view_degenerate_inputs_are_safe() -> void: - var fit_zero_viewport: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2.ZERO, 32, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_float(fit_zero_viewport["zoom"]).is_equal_approx(1.0, 0.001) - var fit_zero_n: Dictionary = AtlasWindowGeometry.fit_window_view( - Vector2(1920.0, 1080.0), 0, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - assert_float(fit_zero_n["zoom"]).is_equal_approx(1.0, 0.001) - - -# ============================================================================= -# clamp_pan_offset_to_pole_wall — item 5 (pole hard wall, row axis only) -# ============================================================================= - - -## Deep inside the valid range (window nowhere near a pole), the clamp must -## be a no-op — offset passes through unchanged. -func test_pole_wall_clamp_is_a_noop_far_from_the_poles() -> void: - var offset := Vector2(10.0, 20.0) - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - offset, Vector2(1920.0, 1080.0), Vector2i(0, 0), 32, 4785, CELL_PIXEL_SIZE, 1.0 - ) - assert_that(clamped).is_equal(offset) - - -## X is NEVER clamped by the pole wall (item 6: east-west is seamless) — even -## an absurdly large X offset passes through untouched. -func test_pole_wall_clamp_never_touches_x() -> void: - var offset := Vector2(999999.0, 0.0) - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - offset, Vector2(1920.0, 1080.0), Vector2i(0, 0), 32, 4785, CELL_PIXEL_SIZE, 1.0 - ) - assert_float(clamped.x).is_equal_approx(999999.0, 0.001) - - -## The core pole-wall behavior: dragging FAR past the north pole (offset.y -## driven to an extreme) must clamp — the resulting offset must be LESS than -## the extreme requested, and a SECOND, even-more-extreme drag must produce -## the SAME clamped value (further dragging is inert once pinned at the wall). -func test_pole_wall_clamp_pins_offset_when_dragged_past_the_pole() -> void: - var rows_half := 100 - var held_center := Vector2i(0, 90) # near the south pole already (row 90 of 100) - var extreme_offset := Vector2(0.0, 5000.0) # a huge downward drag - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - extreme_offset, Vector2(800.0, 800.0), held_center, 32, rows_half, CELL_PIXEL_SIZE, 1.0 - ) - assert_float(clamped.y).override_failure_message( - "an extreme drag toward the pole must be clamped, not pass through" - ).is_less(extreme_offset.y) - - var even_more_extreme := Vector2(0.0, 50000.0) - var clamped_again: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - even_more_extreme, Vector2(800.0, 800.0), held_center, 32, rows_half, CELL_PIXEL_SIZE, 1.0 - ) - assert_float(clamped_again.y).override_failure_message( - "further dragging past an already-pinned wall must be inert (same clamped value)" - ).is_equal_approx(clamped.y, 0.01) - - -## Symmetric check on the north side: a huge UPWARD drag near the north pole -## also clamps. -func test_pole_wall_clamp_pins_offset_on_the_north_side_too() -> void: - var rows_half := 100 - var held_center := Vector2i(0, -90) # near the north pole - var extreme_offset := Vector2(0.0, -5000.0) # a huge upward drag - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - extreme_offset, Vector2(800.0, 800.0), held_center, 32, rows_half, CELL_PIXEL_SIZE, 1.0 - ) - assert_float(clamped.y).override_failure_message( - "an extreme drag toward the north pole must be clamped" - ).is_greater(extreme_offset.y) - - -## rows_half <= 0 (a no-radius body, or a degenerate district_extent()) means -## "no wall concept" — the clamp is a no-op, matching -## canonicalize_district_center()'s own no-radius identity disposition. -func test_pole_wall_clamp_is_noop_when_rows_half_is_zero() -> void: - var offset := Vector2(0.0, 999999.0) - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - offset, Vector2(800.0, 800.0), Vector2i(0, 0), 32, 0, CELL_PIXEL_SIZE, 1.0 - ) - assert_that(clamped).is_equal(offset) - - -## Tiny-body edge case (documented open item in atlas_window_viewer.gd's own -## _clamp_offset_to_pole_wall doc): a window TALLER than the whole planet's -## row span (n=64 window, rows_half=10 -> pole-to-pole is only 20 districts) -## must not crash or produce an inverted/degenerate clamp range — the offset -## still comes back as a finite Vector2, and repeated extreme drags still -## converge to a stable pinned value (not NaN, not unbounded). -func test_pole_wall_clamp_handles_a_window_taller_than_the_planet() -> void: - var rows_half := 10 - var held_n := 64 - var held_center := Vector2i(0, 0) - var clamped: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - Vector2(0.0, 999999.0), Vector2(800.0, 800.0), held_center, held_n, rows_half, - CELL_PIXEL_SIZE, 1.0 - ) - assert_bool(is_finite(clamped.y)).override_failure_message( - "a window taller than the planet's row span must still produce a finite clamp" - ).is_true() - var clamped_again: Vector2 = AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - Vector2(0.0, 9999999.0), Vector2(800.0, 800.0), held_center, held_n, rows_half, - CELL_PIXEL_SIZE, 1.0 - ) - assert_float(clamped_again.y).is_equal_approx(clamped.y, 0.01) - - -# ============================================================================= -# Cross-check: clamp bounds derived from district_extent() (the SAME source -# canonicalize_district_center() uses) — confirms the two T-1142 fixes (item -# 5 pole wall, item 6a wrap/clamp) agree on what "the pole" even is. -# ============================================================================= - - -func test_pole_wall_rows_half_matches_canonicalize_rows_half() -> void: - var radius_km := 6238.4 # GJ380c - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var rows_half: int = int(extent["rows_half"]) - # A center exactly at (0, rows_half) must canonicalize to itself (already - # at the pole boundary, not past it) — pins that the SAME rows_half both - # fixes consume describes an inclusive boundary, not an exclusive one. - var canonical: Vector2i = AtlasDescendGeometry.canonicalize_district_center( - Vector2i(0, rows_half), radius_km - ) - assert_int(canonical.y).is_equal(rows_half) - - -# ============================================================================= -# T-1153: select_rung() — REDESIGNED (live round 3 finding) per-rung -# single-window COVERAGE CEILING model, superseding the original -# `2x`-visual-tolerance-only reading of design doc §5. Select the FINEST -# rung whose own single-window coverage ceiling (MAX_COVERAGE_M) still -# covers the current world extent: Quarter <= 32,768 m; District <= -# 131,072 m; Region otherwise (including tiled coverage beyond its own -# single-window ceiling, a viewer-level concern — see select_rung()'s own -# doc for the full derivation and why this REPLACES the earlier two-gate -# design entirely, not just patches it). -# ============================================================================= - - -## Deep zoom-in (a tiny extent) selects Quarter — comfortably under its own -## 32,768 m ceiling. -func test_select_rung_picks_quarter_well_under_its_ceiling() -> void: - var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) - assert_str(rung).is_equal("Quarter") - - -## An extent past Quarter's own ceiling but under District's selects -## District — the finest rung that can still cover it in one window. -func test_select_rung_picks_district_between_the_two_ceilings() -> void: - # 60,000 m is past Quarter's 32,768 m ceiling but well under District's - # 131,072 m one. - var rung: String = AtlasWindowGeometry.select_rung(60_000.0, 100.0) - assert_str(rung).is_equal("District") - - -## An extent past BOTH Quarter's and District's ceilings selects Region — -## neither finer rung's single window can cover this much world. -func test_select_rung_picks_region_past_both_finer_ceilings() -> void: - var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0) - assert_str(rung).is_equal("Region") - - -## Exactly AT Quarter's own ceiling (32,768 m) must still select Quarter — -## the rule is `<=`, not `<`. -func test_select_rung_quarter_ceiling_boundary_is_inclusive() -> void: - var rung: String = AtlasWindowGeometry.select_rung(32_768.0, 100.0) - assert_str(rung).is_equal("Quarter") - - -## One metre past Quarter's ceiling must flip to District — confirms the -## ceiling bites right at its own boundary, not one cell short of it. -func test_select_rung_one_past_quarter_ceiling_is_district() -> void: - var rung: String = AtlasWindowGeometry.select_rung(32_769.0, 100.0) - assert_str(rung).is_equal("District") - - -## Exactly AT District's own ceiling (131,072 m) must still select District. -func test_select_rung_district_ceiling_boundary_is_inclusive() -> void: - var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0) - assert_str(rung).is_equal("District") - - -## One metre past District's ceiling must flip to Region. -func test_select_rung_one_past_district_ceiling_is_region() -> void: - var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0) - assert_str(rung).is_equal("Region") - - -## canvas_px is unused by the coverage rule (kept for signature stability, -## see select_rung()'s own doc) — degenerate/zero values must not change the -## selected rung at all, unlike the old `2x`-tolerance design's special-cased -## fallback. -func test_select_rung_canvas_px_does_not_affect_selection() -> void: - var with_real_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) - var with_zero_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 0.0) - assert_str(with_zero_canvas).is_equal(with_real_canvas) - - -## spacing_for_rung() is select_rung()'s inverse lookup — pin the three known -## values against the D-243 constants directly (not against RUNG_TABLE -## indices, which would just restate the implementation). -func test_spacing_for_rung_matches_d243_constants() -> void: - assert_float(AtlasWindowGeometry.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.001) - assert_float(AtlasWindowGeometry.spacing_for_rung("District")).is_equal_approx(2048.0, 0.001) - assert_float(AtlasWindowGeometry.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.001) - - -## An unknown tag falls back to District — matching the server's own -## "unknown -> District" posture at every wire-decode boundary. -func test_spacing_for_rung_unknown_tag_falls_back_to_district() -> void: - assert_float(AtlasWindowGeometry.spacing_for_rung("Nonsense")).is_equal_approx(2048.0, 0.001) - - -## MAX_COVERAGE_M's three values, pinned directly against the formulas -## select_rung()'s own doc derives them from — a regression guard -## independent of select_rung()'s own boundary tests above, so a future -## accidental edit to the constants table itself (not just the selection -## logic) is caught here too. -func test_max_coverage_m_matches_derived_formulas() -> void: - assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"]).is_equal_approx(32_768.0, 0.001) - assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["District"]).is_equal_approx(131_072.0, 0.001) - assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Region"]).is_equal_approx(13_107_200.0, 0.001) - - -## The exact scenario that surfaced the original design flaw -## (live-testing enter_orbital()'s own fit zoom): a whole Earth-like body's -## circumference (~40,075 km, matching AtlasDescendGeometry.district_extent()'s -## own cols*DISTRICT_M for radius=6371km) fitted to a 1920px-wide viewport at -## CELL_PIXEL_SIZE=16 must select Region — the direct regression guard for -## the bug an early version of select_rung() had (picking District here, -## which would have meant the canonical orbital frame requests a -## District-tier derive spanning an entire planet — the exact R1-catastrophe -## cost scenario the design doc §4 rejects). -func test_select_rung_at_orbital_fit_zoom_selects_region() -> void: - var radius_km := 6371.0 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var n: int = int(extent["cols"]) - var composite_native: float = float(n) * CELL_PIXEL_SIZE - var viewport := Vector2(1920.0, 1080.0) - var fit_zoom: float = maxf(viewport.x, viewport.y) / composite_native - var world_extent: float = AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, fit_zoom, viewport) - var rung: String = AtlasWindowGeometry.select_rung( - world_extent, maxf(viewport.x, viewport.y) - ) - assert_str(rung).override_failure_message( - "the canonical orbital fit-zoom (whole-planet view) must select Region," - + " never a District-tier derive spanning an entire planet" - ).is_equal("Region") - - -## **Live round 3 regression, the direct fix target:** at 1600x900 (the -## coordinator's capture viewport), zooming IN from the orbital fit all the -## way to Quarter's own ceiling must pass through District along the way — -## a wheel-zoom gesture crossing world_extent_m from Region's territory down -## to Quarter's must select District for SOME real span of extent in -## between, not skip straight from Region to Quarter (the exact "money shot" -## the coordinator wants capture-worthy: a visible SHARPEN in place, not a -## jump). -func test_select_rung_district_is_reachable_between_region_and_quarter() -> void: - # An extent comfortably between District's and Quarter's ceilings (e.g. - # the midpoint) must select District — proving the band is non-empty, - # unlike the old two-gate design where it was empty by construction at - # every real viewport (see git history / the coordinator's live-round - # finding for the retired analysis). - var midpoint: float = ( - (AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"] as float) - + (AtlasWindowGeometry.MAX_COVERAGE_M["District"] as float) - ) * 0.5 - var rung: String = AtlasWindowGeometry.select_rung(midpoint, 1600.0) - assert_str(rung).override_failure_message( - "District must be reachable between Quarter's and District's own" - + " coverage ceilings — the redesigned rule must not skip it" - ).is_equal("District") - - -# ============================================================================= -# T-1153: world_extent_m() — the `E` half of the §5 rule, computed from the -# viewer's own zoom/viewport state. -# ============================================================================= - - -## At zoom=1.0, CELL_PIXEL_SIZE=16: one DISTRICT (2,048 m, the fixed display -## unit — see world_extent_m()'s own doc for why this is rung-INDEPENDENT) -## occupies 16 screen px, so a 1920px-wide viewport shows -## 1920/16 * 2048 = 245,760 m. -func test_world_extent_m_at_zoom_one() -> void: - var extent: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) - ) - assert_float(extent).is_equal_approx(1920.0 / CELL_PIXEL_SIZE * 2048.0, 1.0) - - -## Doubling the zoom must HALVE the displayed world extent — zooming in -## shows less world, not more. -func test_world_extent_m_halves_when_zoom_doubles() -> void: - var extent_1x: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) - ) - var extent_2x: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 2.0, Vector2(1920.0, 1080.0) - ) - assert_float(extent_2x).is_equal_approx(extent_1x * 0.5, 1.0) - - -## The composite's on-screen footprint is rung-invariant (world_extent_m()'s -## own doc) — a change in held rung with NO change in zoom/viewport must -## leave the displayed world extent UNCHANGED. This is the direct regression -## test for the bug this function's signature once had (a granularity_v2 -## parameter that silently changed the formula per rung, when only zoom -## should) — the function no longer TAKES a rung parameter at all, so this -## pins that omission is intentional, not an oversight. -func test_world_extent_m_has_no_rung_parameter() -> void: - var extent_a: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) - ) - var extent_b: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0) - ) - assert_float(extent_a).is_equal_approx(extent_b, 0.001) - - -## Degenerate zoom (<=0) must not divide by zero — a safe zero extent. -func test_world_extent_m_degenerate_zoom_is_safe() -> void: - var extent: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, 0.0, Vector2(1920.0, 1080.0) - ) - assert_float(extent).is_equal_approx(0.0, 0.001) - - -# ============================================================================= -# T-1153: is_fully_zoomed_out() — Jeroen's HARD condition's trigger predicate. -# ============================================================================= - - -func test_is_fully_zoomed_out_true_when_extent_covers_full_circumference() -> void: - var radius_km := 6371.0 - var circumference_m: float = TAU * radius_km * 1000.0 - assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(circumference_m, radius_km)).is_true() - assert_bool( - AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 1.5, radius_km) - ).is_true() - - -func test_is_fully_zoomed_out_false_when_extent_is_less_than_circumference() -> void: - var radius_km := 6371.0 - var circumference_m: float = TAU * radius_km * 1000.0 - assert_bool( - AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 0.5, radius_km) - ).is_false() - - -## A no-radius body (tiny test body) has no circumference concept — never -## auto-resets, matching enter_orbital()'s own no-radius fallback disposition. -func test_is_fully_zoomed_out_false_for_no_radius_body() -> void: - assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(1e12, 0.0)).is_false() - - -# ============================================================================= -# T-1153: screen_center_to_district() — the shared screen<->district formula -# behind both the pan-edge refetch and the rung-reselect refetch. -# ============================================================================= - - -## At the exact center of a symmetric fit (offset centers the composite, -## zoom=1.0), the screen center must map back to the held center exactly. -func test_screen_center_to_district_at_rest_returns_held_center() -> void: - var held_n := 32 - var held_center := Vector2i(10, 20) - var composite_native: float = float(held_n) * CELL_PIXEL_SIZE - var viewport := Vector2(composite_native, composite_native) - var offset := Vector2.ZERO # composite exactly fills the viewport, top-left at origin - var result: Vector2i = AtlasWindowGeometry.screen_center_to_district( - viewport, offset, 1.0, CELL_PIXEL_SIZE, held_center, held_n - ) - assert_that(result).is_equal(held_center) - - -## Panning the offset must shift the recovered district position in the -## OPPOSITE direction of the offset shift (dragging the composite right -## reveals districts to the WEST at screen-center). -func test_screen_center_to_district_shifts_with_pan_offset() -> void: - var held_n := 32 - var held_center := Vector2i(0, 0) - var composite_native: float = float(held_n) * CELL_PIXEL_SIZE - var viewport := Vector2(composite_native, composite_native) - var at_rest: Vector2i = AtlasWindowGeometry.screen_center_to_district( - viewport, Vector2.ZERO, 1.0, CELL_PIXEL_SIZE, held_center, held_n - ) - var panned: Vector2i = AtlasWindowGeometry.screen_center_to_district( - viewport, Vector2(CELL_PIXEL_SIZE * 4.0, 0.0), 1.0, CELL_PIXEL_SIZE, held_center, held_n - ) - assert_int(panned.x).override_failure_message( - "dragging the composite EAST (positive offset) must reveal districts to the WEST" - ).is_less(at_rest.x) - - -# ============================================================================= -# T-1153 (moved from atlas_window_viewer.gd for testability): WASD held-pan -# direction is exercised live only (reads the global Input singleton) — -# edge-scroll suppression/direction are pure and covered here directly. -# ============================================================================= - - -func test_is_cursor_edge_scrolling_true_near_an_edge() -> void: - var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( - true, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 - ) - assert_bool(result).is_true() - - -func test_is_cursor_edge_scrolling_false_away_from_any_edge() -> void: - var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( - true, false, Vector2(800.0, 600.0), Vector2(400.0, 300.0), 24.0 - ) - assert_bool(result).is_false() - - -func test_is_cursor_edge_scrolling_suppressed_when_over_ui() -> void: - var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( - true, true, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 - ) - assert_bool(result).is_false() - - -func test_is_cursor_edge_scrolling_suppressed_without_app_focus() -> void: - var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling( - false, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0 - ) - assert_bool(result).is_false() - - -func test_edge_scroll_direction_points_west_near_left_edge() -> void: - var direction: Vector2 = AtlasWindowGeometry.edge_scroll_direction( - Vector2(800.0, 600.0), Vector2(5.0, 300.0), 24.0 - ) - assert_float(direction.x).is_less(0.0) - assert_float(direction.y).is_equal_approx(0.0, 0.001) - - -# ============================================================================= -# T-1153, live round 3 (Jeroen's ruling, design doc §4): compute_tile_grid() -# — the orbital rest state's multi-window mosaic. -# ============================================================================= - - -## The exact live-round scenario: GJ380c/Lendel (radius 6238.4 km) needs a -## 3x2 = 6-tile grid — the coordinator's own estimate, confirmed here as an -## executable regression. -func test_compute_tile_grid_lendel_produces_six_tiles() -> void: - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) - assert_int(tiles.size()).override_failure_message( - "GJ380c/Lendel must tile into 3x2=6 windows, matching the coordinator's own" - + " live-round finding (13,107.2 km single-window coverage vs. 39,198 km" - + " circumference)" - ).is_equal(6) - - -## A tiny body whose whole circumference fits in ONE Region window's -## coverage ceiling must produce exactly ONE tile — tiling degenerates -## gracefully to the pre-existing single-window behavior when it isn't -## actually needed. -func test_compute_tile_grid_tiny_body_produces_one_tile() -> void: - # radius small enough that circumference << MAX_COVERAGE_M["Region"] - # (13,107,200 m) — a few hundred km radius comfortably qualifies. - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(50.0) - assert_int(tiles.size()).is_equal(1) - assert_that(tiles[0]).is_equal(Vector2i.ZERO) - - -## A no-radius body (tiny test body) must produce exactly one tile at the -## canonical origin — matching enter_orbital()'s own no-radius fallback -## disposition (no circumference/tiling concept without a radius). -func test_compute_tile_grid_no_radius_produces_single_origin_tile() -> void: - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(0.0) - assert_int(tiles.size()).is_equal(1) - assert_that(tiles[0]).is_equal(Vector2i.ZERO) - - -## Every tile center must be a LEGAL canonicalized DistrictPos — column -## wrapped into [0, cols), row clamped into [-rows_half, rows_half] — the -## same range canonicalize_district_center() enforces everywhere else in -## this cluster (pan refetch, entry, rung-reselect). A raw, uncanonicalized -## tile center would fail the server's own normalize_window_center() (or -## silently alias to a different tile than intended). -func test_compute_tile_grid_tiles_are_all_canonicalized() -> void: - var radius_km := 6238.4 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var cols: int = int(extent["cols"]) - var rows_half: int = int(extent["rows_half"]) - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) - for tile: Vector2i in tiles: - assert_int(tile.x).override_failure_message( - "tile column %d must be wrapped into [0, %d)" % [tile.x, cols] - ).is_greater_equal(0) - assert_int(tile.x).is_less(cols) - assert_int(tile.y).override_failure_message( - "tile row %d must be clamped into [-%d, %d]" % [tile.y, rows_half, rows_half] - ).is_greater_equal(-rows_half) - assert_int(tile.y).is_less_equal(rows_half) - - -## No two tiles may share the same canonicalized center — compute_tile_grid() -## must dedupe (a pole-row clamp or column-wrap collision producing the exact -## same DistrictPos twice would otherwise request/draw the same tile twice, -## wasting a request and drawing one tile over another). -func test_compute_tile_grid_has_no_duplicate_centers() -> void: - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) - var seen: Dictionary = {} - for tile: Vector2i in tiles: - assert_bool(seen.has(tile)).override_failure_message( - "tile center %s appears more than once in the grid" % str(tile) - ).is_false() - seen[tile] = true - - -## The tile grid's own center of mass must land on the canonical origin -## (0,0) — the tile-set's symmetric layout (each axis' centers computed as -## `(index - (count-1)/2) * TILE_N`) is centered on the SAME canonical origin -## enter_orbital() uses, so the tile-set's overall framing agrees with -## single-window enter_orbital()'s own "center on (0,0)" contract. -func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void: - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) - var sum_col := 0 - var sum_row := 0 - for tile: Vector2i in tiles: - sum_col += tile.x - sum_row += tile.y - # Column centers wrap (periodic), so a raw average isn't meaningful there - # the way it is for rows — assert row symmetry directly instead (rows - # never wrap, so their average must be very close to 0 for a - # symmetric grid). - var avg_row: float = float(sum_row) / float(tiles.size()) - assert_float(avg_row).override_failure_message( - "the tile grid's row centers must average to ~0 (symmetric around the" - + " canonical origin's equator row)" - ).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N)) - - -# ============================================================================= -# Live round 4: district_to_canvas_local() + recompute_offset_for_held_n_change() -# — the two pure functions behind both round-4 draw-path fixes (tile mosaic -# placement, single-window offset recompute across a rung crossing). -# ============================================================================= - - -## A district AT the held window's own center must land at canvas-local -## `(held_n/2 * cell_px, held_n/2 * cell_px)` — the center of the -## `[0, held_n*cell_px)` square the single-window `Rect2(0,0,extent,extent)` -## draw call already assumes. -func test_district_to_canvas_local_center_district_lands_at_half_extent() -> void: - var held_center := Vector2i(100, 200) - var held_n := 64 - var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - Vector2(held_center), held_center, held_n, CELL_PIXEL_SIZE - ) - var expected: float = float(held_n) * 0.5 * CELL_PIXEL_SIZE - assert_that(result).is_equal(Vector2(expected, expected)) - - -## The window's own top-left corner (held_center - held_n/2) must land at -## canvas-local (0,0) — the exact invariant single-window `_draw()` and -## `fit_window_view()` both assume. -func test_district_to_canvas_local_top_left_corner_lands_at_origin() -> void: - var held_center := Vector2i(0, 0) - var held_n := 32 - var top_left := Vector2(held_center) - Vector2.ONE * (float(held_n) * 0.5) - var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - top_left, held_center, held_n, CELL_PIXEL_SIZE - ) - assert_that(result).is_equal(Vector2.ZERO) - - -## Live round 4's OWN repro, pinned directly: a tile far from held_center -## (0,0) at whole-body scale (held_n ~19,139, Lendel's raw circumference) -## must NOT land near canvas-local (0,0) — the round-4 bug's exact failure -## mode (treating absolute district (0,0) as the canvas origin regardless of -## held_center/held_n) would place it there instead. -func test_district_to_canvas_local_matches_the_live_round_4_repro_scale() -> void: - var held_center := Vector2i.ZERO - var held_n := 19139 # Lendel's raw district-column count (live round 4's own repro) - var tile_center := Vector2(6400, 0) # one TILE_N east of the body's own center - var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - tile_center, held_center, held_n, CELL_PIXEL_SIZE - ) - var buggy_result: Vector2 = tile_center * CELL_PIXEL_SIZE # the round-4 bug's own formula - assert_bool(is_equal_approx(result.x, buggy_result.x)).override_failure_message( - "a tile away from held_center must NOT land where the round-4 bug's" - + " absolute-district-(0,0)-relative formula would put it — got %.1f, the" - + " buggy formula's own value is %.1f" - % [result.x, buggy_result.x] - ).is_false() - - -## Zero held_n is a degenerate/never-real-in-practice input (a body always -## has SOME district extent) but must not divide-by-zero or crash — `half` -## is simply 0, so the district maps 1:1 to canvas-local (scaled by cell_px). -func test_district_to_canvas_local_zero_held_n_does_not_crash() -> void: - var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - Vector2(5, 5), Vector2i.ZERO, 0, CELL_PIXEL_SIZE - ) - assert_that(result).is_equal(Vector2(5, 5) * CELL_PIXEL_SIZE) - - -# ============================================================================= -# Live round 5: nearest_wrap_image() — the tile-mosaic WRAP half of "the -# mosaic doesn't fully draw" (the left-third-black repro). -# ============================================================================= - - -## Live round 5's OWN repro, pinned exactly: Lendel's wrapped tile -## canonicalizes to column 12739 (`-6400 mod 19139`) — the CORRECT -## request/cache key — but its nearest wrap-image relative to the canonical -## origin (held_center.x = 0) is -6400, the actual visible position -## immediately west of center. -func test_nearest_wrap_image_matches_the_lendel_repro() -> void: - var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 19139) - assert_int(result).override_failure_message( - "the wrapped tile's nearest wrap-image relative to held_center=0 must be" - + " -6400 (its actual on-screen position), not 12739 (the correct REQUEST" - + " key, but the wrong DRAW position)" - ).is_equal(-6400) - - -## The two Lendel tiles that were NEVER wrapped (already close to -## held_center) must round-trip unchanged — the fix must not perturb tiles -## that were already drawing correctly. -func test_nearest_wrap_image_is_a_noop_for_already_nearby_columns() -> void: - var cols := 19139 - for col: int in [0, 6400]: - var result: int = AtlasWindowGeometry.nearest_wrap_image(col, 0, cols) - assert_int(result).override_failure_message( - "column %d is already the nearest wrap-image to held_center=0 — must" - + " be returned unchanged" % col - ).is_equal(col) - - -## The result must always be a LEGAL wrap-image of the canonical column — -## i.e. `result mod cols == canonical_col mod cols` — regardless of which -## image is nearest. This is the correctness invariant the whole function -## exists to preserve: re-expressing a column for DRAWING must never change -## WHICH district it actually refers to. -func test_nearest_wrap_image_preserves_the_canonical_identity() -> void: - var cols := 19139 - for held_col: int in [-50000, -1, 0, 1, 9569, 19138, 50000]: - var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, held_col, cols) - assert_int(posmod(result, cols)).override_failure_message( - "nearest_wrap_image(12739, %d, %d) = %d must still canonicalize back" - + " to 12739 — it may only pick a DIFFERENT wrap-image, never a" - + " different district" % [held_col, cols, result] - ).is_equal(12739) - - -## The chosen wrap-image must be the CLOSEST one to held_center — never -## farther than half the circumference away (otherwise a different -## wrap-image would have been nearer). -func test_nearest_wrap_image_is_within_half_circumference_of_held_center() -> void: - var cols := 19139 - for canonical_col: int in [0, 1, 9569, 12739, 19138]: - for held_col: int in [-30000, -500, 0, 500, 25000]: - var result: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_col, cols) - var distance: int = absi(result - held_col) - assert_int(distance).override_failure_message( - ( - "nearest_wrap_image(%d, %d, %d) = %d is %d districts from" - + " held_center — must never exceed half the circumference" - + " (%d), or a closer wrap-image exists" - ) - % [canonical_col, held_col, cols, result, distance, cols / 2] - ).is_less_equal(cols / 2) - - -## `cols <= 0` (no-radius bodies, which never tile per compute_tile_grid()'s -## own doc) must be a safe no-op passthrough — no periodicity to resolve. -func test_nearest_wrap_image_zero_cols_is_a_passthrough() -> void: - var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 0) - assert_int(result).is_equal(12739) - - -## The coordinator's own draw-position counterpart to -## test_compute_tile_grid_tiles_are_all_canonicalized(): the wrapped tile's -## DRAW rect (via district_to_canvas_local(), fed through -## nearest_wrap_image() the way _draw_tile_mosaic() now does) must land -## SUBSTANTIALLY on-canvas when the view covers the whole body — the exact -## Lendel shape (whole-body fit at entry, held_center at the canonical -## origin). A bare `Rect2.intersects()` check is NOT discriminating enough -## here: at Lendel's own whole-body-fit scale, the BUGGY placement (feeding -## the canonical column directly) happens to clip the viewport edge by only -## a couple of px (confirmed by hand-computation — the tile-grid's own -## edge-to-edge tiling means a full-circumference shift lands almost -## exactly one screen-width away, so `intersects()` alone would pass on a -## near-miss that still reads as "the left third is black" visually). -## Asserting a MEANINGFUL overlap FRACTION (at least half the tile's own -## area) is what actually distinguishes "correctly drawn" from "barely -## clipping the edge." -func test_wrapped_tile_draw_rect_lands_substantially_on_canvas_at_whole_body_view() -> void: - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var cols: int = int(extent["cols"]) - var held_center := Vector2i.ZERO - var held_n: int = cols # enter_orbital()'s own whole-body held_n - var tile_n: int = AtlasWindowGeometry.TILE_N - var half_tile: float = float(tile_n) * 0.5 - - # The whole-body fit zoom/viewport (matching enter_orbital()'s own fit). - var viewport := Vector2(1600.0, 900.0) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - viewport, held_n, CELL_PIXEL_SIZE, 0.0001, 64.0 - ) - var view_zoom: float = fit["zoom"] - var view_offset: Vector2 = fit["offset"] - - # The wrapped tile's own canonical center — mirrors compute_tile_grid()'s - # own dedup/canonicalize step for Lendel's westmost tile. - var wrapped_raw_col := -6400 - var canonical_col: int = posmod(wrapped_raw_col, cols) - - var draw_col: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_center.x, cols) - var tile_top_left := Vector2(float(draw_col) - half_tile, 0.0 - half_tile) - var local_origin: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - tile_top_left, held_center, held_n, CELL_PIXEL_SIZE - ) - var extent_px: float = float(tile_n) * CELL_PIXEL_SIZE - - # Canvas-local -> screen space: _canvas.position = view_offset, - # _canvas.scale = view_zoom (AtlasWindowViewer._apply_transform()'s own - # transform, mirrored here since this is a pure-geometry test with no - # live Control/Node2D tree). - var screen_top_left: Vector2 = view_offset + local_origin * view_zoom - var screen_extent: Vector2 = Vector2(extent_px, extent_px) * view_zoom - var tile_rect := Rect2(screen_top_left, screen_extent) - var viewport_rect := Rect2(Vector2.ZERO, viewport) - - var overlap: Rect2 = viewport_rect.intersection(tile_rect) - var tile_area: float = screen_extent.x * screen_extent.y - var overlap_fraction: float = 0.0 - if tile_area > 0.0: - overlap_fraction = (overlap.size.x * overlap.size.y) / tile_area - - assert_float(overlap_fraction).override_failure_message( - ( - "the wrapped tile's draw rect %s overlaps the viewport %s by only" - + " %.1f%% of its own area — must be at least 50%% when the view" - + " covers the whole body. This is live round 5's 'left third of the" - + " mosaic is black' repro: drawing the CANONICAL column (%d) directly" - + " (without nearest_wrap_image()) places this tile off-canvas RIGHT" - + " instead of its true position on the LEFT" - ) - % [tile_rect, viewport_rect, overlap_fraction * 100.0, canonical_col] - ).is_greater_equal(0.5) - - -## The core contract this function exists for: recomputing `_view_offset` so -## a KNOWN screen point continues to map to canvas-local -## `new_held_n/2 * cell_px` (the new window's own center) — i.e. feeding the -## OUTPUT back through district_to_canvas_local()'s own "center district -> -## half-extent local" identity (tested above) and applying the resulting -## transform must reproduce the SAME screen point exactly. -func test_recompute_offset_for_held_n_change_preserves_the_screen_point() -> void: - var screen_point := Vector2(800.0, 450.0) - var view_zoom := 2.5 - var new_held_n := 16 - var offset: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( - screen_point, view_zoom, new_held_n, CELL_PIXEL_SIZE - ) - var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * CELL_PIXEL_SIZE) - var reconstructed_screen_point: Vector2 = new_local * view_zoom + offset - assert_that(reconstructed_screen_point).is_equal_approx(screen_point, Vector2.ONE * 0.01) - - -## Live round 4's OWN repro: crossing from Region (~thousands-districts held_n) -## to District (64) or Quarter (16) must produce a DIFFERENT offset than -## leaving `_view_offset` untouched would — pinning that this function's -## OUTPUT actually depends on `new_held_n` (the exact thing the round-4 bug -## got wrong by never calling this function at all). -func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> void: - var screen_point := Vector2(800.0, 450.0) - var view_zoom := 3.378 # live round 4's own District-band zoom value - var offset_district: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( - screen_point, view_zoom, 64, CELL_PIXEL_SIZE - ) - var offset_quarter: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( - screen_point, view_zoom, 16, CELL_PIXEL_SIZE - ) - assert_that(offset_district).override_failure_message( - "a rung crossing that changes held_n must recompute a DIFFERENT" - + " _view_offset — reusing the same offset across the crossing is" - + " exactly the live round 4 bug (composite renders off-canvas)" - ).is_not_equal(offset_quarter) - - -# ============================================================================= -# T-1172 round 2: cell_index_for_local_offset() — the shared painter/clip -# index formula (see its own doc for the "why shared, not duplicated" case). -# T-1170: these tests moved here from test_atlas_window_geometry_nature.gd — -# the function itself stayed on THIS file (AtlasWindowGeometry) rather than -# moving to atlas_window_geometry_nature.gd, since it is shared with -# AtlasWindowOverlay's terrain painter, a non-nature consumer — see that -## file's own header doc for the full split rationale. -# ============================================================================= - - -func test_cell_index_for_local_offset_top_left_is_zero_zero() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(0.0, 0.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(0, 0)) - - -## The exact live-repro numbers from T-1172 round 2's trace: a query whose -## district-space local offset is (2594.09, 5593.15) inside a 6400-wide, -## 64-cell-side window must resolve to (col=25, row=55) — pinned directly -## against the LIVE captured values that closed the investigation (both the -## painter's _build_tile_texture() and the clip independently produced this -## exact pair for the same query in the live trace). -func test_cell_index_for_local_offset_matches_the_live_trace_repro() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset( - 2594.0849609375, 5593.15258789062, 6400, 64 - ) - assert_that(cell).override_failure_message( - "must match the live-captured painter/clip agreement point from the" - + " T-1172 round 2 investigation — (col=25, row=55)" - ).is_equal(Vector2i(25, 55)) - - -func test_cell_index_for_local_offset_bottom_right_boundary_clamps_inside() -> void: - # local offset == n (the exclusive upper boundary) must clamp to the LAST - # cell, not overflow to a nonexistent grid_side'th cell. - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(6400.0, 6400.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(63, 63)) - - -func test_cell_index_for_local_offset_zero_n_or_grid_side_returns_sentinel() -> void: - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 0, 64)).is_equal( - Vector2i(-1, -1) - ) - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 6400, 0)).is_equal( - Vector2i(-1, -1) - ) diff --git a/client/tests/test_atlas_window_geometry_nature.gd b/client/tests/test_atlas_window_geometry_nature.gd deleted file mode 100644 index 4752bff5b..000000000 --- a/client/tests/test_atlas_window_geometry_nature.gd +++ /dev/null @@ -1,885 +0,0 @@ -## T-1156 wave 1 / T-1170: pure-function tests for AtlasWindowGeometryNature's -## Layer-1 nature-overlay pixel mapping (layer1_pixel_to_world_m/ -## world_m_to_district/layer1_pixel_to_canvas_local), per-rung visibility/ -## filter policy (skeleton_class_visible_at_rung/course_class_visible_at_rung/ -## confluences_visible_at_rung/mouths_visible_at_rung/basins_visible_at_rung/ -## attractors_visible_at_rung), the D8 river_downstream decode -## (d8_downstream_target), and course width/opacity readers -## (course_class_width_px/course_class_opacity). Split from -## test_atlas_window_geometry.gd (already close to the gdlint max-file-lines -## cap) — same file-per-concern precedent as test_atlas_window_colors.gd being -## separate from test_atlas_window_overlay.gd. -## -## T-1170: this file's SUBJECT preload moved from AtlasWindowGeometry to -## AtlasWindowGeometryNature (the T-1170 split, see that file's own doc) — -## every symbol tested below now lives there. cell_index_for_local_offset() -## STAYED on AtlasWindowGeometry (shared with the non-nature terrain painter) -## — its tests stay in test_atlas_window_geometry.gd, not duplicated here. -class_name TestAtlasWindowGeometryNature -extends GdUnitTestSuite - -const AtlasWindowGeometryNature := preload( - "res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd" -) - -const CELL_PIXEL_SIZE: float = 16.0 -const DISTRICT_M: float = 2048.0 - - -# ============================================================================= -# layer1_pixel_to_world_m — the forward mirror of -# server/src/atlas/district_profile.rs's pixel_to_world_m(), verified against -# that function's source directly (not assumed). -# ============================================================================= - - -## Column 0 is world/longitude 0 on every body — no -0.5 centering, unlike -## rows (longitude wraps and has no "half" concept the way latitude does). -func test_layer1_pixel_to_world_m_col_zero_is_world_x_zero() -> void: - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(0.0, 0.0, 256.0, 128.0, 6371.0) - assert_float(w.x).is_equal_approx(0.0, 0.001) - - -## Row 0 is the NORTH POLE — server's own comment: `lat_frac = -0.5 = N pole` -## — which the forward map resolves to the MOST NEGATIVE wy (world Y -## increases southward, matching AtlasDescendGeometry.district_pos_at()'s own -## row-increases-southward convention on the inverse side of this mapping). -func test_layer1_pixel_to_world_m_row_zero_is_north_pole_negative_wy() -> void: - var radius_km := 6371.0 - var grid_h := 128.0 - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( - 0.0, 0.0, 256.0, grid_h, radius_km - ) - var meridian_m: float = PI * radius_km * 1000.0 - assert_float(w.y).is_equal_approx(-0.5 * meridian_m, 1.0) - - -## Row (grid_h - 1) is the SOUTH POLE — `lat_frac = +0.5 = S` — the most -## POSITIVE wy, the opposite extreme from row 0. -func test_layer1_pixel_to_world_m_last_row_is_south_pole_positive_wy() -> void: - var radius_km := 6371.0 - var grid_h := 128.0 - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( - grid_h - 1.0, 0.0, 256.0, grid_h, radius_km - ) - var meridian_m: float = PI * radius_km * 1000.0 - assert_float(w.y).is_equal_approx(0.5 * meridian_m, 1.0) - - -## The equator row (grid_h / 2, approximately — the exact half-height pixel) -## is world Y ~0 — halfway between the two poles. Not EXACT (the denominator -## is grid_h - 1 = 127, not 128), so the tolerance is loose (200km). -func test_layer1_pixel_to_world_m_mid_row_is_near_equator() -> void: - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(64.0, 0.0, 256.0, 128.0, 6371.0) - assert_float(w.y).is_equal_approx(0.0, 200_000.0) - - -## Column at grid_w (a full wrap) must equal the FULL circumference — the -## wrap point, matching longitude's periodic (not clamped) treatment. -func test_layer1_pixel_to_world_m_full_width_col_is_full_circumference() -> void: - var radius_km := 6371.0 - var grid_w := 256.0 - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( - 0.0, grid_w, grid_w, 128.0, radius_km - ) - var circumference_m: float = TAU * radius_km * 1000.0 - assert_float(w.x).is_equal_approx(circumference_m, 5.0) - - -## No-radius (tiny test body): 1 heightmap pixel = 1 DISTRICT_M metre exactly -## — matching pixel_to_world_m()'s own no-radius fallback and -## AtlasDescendGeometry.district_pos_at()'s no-radius branch on the inverse side. -func test_layer1_pixel_to_world_m_no_radius_is_one_pixel_one_district_m() -> void: - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(3.0, 5.0, 64.0, 64.0, 0.0) - assert_that(w).is_equal(Vector2(5.0 * DISTRICT_M, 3.0 * DISTRICT_M)) - - -## Degenerate grid dims (grid_w/grid_h <= 0) must not divide-by-zero or crash. -func test_layer1_pixel_to_world_m_zero_grid_dims_returns_zero() -> void: - var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(1.0, 1.0, 0.0, 0.0, 6371.0) - assert_that(w).is_equal(Vector2.ZERO) - - -# ============================================================================= -# world_m_to_district — one division by DISTRICT_M, sub-district precision -# preserved (not rounded). -# ============================================================================= - - -func test_world_m_to_district_divides_by_district_m() -> void: - var d: Vector2 = AtlasWindowGeometryNature.world_m_to_district( - Vector2(DISTRICT_M * 3.5, DISTRICT_M * -2.25) - ) - assert_that(d).is_equal_approx(Vector2(3.5, -2.25), Vector2.ONE * 0.001) - - -# ============================================================================= -# layer1_pixel_to_canvas_local — the full composition, cross-checked against -# AtlasWindowGeometry.district_to_canvas_local() called manually with the -# same intermediate value. -# ============================================================================= - - -## A river pixel at the held window's own center district must land at -## canvas-local half-extent — same invariant -## test_district_to_canvas_local_center_district_lands_at_half_extent() -## pins for the district-space function this one wraps. -func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: - var AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - var radius_km := 6371.0 - var grid_w := 256.0 - var grid_h := 128.0 - var held_center := Vector2i(10, 20) - var held_n := 64 - var row := 40.0 - var col := 80.0 - - var result: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_canvas_local( - row, col, grid_w, grid_h, radius_km, held_center, held_n, CELL_PIXEL_SIZE - ) - - var world_m: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( - row, col, grid_w, grid_h, radius_km - ) - var district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) - var expected: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - district, held_center, held_n, CELL_PIXEL_SIZE - ) - assert_that(result).is_equal_approx(expected, Vector2.ONE * 0.001) - - -# ============================================================================= -# T-1170 Ruling 2a-2d/5a: d8_downstream_target() — the river_downstream D8 -# pointer decode. Direction table CONFIRMED against Dudley's A1 -# (server/src/atlas/drainage.rs:35-44): 0=N(-1,0) 1=S(1,0) 2=E(0,1) 3=W(0,-1) -# 4=NE(-1,1) 5=NW(-1,-1) 6=SE(1,1) 7=SW(1,-1). Sentinels: MOUTH=8, -# EDGE_DRAIN=9, TERMINAL=10 (reserved). -# ============================================================================= - - -func test_d8_downstream_target_north_decrements_row() -> void: - var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 0) - assert_that(target).is_equal(Vector2(9.0, 10.0)) - - -func test_d8_downstream_target_south_increments_row() -> void: - var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 1) - assert_that(target).is_equal(Vector2(11.0, 10.0)) - - -func test_d8_downstream_target_east_increments_col() -> void: - var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 2) - assert_that(target).is_equal(Vector2(10.0, 11.0)) - - -func test_d8_downstream_target_west_decrements_col() -> void: - var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 3) - assert_that(target).is_equal(Vector2(10.0, 9.0)) - - -func test_d8_downstream_target_diagonals_move_both_axes() -> void: - # 4=NE, 5=NW, 6=SE, 7=SW — each a diagonal (row, col) delta of magnitude 1 - # on both axes, matching the direction letters' compass meaning. - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 4)).is_equal( - Vector2(9.0, 11.0) - ) # NE - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 5)).is_equal( - Vector2(9.0, 9.0) - ) # NW - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 6)).is_equal( - Vector2(11.0, 11.0) - ) # SE - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 7)).is_equal( - Vector2(11.0, 9.0) - ) # SW - - -## MOUTH (8), EDGE_DRAIN (9), and TERMINAL (10, reserved) are all sentinels -## >= RIVER_DOWNSTREAM_SENTINEL_BASE — every one must decode to `null` (chain -## end, no segment to draw), not a direction lookup. -func test_d8_downstream_target_sentinels_return_null() -> void: - assert_that( - AtlasWindowGeometryNature.d8_downstream_target( - 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH - ) - ).is_null() - assert_that( - AtlasWindowGeometryNature.d8_downstream_target( - 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN - ) - ).is_null() - assert_that( - AtlasWindowGeometryNature.d8_downstream_target( - 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL - ) - ).is_null() - - -## A malformed/out-of-range direction (negative, or >= sentinel base but not -## one of the three named sentinels — e.g. a future reserved value) must also -## decode to null, not crash on an out-of-bounds D8_DIRECTION_DELTAS index. -func test_d8_downstream_target_out_of_range_returns_null_not_crash() -> void: - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, -1)).is_null() - assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 255)).is_null() - - -## The sentinel base itself (8) is the exact boundary between the last real -## direction (7=SW) and the first sentinel (8=MOUTH) — pin the boundary -## exactly rather than relying only on the interior-value tests above. -func test_d8_downstream_target_boundary_seven_is_direction_eight_is_sentinel() -> void: - assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 7)).is_not_null() - assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 8)).is_null() - - -# ============================================================================= -# T-1170 Ruling 5a: build_skeleton_chords() — the pure chain-CONSTRUCTION -# function (no draw calls) AtlasWindowNatureOverlay._draw_skeleton_chords() -# delegates to. This is the load-bearing chain-walking logic (visibility -# filtering + D8 decode + sentinel chain-ends), tested here directly rather -# than only through the draw-smoke suite's pixel proof. -# ============================================================================= - - -## Two river cells, cell 0 flows SOUTH (direction 1) into cell 1's own grid -## position — one segment constructed, from cell 0's position to cell 0's -## position + (1, 0) [south]. cls read from river_class at the SAME index. -func test_build_skeleton_chords_constructs_one_segment_for_a_simple_pair() -> void: - var river_cells: Array = [[10, 10], [11, 10]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK - ] - var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] # 1 = S - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "Region" - ) - assert_int(chords.size()).override_failure_message( - "cell 0 (flows S, a real direction) must construct one segment;" - + " cell 1 (MOUTH sentinel) must construct none — expected exactly 1 total" - ).is_equal(1) - var chord: Dictionary = chords[0] - assert_that(chord["from"]).is_equal(Vector2(10.0, 10.0)) - assert_that(chord["to"]).is_equal(Vector2(11.0, 10.0)) - assert_int(chord["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) - - -## Sentinel chain ends: MOUTH, EDGE_DRAIN, and TERMINAL (reserved) must each -## construct ZERO segments for their own cell — a chain-end has no downstream -## neighbor to connect to, regardless of which sentinel flavor. -func test_build_skeleton_chords_sentinel_chain_ends_construct_no_segment() -> void: - var river_cells: Array = [[0, 0], [10, 10], [20, 20]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ] - var river_downstream: Array = [ - AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, - AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN, - AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL, - ] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "Region" - ) - assert_array(chords).override_failure_message( - "every cell is a sentinel chain-end (MOUTH/EDGE_DRAIN/TERMINAL) —" - + " zero segments must be constructed" - ).is_empty() - - -## A downstream direction pointing at a class not visible at this rung's -## SKELETON path is filtered by the UPSTREAM cell's own class, not the -## target's — District shows NO skeleton classes at all (T-1170: skeleton -## draws only at Region now), so a District query must construct zero -## segments regardless of the fixture's directions. -func test_build_skeleton_chords_district_rung_constructs_nothing() -> void: - var river_cells: Array = [[10, 10], [11, 10]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK - ] - var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "District" - ) - assert_array(chords).is_empty() - - -## river_downstream shorter than river_cells (pre-T-1170 payload / graceful -## empty-Vec decode) — cells with no corresponding index must construct no -## segment, not crash on an out-of-bounds read. -func test_build_skeleton_chords_missing_downstream_entries_construct_nothing() -> void: - var river_cells: Array = [[10, 10], [11, 10], [12, 10]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ] - var river_downstream: Array = [1] # only index 0 has a pointer - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "Region" - ) - assert_int(chords.size()).is_equal(1) - - -## An empty river_downstream array entirely (the actual wire shape Dudley's -## `#[serde(default)]` produces for a pre-T-1170 payload) must construct zero -## segments, not error. -func test_build_skeleton_chords_empty_downstream_array_constructs_nothing() -> void: - var river_cells: Array = [[10, 10], [11, 10]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK - ] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, [], "Region" - ) - assert_array(chords).is_empty() - - -## A malformed river_cells entry (not an Array, or too short) is skipped -## entirely — no segment constructed for it, no crash, and it does not -## disturb construction for the OTHER (well-formed) entries in the same -## fixture. -func test_build_skeleton_chords_malformed_cell_entry_is_skipped_not_fatal() -> void: - var river_cells: Array = [[10, 10], "not an array", [12, 10]] - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ] - var river_downstream: Array = [1, 1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "Region" - ) - assert_int(chords.size()).override_failure_message( - "the malformed middle entry must be skipped without disturbing the" - + " well-formed entries around it — expected exactly 1 (cell 0 -> S)" - ).is_equal(1) - - -## river_class shorter than river_cells falls back to RIVER_CLASS_FALLBACK -## (TRUNK) for the missing entry — the same graceful-decode posture -## skeleton_class_visible_at_rung()'s own caller already relies on. -func test_build_skeleton_chords_missing_class_entry_falls_back_to_trunk() -> void: - var river_cells: Array = [[10, 10]] - var river_downstream: Array = [1] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, [], river_downstream, "Region" - ) - assert_int(chords.size()).is_equal(1) - assert_int(chords[0]["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) - - -## REVERT-VERIFICATION pin (per the ticket brief's explicit ask to -## revert-verify the most load-bearing construction path): a chain of THREE -## cells (0 -> 1 -> MOUTH) must construct exactly TWO segments in the correct -## from/to order — proving the chain doesn't just count sentinels correctly -## in isolation (the tests above) but actually threads a multi-hop chain. -## Breaking build_skeleton_chords() to, e.g., always connect cell i to cell -## i+1 by INDEX (the old dot-scatter's adjacency, not a real D8 decode) would -## still pass the single-pair test above by coincidence but fail this one, -## since cell 1's OWN downstream direction (2 = E) does not point at -## cell 2's grid position. -func test_build_skeleton_chords_three_hop_chain_threads_correctly() -> void: - var river_cells: Array = [[0, 0], [1, 0], [1, 5]] # cell 2 is NOT south of cell 1 - var river_class: Array = [ - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ] - var river_downstream: Array = [ - 1, # cell 0 -> S -> (1, 0), matches cell 1's own grid position - 2, # cell 1 -> E -> (1, 1) — NOT cell 2's position (1, 5) - AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, - ] - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, "Region" - ) - assert_int(chords.size()).is_equal(2) - assert_that(chords[0]["from"]).is_equal(Vector2(0.0, 0.0)) - assert_that(chords[0]["to"]).is_equal(Vector2(1.0, 0.0)) - assert_that(chords[1]["from"]).is_equal(Vector2(1.0, 0.0)) - # cell 1's OWN downstream (E) decodes to (1, 1), NOT cell 2's own listed - # position (1, 5) — pinning that this function trusts the D8 DECODE, not - # a by-index lookup into river_cells, exactly per the doc's "the decoded - # target cell is not required to appear in river_cells" contract. - assert_that(chords[1]["to"]).override_failure_message( - "cell 1's downstream target must be its DECODED D8 neighbor (1,1)," - + " never a by-index lookup into river_cells (which would wrongly" - + " give (1,5), cell 2's own listed position)" - ).is_equal(Vector2(1.0, 1.0)) - - -# ============================================================================= -# T-1170 Ruling 5c: the RIVER_CLASS_VISIBLE_BY_RUNG split — -# skeleton_class_visible_at_rung() (Region+ chord-chain path, Ruling 5a) and -# course_class_visible_at_rung() (District/Quarter windowed path, Ruling 5b). -# ============================================================================= - - -func test_skeleton_class_visible_at_rung_region_shows_every_class() -> void: - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Region" - ) - ).is_true() - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Region" - ) - ).is_true() - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Region" - ) - ).is_true() - - -## T-1170: the skeleton path no longer draws AT ALL at District/Quarter (the -## chord chain is Region-only — District/Quarter draw courses instead, the -## OTHER table below) — this is a CHANGE from wave 1's original District -## "trunk only" disposition on the single RIVER_CLASS_VISIBLE_BY_RUNG table. -func test_skeleton_class_visible_at_rung_district_and_quarter_show_nothing() -> void: - for cls in [ - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ]: - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "District") - ).override_failure_message( - "the skeleton (chord-chain) path must show NOTHING at District —" - + " District draws courses instead (Ruling 5b)" - ).is_false() - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "Quarter") - ).is_false() - - -## An unrecognized rung tag falls back to Region's fullest visibility set — -## the cluster's existing "unrecognized -> safest/most permissive already- -## shipped behavior" posture. -func test_skeleton_class_visible_at_rung_unknown_tag_falls_back_to_region() -> void: - assert_bool( - AtlasWindowGeometryNature.skeleton_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Bogus" - ) - ).is_true() - - -func test_course_class_visible_at_rung_district_shows_trunk_and_tributary_only() -> void: - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "District" - ) - ).override_failure_message("District courses must NOT show streams").is_false() - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "District" - ) - ).is_true() - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "District" - ) - ).is_true() - - -## The pre-announced wave-1 revisit executing: Quarter shows ALL THREE -## classes on the course path — "Quarter rivers return". -func test_course_class_visible_at_rung_quarter_shows_every_class() -> void: - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Quarter" - ) - ).override_failure_message("Quarter rivers return — streams must be visible").is_true() - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Quarter" - ) - ).is_true() - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Quarter" - ) - ).is_true() - - -## Region never carries courses (Ruling 1) — the course table has no Region -## key at all, and this reader must fail to EMPTY (not fall back to "show -## everything", the opposite fallback direction from the skeleton reader) so -## a caller can never accidentally draw course polylines at Region. -func test_course_class_visible_at_rung_region_shows_nothing() -> void: - for cls in [ - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ]: - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung(cls, "Region") - ).override_failure_message( - "courses must never be visible at Region — Region draws the skeleton" - + " chord chain, never windowed course content" - ).is_false() - - -func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void: - assert_bool( - AtlasWindowGeometryNature.course_class_visible_at_rung( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Bogus" - ) - ).is_false() - - -# ============================================================================= -# T-1170 Ruling 5b/3h: build_course_render_plan() — pure course-polyline -# CONSTRUCTION (no draw calls), the course-path counterpart to B2's -# build_skeleton_chords(). Synthetic fixtures shaped per Ruling 3h's wire -# shape: {class: u8, points: Vec<(i32,i32)> world-metres, terminus: string} -# — built BEFORE Dudley's A2 (course inventor) lands, per the ticket brief's -# explicit instruction. -# ============================================================================= - - -static func _course_fixture( - cls: int, points: Array, terminus: String = "None" -) -> Dictionary: - return {"edge_id": 1, "class": cls, "points": points, "terminus": terminus} - - -func test_build_course_render_plan_district_trunk_is_visible_and_constructs_points() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0], [4096, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_not_null() - var canvas_pts: PackedVector2Array = plan["canvas_pts"] - assert_int(canvas_pts.size()).is_equal(3) - assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) - assert_str(plan["terminus"]).is_equal("None") - - -## District does NOT show streams (COURSE_CLASS_VISIBLE_BY_RUNG: District == -## [TRIBUTARY, TRUNK]) — a stream-class course must construct nothing at -## District, even with perfectly well-formed points. -func test_build_course_render_plan_district_stream_is_not_visible() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [2048, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).override_failure_message( - "streams must not draw at District — only tributary+trunk are visible there" - ).is_null() - - -## Quarter rivers return — ALL THREE classes construct at Quarter, including -## streams. This is the wave-1 pre-announced revisit actually landing. -func test_build_course_render_plan_quarter_stream_is_visible() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [512, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "Quarter", Vector2i.ZERO, 16, CELL_PIXEL_SIZE - ) - assert_that(plan).override_failure_message( - "Quarter rivers return — streams must be visible at Quarter" - ).is_not_null() - - -## Region never carries courses — a course-shaped fixture queried at "Region" -## must construct nothing, regardless of class. -func test_build_course_render_plan_region_constructs_nothing() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "Region", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_null() - - -## Ruling 3h: terminus MOUTH is preserved through to the plan — the caller -## (the overlay's draw function) reads this to decide whether to draw a -## mouth ring at the LAST canvas point. -func test_build_course_render_plan_preserves_mouth_terminus() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], "Mouth" - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_not_null() - assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH) - - -## EdgeDrain, ContinuesBeyondWindow, and the default None terminus are all -## preserved verbatim too — the PLAN doesn't collapse them, the DRAW caller -## decides presentation (no ring for any of these three). -func test_build_course_render_plan_preserves_edge_drain_and_continues_and_none_termini() -> void: - for terminus in ["EdgeDrain", "ContinuesBeyondWindow", "None"]: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], terminus - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_str(plan["terminus"]).is_equal(terminus) - - -## A course with no `terminus` key at all (an old/malformed payload) defaults -## to COURSE_TERMINUS_NONE (the string "None"), never GDScript `null` or an -## empty string — matching the class-fallback graceful-decode posture used -## throughout this cluster. -func test_build_course_render_plan_missing_terminus_defaults_to_none_string() -> void: - var course: Dictionary = { - "edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "points": [[0, 0], [100, 0]] - } - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_NONE) - - -## A course missing `class` entirely falls back to RIVER_CLASS_FALLBACK -## (TRUNK) — same posture as the skeleton path's river_class fallback. -func test_build_course_render_plan_missing_class_falls_back_to_trunk() -> void: - var course: Dictionary = {"edge_id": 1, "points": [[0, 0], [100, 0]]} - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_not_null() - assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) - - -## Fewer than 2 points (a degenerate single-point or empty course) has no -## line to draw — must construct null, not a 1-point/0-point polyline. -func test_build_course_render_plan_fewer_than_two_points_constructs_nothing() -> void: - var one_point: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0]]) - var no_points: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, []) - assert_that( - AtlasWindowGeometryNature.build_course_render_plan( - one_point, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - ).is_null() - assert_that( - AtlasWindowGeometryNature.build_course_render_plan( - no_points, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - ).is_null() - - -## Missing `points` key entirely (not just an empty array) must also -## construct nothing, not crash on a null/missing field read. -func test_build_course_render_plan_missing_points_key_constructs_nothing() -> void: - var course: Dictionary = {"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK} - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_null() - - -## A malformed individual point (not an array, or too short) is skipped — -## not fatal to the whole polyline, matching build_skeleton_chords()'s own -## "skip the bad entry, keep going" posture — as long as >= 2 valid points -## remain. -func test_build_course_render_plan_malformed_point_is_skipped_not_fatal() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "not a point", [2048, 0], [4096, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_not_null() - var canvas_pts: PackedVector2Array = plan["canvas_pts"] - assert_int(canvas_pts.size()).override_failure_message( - "the malformed point must be skipped, leaving exactly the 3 well-formed points" - ).is_equal(3) - - -## Malformed points that leave FEWER than 2 valid entries must still -## construct null (the "too many bad points" case, distinct from "some bad -## points but enough good ones remain" above). -func test_build_course_render_plan_malformed_points_leaving_too_few_constructs_nothing() -> void: - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "bad", "also bad"] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE - ) - assert_that(plan).is_null() - - -## Points are WORLD METRES (Ruling 3h), not heightmap pixels — cross-checked -## against world_m_to_canvas_local() called manually, proving the plan's -## conversion path matches the documented one-fewer-step-than-skeleton -## pipeline (no layer1_pixel_to_world_m() involved at all). -func test_build_course_render_plan_points_are_world_metres_not_pixels() -> void: - var held_center := Vector2i(5, 5) - var held_n := 64 - var world_pt := Vector2(10240.0, -4096.0) # 5 districts east, 2 north of origin - var course: Dictionary = _course_fixture( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - [[int(world_pt.x), int(world_pt.y)], [0, 0]] - ) - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, "District", held_center, held_n, CELL_PIXEL_SIZE - ) - var expected: Vector2 = AtlasWindowGeometryNature.world_m_to_canvas_local( - world_pt, held_center, held_n, CELL_PIXEL_SIZE - ) - var canvas_pts: PackedVector2Array = plan["canvas_pts"] - assert_that(canvas_pts[0]).is_equal_approx(expected, Vector2.ONE * 0.01) - - -# ============================================================================= -# T-1170 Ruling 5c: course_class_width_px() / course_class_opacity() — -# functional-default companion tables to COURSE_CLASS_VISIBLE_BY_RUNG. -# ============================================================================= - - -func test_course_class_width_px_trunk_widest_stream_thinnest() -> void: - var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM - ) - var tributary_w: float = AtlasWindowGeometryNature.course_class_width_px( - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY - ) - var trunk_w: float = AtlasWindowGeometryNature.course_class_width_px( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK - ) - assert_float(trunk_w).override_failure_message( - "trunk course width must be the WIDEST of the three classes" - ).is_greater(tributary_w) - assert_float(tributary_w).override_failure_message( - "tributary course width must be strictly between stream and trunk" - ).is_greater(stream_w) - - -func test_course_class_opacity_trunk_most_opaque_stream_least() -> void: - var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM - ) - var trunk_o: float = AtlasWindowGeometryNature.course_class_opacity( - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK - ) - assert_float(trunk_o).is_greater(stream_o) - assert_float(trunk_o).override_failure_message("trunk opacity must be fully opaque (1.0)").is_equal_approx( - 1.0, 0.0001 - ) - - -## An unrecognized class id falls back to the stream (thinnest/most transparent) -## defaults on both tables — the documented, deliberate "unknown -> least -## visually assertive" fallback. -func test_course_class_width_and_opacity_unknown_class_falls_back_to_stream() -> void: - var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM - ) - var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( - AtlasWindowGeometryNature.RIVER_CLASS_STREAM - ) - assert_float(AtlasWindowGeometryNature.course_class_width_px(99)).is_equal_approx( - stream_w, 0.0001 - ) - assert_float(AtlasWindowGeometryNature.course_class_opacity(99)).is_equal_approx( - stream_o, 0.0001 - ) - - -# ============================================================================= -# Feature-group per-rung gates — confluences/mouths/basins/attractors. -# ============================================================================= - - -func test_confluences_visible_at_rung_region_true_others_false() -> void: - assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("Region")).is_true() - assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("District")).is_false() - assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("Quarter")).is_false() - - -## Mouths get the one rung-based EXCEPTION in the whole table: District keeps -## them visible (a mouth is always a landmark, per the ruling) — the only -## feature group where District differs from Region's disposition. -func test_mouths_visible_at_rung_region_and_district_true_quarter_false() -> void: - assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("Region")).is_true() - assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("District")).is_true() - assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("Quarter")).is_false() - - -func test_basins_visible_at_rung_region_only() -> void: - assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("Region")).is_true() - assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("District")).is_false() - assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("Quarter")).is_false() - - -func test_attractors_visible_at_rung_region_only() -> void: - assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Region")).is_true() - assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("District")).is_false() - assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Quarter")).is_false() - - -# ============================================================================= -# Coordinator live-eyeball finding (2026-07-23): zoom_compensated_size() — -# marker sizes must stay CONSTANT on screen regardless of _view_zoom -# (Araminta's ruling), but draw calls execute inside a Node2D whose .scale IS -# _view_zoom — a raw constant gets multiplied by that transform at render -# time. This function pre-divides so the transform's multiply cancels back -# out to the literal screen-space value. -# ============================================================================= - - -## At zoom=1.0 (the canvas transform's identity scale) the compensated size -## must equal the input unchanged — no over/under-correction at the one zoom -## level where compensation is a no-op by construction. -func test_zoom_compensated_size_at_zoom_one_is_unchanged() -> void: - assert_float(AtlasWindowGeometryNature.zoom_compensated_size(2.2, 1.0)).is_equal_approx(2.2, 0.0001) - - -## The exact regression shape: at Lendel's real orbital fit zoom (~0.0063, -## live drive script), the compensated size must be much LARGER than the -## raw screen-space constant — inversely proportional to zoom — so that once -## the canvas transform re-multiplies it by view_zoom at render time, the -## EFFECTIVE on-screen size lands back at the literal ruling value, not a -## sub-pixel sliver. -func test_zoom_compensated_size_at_orbital_zoom_scales_up_inversely() -> void: - var view_zoom := 0.0063 - var screen_space_size := 2.2 - var compensated: float = AtlasWindowGeometryNature.zoom_compensated_size( - screen_space_size, view_zoom - ) - # Round-trip: compensated * view_zoom must reconstruct the original - # screen-space size — this IS the property that makes the on-screen - # result zoom-invariant (the canvas transform performs exactly this - # multiply at render time). - assert_float(compensated * view_zoom).is_equal_approx(screen_space_size, 0.001) - assert_float(compensated).override_failure_message( - "at a tiny orbital zoom, the compensated size must be dramatically LARGER" - + " than the raw screen-space constant — that's the whole point of the fix" - ).is_greater(screen_space_size * 10.0) - - -## The exact BUG this fix closes, pinned as a regression: an UNCOMPENSATED -## radius (screen_space_size used directly, the pre-fix behavior) multiplied -## by Lendel's real orbital zoom produces a sub-pixel effective size — this -## is the "the ruling's px value, at orbital fit zoom, is invisible" claim -## from the coordinator's diagnosis, verified numerically rather than just -## asserted. -func test_uncompensated_radius_at_orbital_zoom_would_be_sub_pixel() -> void: - var view_zoom := 0.0063 - var raw_screen_space_radius := 2.2 # RIVER_DOT_RADIUS_BY_CLASS_REGION[TRUNK] - var effective_size_if_uncompensated: float = raw_screen_space_radius * view_zoom - assert_float(effective_size_if_uncompensated).override_failure_message( - "an uncompensated radius at orbital zoom must be sub-pixel — pinning the" - + " numeric magnitude of the bug this fix closes, not just its existence" - ).is_less(0.02) - - -## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce -## infinity/NaN — the floor guard keeps this function total. -func test_zoom_compensated_size_zero_zoom_does_not_blow_up() -> void: - var result: float = AtlasWindowGeometryNature.zoom_compensated_size(2.2, 0.0) - assert_bool(is_finite(result)).override_failure_message( - "a degenerate zero view_zoom must not produce inf/NaN" - ).is_true() diff --git a/client/tests/test_atlas_window_geometry_stroke_width.gd b/client/tests/test_atlas_window_geometry_stroke_width.gd deleted file mode 100644 index 9766199dc..000000000 --- a/client/tests/test_atlas_window_geometry_stroke_width.gd +++ /dev/null @@ -1,252 +0,0 @@ -## T-1170 live round (2026-07-23): tests for -## AtlasWindowGeometryNature.zoom_compensated_stroke_width() — the -## STROKE-WIDTH-specific sibling of zoom_compensated_size(), added after the -## course-polyline hairline finding (Araminta's pixel scan of -## D-district-courses.png/Q-quarter-courses.png: a UNIFORM 1px hairline for -## the ENTIRE visible course, no width/opacity variation at all, in BOTH -## District and Quarter captures). Split into its own file rather than -## folded into test_atlas_window_geometry_nature.gd, which was already at the -## gdlint max-file-lines cap — same file-per-concern precedent as every other -## split in this cluster. -## -## Live A/B evidence (temporary instrumentation, since reverted — the -## dossier discipline): draw_line()/draw_polyline() called with a -## canvas-local width in [0.6, 1.0) renders as a flat 1px hairline -## regardless of the input value, confirmed identically on BOTH APIs (ruling -## out a draw_polyline()-specific quirk) — Godot's line rasterizer has a -## ~1.0-canvas-local-unit floor that draw_circle()'s radius parameter does -## NOT share (confirmed: mouth ring radii at the same District/Quarter zoom -## render correctly-sized via the unchanged zoom_compensated_size()/_zs() -## path — only the STROKE WIDTH argument was affected). The compensation -## MATH itself was never wrong (0.373 * 3.75 round-trips to 1.4 exactly) — -## the bug was that nothing floored the intermediate value against Godot's -## own rasterizer minimum before handing it to draw_line()/draw_polyline(). -class_name TestAtlasWindowGeometryStrokeWidth -extends GdUnitTestSuite - -const AtlasWindowGeometryNature := preload( - "res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd" -) - - -## At zoom=1.0, unchanged from zoom_compensated_size() — no floor engages -## when the input is already >= 1.0. -func test_zoom_compensated_stroke_width_at_zoom_one_is_unchanged() -> void: - assert_float( - AtlasWindowGeometryNature.zoom_compensated_stroke_width(2.2, 1.0) - ).is_equal_approx(2.2, 0.0001) - - -## The EXACT regression shape this round closes: at District's real fit zoom -## (3.75, live capture), the tributary class's raw table width (1.4px) -## divides to 0.3733 canvas-local — BELOW the 1.0 floor under the OLD -## zoom_compensated_size() path (pinned directly, not just asserted) — and -## zoom_compensated_stroke_width() must instead return exactly 1.0 (the -## floor), never the sub-floor raw division result. -func test_zoom_compensated_stroke_width_district_tributary_hits_the_floor() -> void: - var view_zoom := 3.75 # LENDEL's live District fit zoom, capture-confirmed - var raw_width_px := 1.4 # COURSE_CLASS_WIDTH_PX[RIVER_CLASS_TRIBUTARY] - var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom) - assert_float(unfloored).override_failure_message( - "regression pin: the OLD unfloored division must be BELOW 1.0 at this" - + " zoom — this is the exact numeric shape of the hairline bug" - ).is_less(1.0) - var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) - assert_float(floored).override_failure_message( - "zoom_compensated_stroke_width() must clamp to the 1.0 floor, not the" - + " sub-pixel unfloored value that collapses to Godot's hairline" - ).is_equal_approx(1.0, 0.0001) - - -## Same shape at Quarter's real fit zoom (7.5, live capture) — the floor -## engages even harder there (raw width divides to 0.1867). -func test_zoom_compensated_stroke_width_quarter_tributary_hits_the_floor() -> void: - var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed - var raw_width_px := 1.4 - var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) - assert_float(floored).is_equal_approx(1.0, 0.0001) - - -## Regression pin (PR #195 stroke-width-class shape, per the coordinator's -## explicit ask): for EACH course class, at District's real fit zoom, the -## EFFECTIVE on-screen width the render plan feeds (canvas-local width times -## view_zoom, exactly what the canvas transform multiplies at render time) -## must equal AT LEAST the table value — never less, since the floor can only -## push the effective width UP from what an unfloored divide would produce, -## never down. This is the "does the value actually reaching the screen -## match the table" pin the coordinator asked for, computed both ways -## (floored vs table) rather than eyeballed. -func test_effective_stroke_width_at_district_zoom_meets_table_value_per_class() -> void: - var view_zoom := 3.75 - for cls in [ - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ]: - var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls) - var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( - table_width, view_zoom - ) - var effective_screen_px: float = canvas_local * view_zoom - assert_float(effective_screen_px).override_failure_message( - ( - "class %d's effective on-screen stroke width (%.3fpx) must be AT" - + " LEAST its table value (%.3fpx) — the floor must never make a" - + " course THINNER than the ruling specifies, only ever thicker" - + " when the literal value would otherwise be sub-pixel" - ) - % [cls, effective_screen_px, table_width] - ).is_greater_equal(table_width - 0.001) - - -## Same pin at Quarter's fit zoom (7.5) — the floor engages harder there -## (streams' 0.9px table value divides to 0.12 canvas-local, furthest below -## the floor of any class/rung combination this batch draws). -func test_effective_stroke_width_at_quarter_zoom_meets_table_value_per_class() -> void: - var view_zoom := 7.5 - for cls in [ - AtlasWindowGeometryNature.RIVER_CLASS_STREAM, - AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, - AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, - ]: - var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls) - var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( - table_width, view_zoom - ) - var effective_screen_px: float = canvas_local * view_zoom - assert_float(effective_screen_px).is_greater_equal(table_width - 0.001) - - -## At a LOW zoom (well under 1.0, e.g. an extreme zoom-out within a rung — -## not just Region's orbital case), the floor must NOT engage: the ordinary -## divide-then-scale math must still produce the literal table value exactly, -## matching zoom_compensated_size()'s own unfloored behavior. Pins that the -## floor is a ONE-DIRECTION safety net, not a blanket override. -func test_zoom_compensated_stroke_width_does_not_engage_at_low_zoom() -> void: - var view_zoom := 0.1 - var raw_width_px := 2.2 - var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) - var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom) - assert_float(floored).override_failure_message( - "at a zoom where the unfloored value is already well above 1.0, the" - + " floor must be a no-op — identical to zoom_compensated_size()" - ).is_equal_approx(unfloored, 0.0001) - - -## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce -## infinity/NaN — same total-function guarantee as zoom_compensated_size(). -func test_zoom_compensated_stroke_width_zero_zoom_does_not_blow_up() -> void: - var result: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(2.2, 0.0) - assert_bool(is_finite(result)).override_failure_message( - "a degenerate zero view_zoom must not produce inf/NaN" - ).is_true() - - -# ============================================================================= -# T-1170 live round (2026-07-23, coordinator's mouth-ring finding): -# zoom_compensated_ring_radius() — the RADIUS-SMALLER-THAN-STROKE regime. -# Same A/B-bracket discipline as the stroke-width suite above, applied to -# draw_arc() RING markers (mouth rings), whose radius AND stroke are both -# small zoom-compensated values that can cross each other. -## -## Live A/B evidence (temporary instrumentation, since reverted): at the -## PRODUCTION Quarter-rung radius/stroke pair (radius=0.667, stroke=1.0 -## canvas-local units, Quarter fit zoom 7.5), draw_arc() rendered a SOLID -## BLOB, not a hollow ring — confirmed via a re-centered live capture (the -## ORIGINAL "zero ring pixels" symptom was a separate viewport-framing crop, -## not this bug — see zoom_compensated_ring_radius()'s own doc). Bracket -## (stroke fixed at 1.0 canvas-local, radius varied): 0.51 (~stroke/2) -> -## blob; 1.0 (=stroke, the production case) -> blob; 1.5 (1.5x stroke) -> -## hollow ring recovers; 2.0 (2x stroke) -> hollow ring, cleaner. Floor set -## at 2x with margin over the observed 1.0x-blob/1.5x-hollow transition. -# ============================================================================= - - -## At zoom=1.0 with a radius comfortably above stroke*2 already, the floor -## must be a no-op — identical to zoom_compensated_size() directly. -func test_zoom_compensated_ring_radius_no_op_when_radius_already_clears_the_floor() -> void: - var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, 1.5, 1.0) - assert_float(radius).is_equal_approx(5.0, 0.0001) - - -## The EXACT regression shape this round closes: at Quarter's real fit zoom -## (7.5, live capture), MOUTH_RING_RADIUS (5.0) divides to 0.667 -## canvas-local — BELOW its own paired stroke (1.5/7.5 floored to 1.0 via -## zoom_compensated_stroke_width) — pinned directly, not just asserted. -## zoom_compensated_ring_radius() must instead return stroke * 2.0 (the -## floor), never the sub-floor raw division result that produced the blob. -func test_zoom_compensated_ring_radius_quarter_mouth_ring_hits_the_floor() -> void: - var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed - var raw_radius := 5.0 # MOUTH_RING_RADIUS - var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom) - var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom) - assert_float(unfloored).override_failure_message( - "regression pin: the OLD unfloored radius must be AT/BELOW the paired" - + " stroke at this zoom — this is the exact numeric shape of the blob bug" - ).is_less_equal(stroke) - var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( - raw_radius, stroke, view_zoom - ) - assert_float(floored).override_failure_message( - "zoom_compensated_ring_radius() must clamp to stroke * 2.0 (the floor)," - + " not the sub-floor unfloored value that renders as a solid blob" - ).is_equal_approx(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, 0.0001) - - -## Regression pin (the stroke-width suite's own "effective on-screen value" -## shape, applied to the radius/stroke RATIO instead of an absolute value): -## for the mouth ring AND halo (the two draw_arc() ring markers in this -## cluster), at Quarter's real fit zoom, the floored radius must be AT LEAST -## RING_RADIUS_STROKE_MULTIPLIER times its own paired stroke — the actual -## geometric property that keeps the ring hollow, verified directly rather -## than just re-checking the numeric floor value in isolation. -func test_ring_radius_stays_at_least_the_multiplier_above_its_stroke_at_quarter_zoom() -> void: - var view_zoom := 7.5 - # (raw_radius_px, raw_stroke_px) pairs — the mouth ring and halo's own - # literal call-site arguments in _draw_mouth(). - for pair in [[5.0, 1.5], [8.0, 1.0]]: - var raw_radius: float = pair[0] - var raw_stroke: float = pair[1] - var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( - raw_stroke, view_zoom - ) - var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( - raw_radius, stroke, view_zoom - ) - assert_float(radius).override_failure_message( - ( - "radius %.3f must be at least %.1fx its paired stroke %.3f — a ratio" - + " below this rendered as a SOLID BLOB in the live A/B bracket," - + " never a hollow ring" - ) - % [radius, AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, stroke] - ).is_greater_equal(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER - 0.0001) - - -## At a LOW zoom (e.g. Region's tiny orbital fit, or any zoom where the -## naive radius is already well clear of the floor), the floor must NOT -## engage — matching zoom_compensated_stroke_width()'s own -## does-not-engage-at-low-zoom guarantee. Pins that this is a one-direction -## safety net, not a blanket override. -func test_zoom_compensated_ring_radius_does_not_engage_at_low_zoom() -> void: - var view_zoom := 0.0063 # Lendel's real orbital fit zoom - var raw_radius := 5.0 # MOUTH_RING_RADIUS - var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom) - var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( - raw_radius, stroke, view_zoom - ) - var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom) - assert_float(floored).override_failure_message( - "at a zoom where the naive radius is already far above the floor, the" - + " floor must be a no-op — identical to zoom_compensated_size()" - ).is_equal_approx(unfloored, 0.0001) - - -## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce -## infinity/NaN — same total-function guarantee as the stroke-width sibling. -func test_zoom_compensated_ring_radius_zero_zoom_does_not_blow_up() -> void: - var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, 0.0) - var result: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, stroke, 0.0) - assert_bool(is_finite(result)).override_failure_message( - "a degenerate zero view_zoom must not produce inf/NaN" - ).is_true() diff --git a/client/tests/test_atlas_window_nature_overlay.gd b/client/tests/test_atlas_window_nature_overlay.gd deleted file mode 100644 index 10d453427..000000000 --- a/client/tests/test_atlas_window_nature_overlay.gd +++ /dev/null @@ -1,579 +0,0 @@ -## T-1156 wave 1: tests for AtlasWindowNatureOverlay — the whole-body -## Layer-1 (river/basin/attractor) draw node on the zoom ladder. Covers -## request/response lifecycle (idempotent-per-body, staleness guard, decode -## tolerance for a missing river_class array) and the draw-gate wiring -## (rung/overlay-bar double-gate), NOT pixel-level draw output — the -## coordinate math itself is covered directly in -## test_atlas_window_geometry_nature.gd, matching test_atlas_window_overlay.gd's -## own "cache/lifecycle here, colorizer pixels elsewhere" split. -class_name TestAtlasWindowNatureOverlay -extends GdUnitTestSuite - -const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") - - -## Minimal viewer stub — AtlasWindowNatureOverlay only reaches the viewer -## through get_held_granularity_v2()/get_body_radius_km()/get_held_center()/ -## get_held_n()/get_cell_pixel_size()/get_view_zoom()/is_overlay_visible(), -## the same duck-typed-viewer precedent test_atlas_window_overlay.gd's -## _ViewerStub already establishes for AtlasWindowOverlay. get_view_zoom() -## added post-live-eyeball (coordinator finding, 2026-07-23): _draw() now -## reads it for the zoom-compensated marker-size fix. is_tile_mode()/ -## get_district_window()/get_tile_set() added for T-1172 (the water clip) — -## district_window/tile_set default to null/an empty stub, matching "no -## arrived composite data yet" (the fail-open case) unless a test sets them. -class _ViewerStub: - var held_granularity_v2: String = "Region" - var body_radius_km: float = 6371.0 - var held_center: Vector2i = Vector2i.ZERO - var held_n: int = 64 - var view_zoom: float = 1.0 - var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": false, "gen_attractors": false} - var tile_mode: bool = false - var district_window: Variant = null - var tile_set: Variant = null - - func get_held_granularity_v2() -> String: - return held_granularity_v2 - - func get_body_radius_km() -> float: - return body_radius_km - - func get_held_center() -> Vector2i: - return held_center - - func get_held_n() -> int: - return held_n - - func get_cell_pixel_size() -> float: - return 16.0 - - func get_view_zoom() -> float: - return view_zoom - - func is_overlay_visible(overlay_id: String) -> bool: - return bool(overlay_visibility.get(overlay_id, false)) - - func is_tile_mode() -> bool: - return tile_mode - - func get_district_window() -> Variant: - return district_window - - func get_tile_set() -> Variant: - return tile_set - - -## Bare tile-set stub — AtlasWindowNatureOverlay's water-clip lookup only -## reaches it through get_tiles(), matching AtlasWindowTileSet's own public -## surface (an Array of {"center": Vector2i, "window": Variant}). -class _TileSetStub: - var tiles: Array = [] - - func get_tiles() -> Array: - return tiles - - -static func _mock_layer1(river_class: Variant = null) -> Dictionary: - var rn: Dictionary = { - "river_cells": [[10, 10], [20, 20], [30, 30]], - "confluences": [[15, 15]], - "mouths": [[40, 40]], - } - if river_class != null: - rn["river_class"] = river_class - return { - "river_network": rn, - "drainage_basins": [{"basin_id": 1, "boundary": [[0, 0], [0, 10], [10, 10], [10, 0]]}], - "attractors": [{"position": [10, 10], "strength": 0.5, "attractor_type": "Oasis", "sub_biome": ""}], - "grid_w": 256, - "grid_h": 128, - } - - -static func _mock_response(body_id: String, layer1: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "layer1": layer1} - - -## Window-only responses (the OTHER shape SimBridge.atlas_layers_received -## carries, per AtlasWindowRequest's own test conventions) must be ignored — -## `layer1` is null on that envelope, matching atlas_response_from_raw()'s -## "only one of layer1/district_window populated per response" contract. -static func _mock_window_response(body_id: String) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": {"n": 32}, "layer1": null} - - -func _make_overlay(viewer: Variant = null) -> Variant: - var o = auto_free(AtlasWindowNatureOverlay.new(viewer if viewer != null else _ViewerStub.new())) - add_child(o) - return o - - -# ============================================================================= -# Request lifecycle -# ============================================================================= - - -func test_no_layer1_before_any_request() -> void: - var o = _make_overlay() - assert_that(o.get_layer1()).is_null() - - -func test_response_for_requested_body_is_adopted() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - var layer1: Dictionary = _mock_layer1() - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) - assert_that(o.get_layer1()).is_equal(layer1) - - -## Staleness guard: a response for a body this node never asked for (or -## navigated away from) must be ignored — same posture -## AtlasGenerationProxy.on_response()'s own body_id guard establishes. -func test_response_for_a_different_body_is_ignored() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - SimBridge.atlas_layers_received.emit(_mock_response("SomeOtherBody", _mock_layer1())) - assert_that(o.get_layer1()).is_null() - - -## A window-shaped response (the windowed DistrictWindowLayer envelope, -## `layer1` null) must be ignored outright — this node only ever adopts the -## whole-body Layer-1 envelope. -func test_window_only_response_is_ignored() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - SimBridge.atlas_layers_received.emit(_mock_window_response("GJ380c")) - assert_that(o.get_layer1()).is_null() - - -## request_layer1() for the SAME body id, after data has already arrived, -## must NOT clear the held data — a re-entrant enter_orbital() on the body -## already showing keeps drawing rivers instead of flashing them away. -func test_request_layer1_same_body_after_arrival_keeps_held_data() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - var layer1: Dictionary = _mock_layer1() - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) - o.request_layer1("GJ380c") - assert_that(o.get_layer1()).is_equal(layer1) - - -## request_layer1() for a DIFFERENT body id must clear the previous body's -## held data immediately — the old body's rivers must never draw over the -## new body's terrain during the in-flight gap. -func test_request_layer1_different_body_clears_stale_data() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", _mock_layer1())) - o.request_layer1("AnotherBody") - assert_that(o.get_layer1()).override_failure_message( - "switching bodies must clear the previous body's layer1 data immediately," - + " not just leave it drawn until the new response arrives" - ).is_null() - - -func test_request_layer1_empty_body_id_is_a_noop() -> void: - var o = _make_overlay() - o.request_layer1("") - SimBridge.atlas_layers_received.emit(_mock_response("", _mock_layer1())) - assert_that(o.get_layer1()).override_failure_message( - "an empty body_id must never be requested/adopted" - ).is_null() - - -# ============================================================================= -# Decode tolerance — layer1 without river_class (pre-T-1156 payload / the -# graceful-fallback empty-array case, Dudley's #[serde(default)] contract). -# ============================================================================= - - -## A response with NO river_class key at all (river_network dict omits it — -## the msgpack-decode equivalent of Dudley's serde default producing an -## empty Vec) must still be adopted without error; per-cell class then falls -## back to RIVER_CLASS_FALLBACK (TRUNK) at draw time, not a crash/decode failure. -func test_layer1_without_river_class_key_is_still_adopted() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - var layer1: Dictionary = _mock_layer1() # no river_class arg -> key absent - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) - assert_that(o.get_layer1()).is_equal(layer1) - assert_bool((o.get_layer1()["river_network"] as Dictionary).has("river_class")).is_false() - - -## An explicitly EMPTY river_class array (the actual wire shape Dudley's -## `#[serde(default)]` produces for a pre-T-1156 payload) must also decode -## without error and be adopted — the per-cell RIVER_CLASS_FALLBACK -## resolution this enables is exercised at draw time by -## test_atlas_window_nature_overlay_draw_smoke.gd (real-render smoke suite; -## draw_circle()/draw_rect() calls require a live render pass under this -## engine version — confirmed directly, matching -## test_atlas_window_overlay_draw_smoke.gd's own header doc on why a plain -## unit test cannot call `_draw()` outside one). -func test_layer1_with_empty_river_class_array_is_adopted() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - var layer1: Dictionary = _mock_layer1(PackedByteArray([])) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) - assert_that(o.get_layer1()).is_equal(layer1) - - -## No viewer at all (viewer == null, matching AtlasWindowOverlay's own -## "viewer == null -> return" guard convention) is a legal, inert state — -## _draw()'s null-viewer early-return (BEFORE any draw_*() call) is safe to -## call directly since it never reaches the engine's draw-context requirement. -func test_draw_with_null_viewer_is_a_noop() -> void: - var o = auto_free(AtlasWindowNatureOverlay.new(null)) - add_child(o) - o._draw() - assert_that(o.get_layer1()).is_null() - - -## grid_w/grid_h missing or zero (a malformed/degenerate layer1) must decode -## and adopt cleanly — _draw()'s own grid_w<=0/grid_h<=0 early-return (also -## BEFORE any draw_*() call) is exercised the same direct way. -func test_draw_with_zero_grid_dims_returns_before_any_draw_call() -> void: - var o = _make_overlay() - o.request_layer1("GJ380c") - var layer1: Dictionary = _mock_layer1() - layer1["grid_w"] = 0 - layer1["grid_h"] = 0 - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) - o._draw() # grid_w<=0 -> returns before touching the canvas — safe to call directly - assert_that(o.get_layer1()).is_not_null() - - -## PR #195 review (Tyre I1) regression pin: every stroke-WIDTH argument in -## _draw_attractor_shape() must route through the pre-compensated `px_w` -## param, never a raw numeric literal — Godot multiplies stroke widths by the -## canvas scale exactly like radii, so a raw `2.0` rasterizes at ~0.01px at -## the Region orbital fit zoom (the identical sub-pixel failure the dot/ring -## zoom compensation fixed, missed on glyph outlines in the first pass). The -## draw-smoke suite cannot gate this (its own header documents the vacuous- -## pass mode under X11 BadMatch), so this is a SOURCE-SCAN pin: parse the -## overlay script's _draw_attractor_shape body and assert no draw_arc/ -## draw_line call carries a bare numeric width literal. Crude but -## environment-independent, and it pins the exact regression class (someone -## reintroducing a literal width in a new glyph arm). -func test_attractor_shape_stroke_widths_are_never_raw_literals() -> void: - var src: String = ( - FileAccess.get_file_as_string("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") - ) - var fn_start := src.find("func _draw_attractor_shape(") - assert_that(fn_start).override_failure_message( - "_draw_attractor_shape must exist in atlas_window_nature_overlay.gd" - ).is_not_equal(-1) - var next_fn := src.find("\nfunc ", fn_start + 1) - var body := src.substr(fn_start, (next_fn - fn_start) if next_fn != -1 else -1) - var stroke_re := RegEx.new() - # A draw_arc/draw_line call whose FINAL (width) argument is a bare numeric - # literal: `, )` at call end. px_w-scaled forms - # (`px_w`, `2.0 * px_w`) do not match. - stroke_re.compile("draw_(arc|line)\\([^\\n]*,\\s*\\d+(\\.\\d+)?\\s*\\)") - var hits := stroke_re.search_all(body) - var offenders: Array[String] = [] - for hit in hits: - offenders.append(hit.get_string()) - assert_array(offenders).override_failure_message( - "raw numeric stroke width(s) in _draw_attractor_shape — route through" - + " px_w (PR #195 Tyre I1): %s" % [offenders] - ).is_empty() - - -# ============================================================================= -# T-1172 — the two-waterline clip. _is_drawn_water()/_district() are called -# directly (both are pure lookups with NO draw_*() call of their own — the -# _draw()-requires-a-live-render-pass constraint the smoke suite exists for -# does not apply to them), matching this file's own "call private helpers -# directly when they're the load-bearing unit" precedent -# (test_draw_with_zero_grid_dims_returns_before_any_draw_call() above already -# calls _draw() itself specifically because its early-return is BEFORE any -# draw call — same reasoning here, one level down). -# ============================================================================= - - -## A 4x4 District window (n=4, grid_side=4) centered on district (0,0), -## spanning [-2, 2) on both axes — cell (0,0) is water, everything else land. -## Mirrors test_atlas_window_water_clip.gd's own _mock_4x4_window() fixture -## shape (kept local here rather than shared — no cross-test-file import -## precedent in this cluster). -static func _mock_4x4_water_corner_window() -> Dictionary: - var morphology := PackedByteArray() - morphology.resize(16) - for i in range(16): - morphology[i] = 8 # AlluvialPlain — land - morphology[0] = 0 # OpenOcean — the single water cell, row 0 col 0 - return {"center": [0, 0], "n": 4, "granularity_v2": "District", "morphology": morphology} - - -func _ctx_for(viewer_stub: _ViewerStub) -> Dictionary: - return { - "grid_w": 256.0, - "grid_h": 128.0, - "radius_km": 0.0, # no-radius: 1 heightmap pixel = 1 district metre (simplest math) - "held_center": viewer_stub.held_center, - "held_n": viewer_stub.held_n, - "cell_px": 16.0, - "cols": 0, - "granularity_v2": viewer_stub.held_granularity_v2, - "view_zoom": viewer_stub.view_zoom, - } - - -## Single-window mode: a district position resolving to a LAND cell must not -## be clipped (_is_drawn_water() returns false — the dot draws). -func test_is_drawn_water_false_for_a_land_cell_single_window() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(1.5, 1.5), ctx)).override_failure_message( - "a district position over a LAND cell must not be clipped" - ).is_false() - - -## Single-window mode: a district position resolving to a WATER cell must be -## clipped (_is_drawn_water() returns true — the caller skips drawing). -func test_is_drawn_water_true_for_a_water_cell_single_window() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(-1.5, -1.5), ctx)).override_failure_message( - "a district position over a WATER (OpenOcean) cell must be clipped" - ).is_true() - - -## Tile mode: same land/water split, but the composite data arrives via -## get_tile_set().get_tiles() instead of get_district_window() — proving the -## clip predicate reaches BOTH path shapes, per the coordinator's explicit -## "both path shapes exercised" ask. -func test_is_drawn_water_works_in_tile_mode_land() -> void: - var stub := _ViewerStub.new() - stub.tile_mode = true - var ts := _TileSetStub.new() - ts.tiles = [{"center": Vector2i.ZERO, "window": _mock_4x4_water_corner_window()}] - stub.tile_set = ts - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(1.5, 1.5), ctx)).override_failure_message( - "tile mode: a district position over a LAND cell must not be clipped" - ).is_false() - - -func test_is_drawn_water_works_in_tile_mode_water() -> void: - var stub := _ViewerStub.new() - stub.tile_mode = true - var ts := _TileSetStub.new() - ts.tiles = [{"center": Vector2i.ZERO, "window": _mock_4x4_water_corner_window()}] - stub.tile_set = ts - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(-1.5, -1.5), ctx)).override_failure_message( - "tile mode: a district position over a WATER cell must be clipped" - ).is_true() - - -## Tyre's rule 5: NO arrived composite data at the queried position (single- -## window mode, window is null — the pre-arrival state) must FAIL OPEN — the -## clip is a presentation refinement, never a data gate. -func test_is_drawn_water_fails_open_with_no_composite_data_single_window() -> void: - var stub := _ViewerStub.new() - stub.district_window = null # nothing arrived yet - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(0.0, 0.0), ctx)).override_failure_message( - "no arrived composite data must fail OPEN (draw the dot), never clip" - ).is_false() - - -## Same fail-open guarantee in tile mode: no tile set at all (get_tile_set() -## returns null, matching the viewer's own pre-enter_orbital() state). -func test_is_drawn_water_fails_open_with_no_tile_set() -> void: - var stub := _ViewerStub.new() - stub.tile_mode = true - stub.tile_set = null - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(0.0, 0.0), ctx)).override_failure_message( - "no tile set at all must fail OPEN (draw the dot), never clip" - ).is_false() - - -## Fail-open ALSO covers "a tile set exists but no tile covers this position -## yet" (mid-progressive-arrival) — the coordinator's own "briefly-unclipped -## dot during progressive arrival is fine and self-heals" framing. -func test_is_drawn_water_fails_open_when_no_tile_covers_the_position() -> void: - var stub := _ViewerStub.new() - stub.tile_mode = true - var ts := _TileSetStub.new() - ts.tiles = [{"center": Vector2i(500, 500), "window": null}] # far away, unarrived - stub.tile_set = ts - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(0.0, 0.0), ctx)).is_false() - - -## Mouths must be SUPPRESSED (not snapped, not dimmed) on drawn water and -## render exactly as today on drawn land — this suite covers the SHARED -## predicate _draw_rivers() calls for river cells/confluences/mouths alike -## (_is_drawn_water() itself has no notion of "which feature type" — that's -## by design, per the ruling's "same predicate" wording for rule 3). A -## dedicated assertion here pins the WORDING intent (mouth-specific rule 3) -## even though the underlying mechanism is identical to the river-cell tests -## above — a future refactor that special-cases mouths differently should -## still trip this. -func test_mouth_position_on_water_is_suppressed_same_predicate_as_rivers() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(-1.9, -1.9), ctx)).override_failure_message( - "a mouth position over drawn water must resolve as clipped, via the SAME" - + " predicate river cells/confluences use — no separate snap/dim path" - ).is_true() - - -func test_mouth_position_on_land_is_not_suppressed() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool(o._is_drawn_water(Vector2(1.9, 1.9), ctx)).override_failure_message( - "a mouth position over drawn land must render exactly as today (not clipped)" - ).is_false() - - -# ============================================================================= -# T-1170 Ruling 3g/5a: _segment_touches_drawn_water() — the CHORD SEGMENT -# clip rule (both endpoints + midpoint), replacing the old per-point-only -# clip for the skeleton-chord draw path. The water cell is (0,0) in district -# space, per _mock_4x4_water_corner_window()'s own doc — spans roughly -# [-0.5, 0.5) x [-0.5, 0.5) at this fixture's district granularity. -# ============================================================================= - - -## Both endpoints on land, entirely away from the water cell — no clip. -func test_segment_touches_drawn_water_false_when_fully_on_land() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool( - o._segment_touches_drawn_water(Vector2(1.0, 1.0), Vector2(1.9, 1.9), ctx) - ).override_failure_message( - "a segment entirely on land (both endpoints, and therefore its" - + " midpoint) must not be clipped" - ).is_false() - - -## Either endpoint alone on water clips the whole segment. -func test_segment_touches_drawn_water_true_when_an_endpoint_is_on_water() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - assert_bool( - o._segment_touches_drawn_water(Vector2(-1.9, -1.9), Vector2(1.9, 1.9), ctx) - ).override_failure_message( - "a segment with EITHER endpoint over drawn water must be clipped" - ).is_true() - - -## The decision this rule specifically exists to catch (Ruling 3g's ask, "pick -## the visually cleaner rule, document it, test it"): BOTH endpoints on land, -## on opposite sides of the water cell, with the MIDPOINT landing inside it — -## an endpoints-only rule would miss this entirely (a chord visibly crossing -## open water with neither end clipped). The midpoint sample must catch it. -func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> void: - var stub := _ViewerStub.new() - stub.district_window = _mock_4x4_water_corner_window() - var o = _make_overlay(stub) - var ctx: Dictionary = _ctx_for(stub) - # The water cell is (col=0, row=0), spanning district [-2,-1) x [-2,-1) in - # this n=4/grid_side=4 fixture (1:1 district-to-cell mapping). Pick - # endpoints that EACH resolve to a DIFFERENT LAND cell adjacent to the - # water corner — (-1.99, -0.9) resolves to (col=0, row=1), land; (-0.9, - # -1.99) resolves to (col=1, row=0), land — but their MIDPOINT - # (-1.445, -1.445) falls squarely inside the water cell (col=0, row=0). - # Verified numerically, not eyeballed (see the two sanity asserts below). - var from_district := Vector2(-1.99, -0.9) - var to_district := Vector2(-0.9, -1.99) - # Sanity: neither endpoint alone is clipped (both resolve to LAND cells) - # — isolates the midpoint as the ONLY reason the segment clips below. - assert_bool(o._is_drawn_water(from_district, ctx)).override_failure_message( - "test setup invariant: the FROM endpoint alone must resolve to land" - ).is_false() - assert_bool(o._is_drawn_water(to_district, ctx)).override_failure_message( - "test setup invariant: the TO endpoint alone must resolve to land" - ).is_false() - assert_bool(o._segment_touches_drawn_water(from_district, to_district, ctx)).override_failure_message( - "a segment whose ENDPOINTS are both on land but whose MIDPOINT lands" - + " on drawn water must still be clipped — this is the exact failure" - + " mode an endpoints-only rule would miss (Ruling 3g's ask)" - ).is_true() - - -# ============================================================================= -# T-1170 Ruling 5b (B3): _draw_course_path() early-return gating — the SAME -# "call the function directly when its early-return happens BEFORE any -# draw_*() call" precedent test_draw_with_null_viewer_is_a_noop() and -# test_draw_with_zero_grid_dims_returns_before_any_draw_call() already -# establish. Every case below returns before _draw_one_course() is ever -# reached, so calling _draw_course_path() directly (no SubViewport/render -# context) is safe. This is a SEPARATE data source/gate from the Layer-1 -# skeleton path above — none of these tests touch _layer1 at all. -# ============================================================================= - - -## The overlay-bar "gen_rivers" toggle gates the course path too — the SAME -## toggle the skeleton path uses (one player-facing "rivers" control covers -## both presentation surfaces, per the ruling). -func test_draw_course_path_returns_before_any_draw_when_gen_rivers_is_off() -> void: - var stub := _ViewerStub.new() - stub.overlay_visibility["gen_rivers"] = false - stub.district_window = { - "n": 64, "granularity_v2": "District", - "courses": [{"class": 2, "points": [[0, 0], [100, 0]], "terminus": "None"}], - } - var o = _make_overlay(stub) - o._draw_course_path() # must return before draw_polyline() — no crash outside a render context - - -## No district window at all (single-window mode hasn't arrived yet) — the -## course path must return cleanly, not crash on a null window read. -func test_draw_course_path_returns_before_any_draw_when_no_window() -> void: - var stub := _ViewerStub.new() - stub.district_window = null - var o = _make_overlay(stub) - o._draw_course_path() - - -## Ruling 3h decode tolerance: a window WITHOUT a `courses` key at all (the -## old/pre-A2 payload shape) must draw NOTHING at District/Quarter except -## mouths-on-land from the skeleton (that's the OTHER path's job) — this -## path itself must simply return, not error or fall back to a dot-scatter. -func test_draw_course_path_missing_courses_field_is_tolerated() -> void: - var stub := _ViewerStub.new() - stub.district_window = {"n": 64, "granularity_v2": "District"} # no "courses" key - var o = _make_overlay(stub) - o._draw_course_path() - - -## An explicitly present but EMPTY courses array must also be tolerated -## cleanly (the loop simply iterates zero times). -func test_draw_course_path_empty_courses_array_is_tolerated() -> void: - var stub := _ViewerStub.new() - stub.district_window = {"n": 64, "granularity_v2": "District", "courses": []} - var o = _make_overlay(stub) - o._draw_course_path() - - -## A `courses` field that is present but the WRONG TYPE (not an Array — e.g. -## a malformed/corrupted payload) must be tolerated the same way as a -## missing field, not crash attempting to iterate a non-Array. -func test_draw_course_path_non_array_courses_field_is_tolerated() -> void: - var stub := _ViewerStub.new() - stub.district_window = {"n": 64, "granularity_v2": "District", "courses": "not an array"} - var o = _make_overlay(stub) - o._draw_course_path() diff --git a/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd b/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd deleted file mode 100644 index 3e47b6973..000000000 --- a/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd +++ /dev/null @@ -1,299 +0,0 @@ -## T-1156 wave 1: a REAL draw smoke test for AtlasWindowNatureOverlay, -## matching test_atlas_window_overlay_draw_smoke.gd's established pattern — -## `draw_circle()`/`draw_rect()`/`draw_arc()`/`draw_colored_polygon()` calls -## require a live render pass under this engine version (confirmed directly: -## a plain unit test calling `_draw()` outside one throws "Drawing is only -## allowed inside this node's `_draw()`..."). Render into a REAL SubViewport, -## force a settle wait, and assert visible non-background pixels — proving -## the river-dot/mouth-ring/basin-polygon/attractor-glyph draw calls actually -## paint, not just that the state feeding them is correct (that half is -## test_atlas_window_nature_overlay.gd's job). -## -## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under -## `tests/run-godot`'s hardcoded `--headless`**, same posture/rationale as -## test_atlas_window_overlay_draw_smoke.gd's own header doc (dummy driver, -## no GPU texture output — SubViewport.get_texture().get_image() returns an -## all-zero/unusable image under it). -## -## To actually exercise this file's assertions, run it with a real driver: -## godot4 --display-driver x11 --rendering-driver opengl3 \ -## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ -## -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd -## -## **Known limitation, verified directly (2026-07-23 zoom-compensation fix -## verification):** the shared _BackgroundRect/_render_to_image() harness -## (copied from test_atlas_window_overlay_draw_smoke.gd) occasionally fails -## to composite the COLOR_BG fill at all under a real X11/opengl3 run in this -## environment (sampled pixels read (0,0,0,0), not COLOR_BG — an unrelated, -## intermittent X11/SubViewport timing issue, confirmed via an XServer -## "BadMatch" warning in that same run's log). When that happens EVERY pixel -## reads as "non-background" regardless of what this overlay actually draws, -## making a low MIN_NON_BACKGROUND_FRACTION threshold pass VACUOUSLY (it -## would pass even with zoom-compensation deliberately broken — confirmed by -## direct revert-test). The terrain sibling suite is accidentally immune to -## this (its checkerboard composite covers most of the frame regardless of -## background correctness); this suite's SPARSE markers are not. **The -## reliable, environment-independent regression gate for the zoom- -## compensation fix is therefore the pure-function suite in -## test_atlas_window_geometry_nature.gd** (zoom_compensated_size()'s own -## tests) — this smoke suite is a supplementary "does it actually paint" -## check when the harness cooperates, not the primary gate. -class_name TestAtlasWindowNatureOverlayDrawSmoke -extends GdUnitTestSuite - -const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") - -const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const) -const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512) -const MIN_NON_BACKGROUND_FRACTION: float = 0.0005 # river dots are sparse — a low bar is honest here - -const SKIP_REASON: String = ( - "no real rendering driver (dummy/headless) — run with e.g." - + " `godot4 --display-driver x11 --rendering-driver opengl3" - + " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c" - + " -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd` to exercise this file" -) - - -## Matches test_atlas_window_overlay_draw_smoke.gd's own detection exactly. -static func _dummy_renderer_active() -> bool: - return DisplayServer.get_name() == "headless" - - -## Same duck-typed viewer contract as test_atlas_window_nature_overlay.gd's -## _ViewerStub, minus the SimBridge-signal machinery this smoke test doesn't -## need (layer1 is injected directly via _layer1, not through a response). -## view_zoom MUST be kept in sync with whatever zoom _render_to_image() is -## called with — the whole point of this smoke suite (post-live-eyeball, -## coordinator finding 2026-07-23) is proving markers stay visible at the -## REAL orbital fit zoom, not an artificially large test zoom that would -## mask the zoom-compensation bug the fix addresses. is_tile_mode()/ -## get_district_window()/get_tile_set() added for T-1172 (the water clip) — -## defaults to single-window mode with NO arrived composite (district_window -## null), the fail-open case, so these smoke tests keep drawing every marker -## exactly as before the clip existed (this file's own job is proving the -## draw calls paint pixels at all, not exercising the clip's water-detection -## branch — that's test_atlas_window_nature_overlay.gd's job). -class _ViewerStub: - var held_granularity_v2: String = "Region" - var body_radius_km: float = 6371.0 - var held_center: Vector2i = Vector2i.ZERO - var held_n: int = 64 - var view_zoom: float = 1.0 - var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": true, "gen_attractors": true} - var tile_mode: bool = false - var district_window: Variant = null - var tile_set: Variant = null - - func get_held_granularity_v2() -> String: - return held_granularity_v2 - - func get_body_radius_km() -> float: - return body_radius_km - - func get_held_center() -> Vector2i: - return held_center - - func get_held_n() -> int: - return held_n - - func get_cell_pixel_size() -> float: - return 16.0 - - func get_view_zoom() -> float: - return view_zoom - - func is_overlay_visible(overlay_id: String) -> bool: - return bool(overlay_visibility.get(overlay_id, false)) - - func is_tile_mode() -> bool: - return tile_mode - - func get_district_window() -> Variant: - return district_window - - func get_tile_set() -> Variant: - return tile_set - - -## A dense scatter of river cells spanning the whole held window's canvas -## footprint (not clustered in one corner) — a genuine "does the composite -## draw something visible across the frame" check, same intent as the -## terrain smoke test's checkerboard morphology spread. -static func _mock_layer1_dense() -> Dictionary: - var river_cells: Array = [] - var river_class: PackedByteArray = PackedByteArray() - for i in range(40): - river_cells.append([i * 3, i * 3]) - river_class.append(2) # trunk — visible at every rung this suite exercises - return { - "river_network": { - "river_cells": river_cells, - "river_class": river_class, - "confluences": [[60, 60]], - "mouths": [[120, 120]], - }, - "drainage_basins": [ - {"basin_id": 1, "boundary": [[0, 0], [0, 40], [40, 40], [40, 0]]}, - ], - "attractors": [ - {"position": [80, 80], "strength": 0.9, "attractor_type": "Oasis", "sub_biome": ""}, - ], - "grid_w": 256, - "grid_h": 128, - } - - -## Same _BackgroundRect/_render_to_image/_non_background_fraction shared -## rendering infrastructure as test_atlas_window_overlay_draw_smoke.gd — -## duplicated rather than imported since gdUnit4 test suites are not -## typically composed via inheritance in this codebase (no precedent for a -## shared test-infra base class in client/tests/), and the block is small. -class _BackgroundRect extends Node2D: - var fill_color: Color = Color.BLACK - var fill_size: Vector2 = Vector2.ZERO - - func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color) - - -func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image: - var sub_viewport := SubViewport.new() - sub_viewport.size = VIEWPORT_SIZE - sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS - sub_viewport.transparent_bg = false - add_child(sub_viewport) - auto_free(sub_viewport) - - var bg := _BackgroundRect.new() - bg.fill_color = COLOR_BG - bg.fill_size = Vector2(VIEWPORT_SIZE) - sub_viewport.add_child(bg) - bg.queue_redraw() - - var canvas := Node2D.new() - canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 - canvas.scale = Vector2(zoom, zoom) - sub_viewport.add_child(canvas) - canvas.add_child(overlay) - overlay.queue_redraw() - - for _i in range(6): - await get_tree().process_frame - - return sub_viewport.get_texture().get_image() - - -static func _non_background_fraction(image: Image) -> float: - var w: int = image.get_width() - var h: int = image.get_height() - if w <= 0 or h <= 0: - return 0.0 - var total: int = w * h - var differing: int = 0 - var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0) - for y in range(h): - for x in range(w): - var px: Color = image.get_pixel(x, y) - var px_rgb: Color = Color(px.r, px.g, px.b, 1.0) - if not px_rgb.is_equal_approx(bg_rgb): - differing += 1 - return float(differing) / float(total) - - -## Region rung, every toggle on: rivers + confluence + mouth + basin fill + -## attractor glyph must all draw SOMETHING — the "does the composite actually -## paint" proof the state-only unit suite cannot provide. -func test_region_rung_draws_visible_pixels() -> void: - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay := AtlasWindowNatureOverlay.new() - var stub := _ViewerStub.new() - overlay.viewer = stub - overlay._layer1 = _mock_layer1_dense() - overlay._requested_body_id = "SmokeBody" - - # Zoom chosen so the held window's canvas footprint (held_n * cell_px = - # 64 * 16 = 1024 units) comfortably fills the 512px viewport. - var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size()) - stub.view_zoom = zoom - var image: Image = await _render_to_image(overlay, zoom) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "the Region-rung nature composite (rivers + confluence + mouth + basin +" - + " attractor, every toggle on) must render VISIBLE non-background pixels —" - + " got only %.4f%% of the frame differing from COLOR_BG" - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) - - -## District rung: only trunk rivers (class 2, all cells in the dense fixture -## are trunk) + the mouth carve-out draw; basins/attractors/confluences are -## rung-gated off. Still must produce visible pixels — proving the "fade -## down, don't vanish" posture actually leaves SOMETHING on screen. -func test_district_rung_still_draws_visible_pixels() -> void: - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay := AtlasWindowNatureOverlay.new() - var stub := _ViewerStub.new() - stub.held_granularity_v2 = "District" - overlay.viewer = stub - overlay._layer1 = _mock_layer1_dense() - overlay._requested_body_id = "SmokeBody" - - var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size()) - stub.view_zoom = zoom - var image: Image = await _render_to_image(overlay, zoom) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "the District-rung composite (trunk rivers + mouth carve-out only) must" - + " still render VISIBLE non-background pixels — got only %.4f%% of the" - + " frame differing from COLOR_BG" - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) - - -## Coordinator live-eyeball regression (2026-07-23): the ORBITAL TILE MOSAIC -## rest state's REAL fit zoom on a Lendel-scale body (~0.0063, confirmed -## directly via a live drive script — held_n=19139 districts, -## cell_px=16, a ~1600px viewport) — NOT the comfortable ~0.5 zoom the -## sibling test above uses. Before the zoom-compensation fix, this exact -## zoom magnitude produced ZERO visible river/mouth pixels (a 2.2px trunk -## dot rasterized at ~0.014 screen px) despite RVR being on and the policy -## table correctly returning full Region visibility — the captures showed -## terrain-only with literally nothing drawn. Pins the regression at -## production scale, not a toy zoom that could accidentally still pass. -func test_orbital_scale_zoom_still_draws_visible_pixels() -> void: - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay := AtlasWindowNatureOverlay.new() - var stub := _ViewerStub.new() - stub.held_n = 19139 # Lendel's own raw circumference, live drive script - stub.view_zoom = 0.0063 # Lendel's own live orbital fit zoom - overlay.viewer = stub - overlay._layer1 = _mock_layer1_dense() - overlay._requested_body_id = "SmokeBody" - - var image: Image = await _render_to_image(overlay, stub.view_zoom) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "at Lendel's REAL orbital fit zoom (~0.0063), the Region-rung nature" - + " composite must still render VISIBLE non-background pixels — got only" - + " %.4f%% of the frame differing from COLOR_BG. This is exactly the" - + " coordinator's live-eyeball finding: uncompensated screen-space marker" - + " sizes get multiplied by the canvas's own zoom transform, vanishing" - + " sub-pixel at the orbital rest state's tiny fit zoom." - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_atlas_window_overlay.gd b/client/tests/test_atlas_window_overlay.gd deleted file mode 100644 index d7b4c5b93..000000000 --- a/client/tests/test_atlas_window_overlay.gd +++ /dev/null @@ -1,399 +0,0 @@ -## T-1145 item 3 (interim presentation, pending the T-1143 design pass): -## tests for AtlasWindowOverlay's smoothed-composite texture rebuild cache — -## the "rebuild ONLY when window/overlay/tint inputs change, not per frame" -## requirement. Does not test the actual PIXEL CONTENT of the built texture -## (that content is exactly _cell_color()/_apply_glaciation(), already -## covered by test_atlas_window_colors.gd's colorizer tests — this file is -## about WHEN a rebuild happens, not what color a given cell produces). -class_name TestAtlasWindowOverlay -extends GdUnitTestSuite - - -static func _mock_window(n: int = 2) -> Dictionary: - return { - "center": [0, 0], - "n": n, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - - -## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through -## get_district_window()/is_overlay_visible()/get_cell_pixel_size()/ -## is_tile_mode(), so a bare stub with just those methods is a legitimate -## "viewer" for these tests, matching the duck-typed-viewer precedent this -## whole overlay cluster already relies on (atlas_overlay_bar.gd/ -## atlas_legend_panel.gd). is_tile_mode() always returns false — this suite -## covers the single-window composite-cache path only; the tile mosaic path -## is covered separately by test_atlas_window_tile_set.gd + the viewer's own -## is_tile_mode()-branching tests. -class _ViewerStub: - var window: Variant = null - var active_overlay: String = "" - # T-1161: the viewer's currently-HELD rung tag — added alongside the - # per-rung filter tests below. Not read by AtlasWindowOverlay today (the - # overlay trusts each window dict's OWN echoed granularity_v2, per - # cell_grid_side_for_window()'s precedent) but a real AtlasWindowViewer - # exposes get_held_granularity_v2() (T-1153), so the stub carries it too - # for parity with the real duck-typed interface. - var held_granularity_v2: String = "District" - - func get_district_window() -> Variant: - return window - - func is_overlay_visible(overlay_id: String) -> bool: - return overlay_id == active_overlay - - func get_cell_pixel_size() -> float: - return 16.0 - - func is_tile_mode() -> bool: - return false - - func get_held_granularity_v2() -> String: - return held_granularity_v2 - - -## T-1161 reframe: COMPOSITE_SMOOTH is now Axis 1 of TWO independent axes -## (see the file header doc) — the texture-vs-flat-rects PIPELINE choice. -## This assertion survives unchanged: the composite is still a TEXTURE at -## every rung. Axis 2 (which FILTER that texture samples with) is now a -## per-rung runtime decision covered separately below by the -## _filter_for_granularity_v2() tests — it is no longer bundled into this -## compile-time const. -func test_composite_smooth_defaults_true() -> void: - assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message( - "T-1145 item 3 ships the smoothed composite as the DEFAULT presentation" - ).is_true() - - -## A fresh overlay with no draw yet has never built a texture — the cache -## starts empty. -func test_no_texture_before_first_draw() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - assert_that(o._cached_texture).is_null() - - -## First _rebuild_texture_if_needed() call for a real window builds a texture. -func test_rebuild_builds_a_texture_on_first_call() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var window: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window, 2, "") - assert_that(o._cached_texture).override_failure_message( - "the first rebuild call for a real window must produce a texture" - ).is_not_null() - - -## Calling _rebuild_texture_if_needed() AGAIN with the SAME window object -## (same reference) and the same active toggle must NOT rebuild — the exact -## same ImageTexture instance survives (reference equality, not just -## "another texture that happens to look the same"). -func test_rebuild_is_a_noop_when_window_and_toggle_are_unchanged() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var window: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window, 2, "") - var first_texture: ImageTexture = o._cached_texture - - o._rebuild_texture_if_needed(window, 2, "") - - assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message( - "an unchanged (window, active_toggle) pair must reuse the SAME texture" - + " object, not rebuild an equivalent-but-new one" - ).is_true() - - -## A DIFFERENT window object (even with identical field VALUES) — matching -## what a fresh server response always is, a new Dictionary — MUST trigger a -## rebuild. This is the reference-vs-value distinction the class doc calls -## out explicitly (is_same(), not a deep compare). -func test_rebuild_fires_for_a_different_window_object_with_same_values() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var window_a: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window_a, 2, "") - var first_texture: ImageTexture = o._cached_texture - - # A structurally-IDENTICAL but DISTINCT Dictionary object — the exact - # shape a second server response for the same window content would be. - var window_b: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window_b, 2, "") - - assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message( - "a new window object (even with identical field values) must trigger a" - + " fresh rebuild — the cache key is REFERENCE identity, not value equality" - ).is_false() - - -## Changing the active toggle overlay (same window object) must ALSO trigger -## a rebuild — temp/moisture/veg/base each read different colors per cell. -func test_rebuild_fires_when_active_toggle_changes() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var window: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window, 2, "") - var first_texture: ImageTexture = o._cached_texture - - o._rebuild_texture_if_needed(window, 2, "gen_dw_temp") - - assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message( - "switching the active toggle overlay must trigger a rebuild — the SAME" - + " window's cells read different colors under a different toggle" - ).is_false() - - -## Panning/zooming (which redraw this node constantly via _apply_transform()) -## never touches window/overlay state — repeated rebuild CALLS with identical -## inputs (simulating many redraws while nothing about the DATA changed) must -## all be no-ops after the first, confirming the "not per frame" requirement -## end to end, not just for a single repeat. -func test_repeated_rebuild_calls_with_unchanged_inputs_all_reuse_the_same_texture() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var window: Dictionary = _mock_window() - o._rebuild_texture_if_needed(window, 2, "") - var first_texture: ImageTexture = o._cached_texture - - for _i in range(20): # 20 simulated redraws (pan/zoom frames) - o._rebuild_texture_if_needed(window, 2, "") - - assert_bool(is_same(o._cached_texture, first_texture)).override_failure_message( - "20 repeated rebuild calls with unchanged inputs must never touch the cache" - ).is_true() - - -## The overlay's real _draw() entry point (via the smoothed path) produces a -## texture through the SAME _ViewerStub duck-typed interface every other -## caller in this cluster uses — an end-to-end sanity check that _draw() -## actually reaches _rebuild_texture_if_needed() for a real window, not just -## that the helper works in isolation. -func test_draw_builds_a_texture_through_the_viewer_stub() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var stub := _ViewerStub.new() - stub.window = _mock_window() - o.viewer = stub - o._draw() - assert_that(o._cached_texture).is_not_null() - - -# ============================================================================= -# T-1152/T-1153: cell_grid_side_for_window() — the district-extent-vs- -# derived-cell-grid split every rung's response now carries. -# ============================================================================= - - -## District (the default/omitted tag): cell_grid_side == n, unchanged from -## the pre-T-1152 identity mapping. -func test_cell_grid_side_for_window_district_matches_n() -> void: - var window: Dictionary = {"n": 32, "granularity_v2": "District"} - assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32) - - -## Quarter: 4x MORE cells than districts (WINDOW_GRANULARITY_QUARTER). -func test_cell_grid_side_for_window_quarter_multiplies_by_four() -> void: - var window: Dictionary = {"n": 32, "granularity_v2": "Quarter"} - assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(128) - - -## Region: FAR FEWER cells than districts — round(n/100), matching -## WindowGranularity::cell_grid_side's own Region branch exactly (the -## "inversion" the server doc calls out: finer rungs multiply, Region divides). -func test_cell_grid_side_for_window_region_divides_by_districts_per_region() -> void: - var window: Dictionary = {"n": 6400, "granularity_v2": "Region"} - assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(64) - - -## A Region window smaller than one region (n < 100) must still derive a -## minimum 1x1 cell grid, never 0 — matching the server's `.max(1)`. -func test_cell_grid_side_for_window_region_minimum_is_one() -> void: - var window: Dictionary = {"n": 50, "granularity_v2": "Region"} - assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(1) - - -## Missing granularity_v2 (an old-shape response) falls back to District — -## matching the server's own "unknown -> District" posture at every -## resolution boundary. -func test_cell_grid_side_for_window_missing_tag_falls_back_to_district() -> void: - var window: Dictionary = {"n": 32} - assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32) - - -## T-1152/T-1153, design doc §6 encoding continuity: a Region-rung window (a -## FAR SPARSER cell grid — one region cell spans 100 districts) renders -## through the EXACT SAME _draw()/_rebuild_texture_if_needed() path as -## District — no separate branch, no crash reading past the (much smaller) -## per-cell arrays. This is the direct "same colorizer family at every rung" -## behavioral test the ticket asks for. -func test_draw_builds_a_texture_for_a_region_rung_window() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var stub := _ViewerStub.new() - # n=200 districts -> cell_grid_side = round(200/100) = 2 -> 4 cells, - # matching the 4-entry per-cell arrays below (same shape _mock_window() - # uses, just at Region's district-to-cell ratio). - stub.window = { - "center": [0, 0], - "n": 200, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - o.viewer = stub - o._draw() - assert_that(o._cached_texture).override_failure_message( - "a Region-rung window must render through the same composite path as District" - ).is_not_null() - assert_int(o._cached_texture.get_width()).override_failure_message( - "the built texture's resolution must be the DERIVED cell-grid side (2)," - + " not the window's district extent (200)" - ).is_equal(2) - - -## §6 "no mode flip" acceptance criterion, restated at the texture-cache -## level: swapping from a District-rung window to a Region-rung window at a -## NEW window object (the progressive-refinement swap) must still go through -## a single rebuild call producing a fresh texture — not a crash, not a -## silently-stale texture sized for the wrong rung. -func test_rebuild_handles_a_rung_swap_from_district_to_region() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var district_window: Dictionary = _mock_window() # n=2, District, 4 cells - o._rebuild_texture_if_needed( - district_window, AtlasWindowOverlay.cell_grid_side_for_window(district_window), "" - ) - assert_int(o._cached_texture.get_width()).is_equal(2) - - var region_window: Dictionary = { - "center": [0, 0], - "n": 200, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - o._rebuild_texture_if_needed( - region_window, AtlasWindowOverlay.cell_grid_side_for_window(region_window), "" - ) - assert_int(o._cached_texture.get_width()).override_failure_message( - "a rung swap must rebuild at the NEW rung's derived cell-grid resolution" - ).is_equal(2) # region_window's cell_grid_side is also 2 here (200/100) — same size, different data - - -# ============================================================================= -# T-1161: _filter_for_granularity_v2() — the per-rung sampling filter policy -# (Araminta's ruling: Region incl. the orbital tile mosaic -> NEAREST; -# District/Quarter -> LINEAR; no hysteresis, no px-per-cell threshold, keyed -# purely on rung IDENTITY). -# ============================================================================= - - -## Region is the sparse rung the ruling targets — GPU bilinear blending at -## 204.8 km/cell reads as smoothing-over-absence, so it samples NEAREST. -func test_filter_for_granularity_v2_region_is_nearest() -> void: - assert_int(AtlasWindowOverlay._filter_for_granularity_v2("Region")).override_failure_message( - "Region must sample TEXTURE_FILTER_NEAREST — dense-enough rungs get LINEAR," - + " Region is the sparse one the ruling targets" - ).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) - - -## District is dense enough that the bilinear blend reads as texture, not as -## papering over sparse data — LINEAR is earned. -func test_filter_for_granularity_v2_district_is_linear() -> void: - assert_int(AtlasWindowOverlay._filter_for_granularity_v2("District")).override_failure_message( - "District must sample TEXTURE_FILTER_LINEAR" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) - - -## Quarter (4x MORE cells than District) is denser still — also LINEAR. -func test_filter_for_granularity_v2_quarter_is_linear() -> void: - assert_int(AtlasWindowOverlay._filter_for_granularity_v2("Quarter")).override_failure_message( - "Quarter must sample TEXTURE_FILTER_LINEAR" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) - - -## An unrecognized/empty tag must NEVER be trusted into the crisp NEAREST -## treatment — mirrors cell_grid_side_for_window()'s own "unknown -> District" -## fallback posture, failing toward the already-shipped LINEAR look rather -## than an unintended NEAREST for a wire shape this code doesn't recognize. -func test_filter_for_granularity_v2_unknown_falls_back_to_linear() -> void: - assert_int(AtlasWindowOverlay._filter_for_granularity_v2("")).override_failure_message( - "an empty/unrecognized granularity_v2 tag must fall back to LINEAR, never NEAREST" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) - assert_int(AtlasWindowOverlay._filter_for_granularity_v2("SomeFutureRung")).override_failure_message( - "an unrecognized granularity_v2 tag must fall back to LINEAR, never NEAREST" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) - - -## Integration-shaped: a Region-rung window dict, drawn through the real -## _draw() entry point via the _ViewerStub duck-typed interface (same -## end-to-end shape as test_draw_builds_a_texture_for_a_region_rung_window() -## above), must drive the NODE's own texture_filter property to NEAREST — -## not just the helper function in isolation. -func test_draw_sets_node_texture_filter_to_nearest_for_region_window() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var stub := _ViewerStub.new() - stub.held_granularity_v2 = "Region" - stub.window = { - "center": [0, 0], - "n": 200, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - o.viewer = stub - o._draw() - assert_int(o.texture_filter).override_failure_message( - "a Region-rung window must drive the node's texture_filter to NEAREST after a draw" - ).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) - - -## The District-rung counterpart of the above — confirms the node's -## texture_filter lands on LINEAR (not left over from a previous NEAREST -## draw, and not defaulting to NEAREST) for the dense rung. -func test_draw_sets_node_texture_filter_to_linear_for_district_window() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var stub := _ViewerStub.new() - stub.window = _mock_window() # District (the default/omitted tag) - o.viewer = stub - o._draw() - assert_int(o.texture_filter).override_failure_message( - "a District-rung window must drive the node's texture_filter to LINEAR after a draw" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) - - -## A rung SWAP (Region -> District, on the SAME node) must flip texture_filter -## along with it — confirms the property is recomputed every draw, not -## sticky from the first rung the node ever rendered. -func test_draw_flips_node_texture_filter_on_a_rung_swap() -> void: - var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new()) - var stub := _ViewerStub.new() - stub.window = { - "center": [0, 0], - "n": 200, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - o.viewer = stub - o._draw() - assert_int(o.texture_filter).is_equal(CanvasItem.TEXTURE_FILTER_NEAREST) - - stub.window = _mock_window() # swap to District - o._draw() - assert_int(o.texture_filter).override_failure_message( - "swapping to a District-rung window must flip texture_filter to LINEAR," - + " not leave it stuck at the previous rung's NEAREST" - ).is_equal(CanvasItem.TEXTURE_FILTER_LINEAR) diff --git a/client/tests/test_atlas_window_overlay_draw_smoke.gd b/client/tests/test_atlas_window_overlay_draw_smoke.gd deleted file mode 100644 index 0d1eb1747..000000000 --- a/client/tests/test_atlas_window_overlay_draw_smoke.gd +++ /dev/null @@ -1,407 +0,0 @@ -## Live round 4: a REAL draw smoke test — the "does anything draw at all" -## gap has now bitten twice (round 4's tile-mosaic coordinate bug AND its -## per-tile-texture-lifetime bug, both invisible to test_atlas_window_overlay.gd's -## existing suite, which only asserts on the CACHE FIELDS being populated — -## never on an actual composited pixel). This file closes that gap -## structurally: render AtlasWindowOverlay into a REAL SubViewport, force a -## GPU sync, grab the rendered Image, and assert a meaningful fraction of -## pixels differ from the background color — for BOTH the single-window path -## (a) and the tile-mosaic path (b), matching the coordinator's explicit ask. -## -## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under -## `tests/run-godot`'s hardcoded `--headless`** (dummy driver, no GPU texture -## output; confirmed directly: SubViewport.get_texture().get_image() returns -## an all-zero/unusable image under it). This matters beyond "the assertions -## are meaningless there": the push gate runs the FULL suite through -## `tests/run-godot --headless` for every push, for everyone — a loud FAILURE -## here would bounce every future push project-wide, not just report a local -## false negative. Every test below carries the gdUnit4 fuzzer-arg skip -## convention (`_do_skip`/`_skip_reason`, matching test_input_gate_live.gd's -## own server-binary-not-built skip) keyed on `_dummy_renderer_active()`, so -## `tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports -## green-with-skips under headless, not red. -## -## To actually exercise this file's assertions, run it with a real driver: -## godot4 --display-driver x11 --rendering-driver opengl3 \ -## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ -## -a res://tests/test_atlas_window_overlay_draw_smoke.gd -## (matching tests/visual_capture.gd's own documented "requires a real -## rendering driver" precedent — see docs/DEVOPS.md's own note on this file.) -class_name TestAtlasWindowOverlayDrawSmoke -extends GdUnitTestSuite - -const AtlasWindowOverlay := preload("res://ui/implant/apps/atlas/atlas_window_overlay.gd") -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const) -const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512) - -## Minimum fraction of the captured image that must differ from COLOR_BG for -## a draw to count as "genuinely rendered something" — low enough to tolerate -## a mostly-water/mostly-one-color composite (round 4's own repro shots were -## legitimately near-uniform ocean at some zooms), high enough that a -## fully-blank/fully-background/fully-white frame (both round 4 bugs) fails it. -const MIN_NON_BACKGROUND_FRACTION: float = 0.05 - -const SKIP_REASON: String = ( - "no real rendering driver (dummy/headless) — run with e.g." - + " `godot4 --display-driver x11 --rendering-driver opengl3" - + " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c" - + " -a res://tests/test_atlas_window_overlay_draw_smoke.gd` to exercise this file" -) - - -## True under Godot's `--display-driver headless` (the dummy renderer -## `tests/run-godot`'s hardcoded `--headless` flag selects) — `DisplayServer. -## get_name()` reports `"headless"` there and the real driver name (`"X11"`, -## `"Wayland"`, etc.) otherwise, confirmed directly against both this -## worktree's `tests/run-godot` invocation and a real `--display-driver x11 -## --rendering-driver opengl3` run. Named as a function, not a const, since -## `DisplayServer` singleton state isn't available at script-parse time. -static func _dummy_renderer_active() -> bool: - return DisplayServer.get_name() == "headless" - - -## Single-window viewer stub — mirrors test_atlas_window_overlay.gd's -## _ViewerStub exactly (is_tile_mode() -> false), so this exercises the -## SAME single-window draw path that suite's cache tests cover, just -## through a REAL render instead of inspecting `_cached_texture` directly. -class _SingleWindowViewerStub: - var window: Variant = null - - func get_district_window() -> Variant: - return window - - func is_overlay_visible(_overlay_id: String) -> bool: - return false - - func get_cell_pixel_size() -> float: - return 16.0 - - func is_tile_mode() -> bool: - return false - - -## Tile-mode viewer stub — is_tile_mode() -> true, get_tile_set() returns a -## bare object exposing get_tiles() (AtlasWindowOverlay's own duck-typed -## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly: -## Array of {"center": Vector2i, "window": Variant}). -class _TileModeViewerStub: - # PR #192 cold-start dossier: AtlasWindowOverlay reads viewer.COLOR_BORDER_FADE - # directly for a pending tile's wash (avoids a cyclic preload of the - # viewer's own script — see that read site's own doc) — mirrored here - # byte-for-byte (AtlasWindowViewer.COLOR_BORDER_FADE, private const). - const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) - - var tiles: Array = [] - var held_center: Vector2i = Vector2i.ZERO - var held_n: int = 0 - # Live round 5: nearest_wrap_image()'s cols input — 0 here (a no-radius - # passthrough) is fine for these tests, which don't exercise the wrap - # seam itself (that's test_atlas_window_geometry.gd's own coverage); - # this stub only needs to satisfy _draw_tile_mosaic()'s duck-typed call. - var body_radius_km: float = 0.0 - - func get_district_window() -> Variant: - return null - - func is_overlay_visible(_overlay_id: String) -> bool: - return false - - func get_cell_pixel_size() -> float: - return 16.0 - - func is_tile_mode() -> bool: - return true - - func get_tile_set() -> Variant: - return _TileSetStub.new(tiles) - - func get_held_center() -> Vector2i: - return held_center - - func get_held_n() -> int: - return held_n - - func get_body_radius_km() -> float: - return body_radius_km - - -class _TileSetStub: - var _tiles: Array = [] - - func _init(tiles: Array) -> void: - _tiles = tiles - - func get_tiles() -> Array: - return _tiles - - -## A `Node2D._draw()`-based flat-fill background — deliberately NOT a -## `ColorRect` (a `Control`). A `ColorRect` parented directly under a bare -## `SubViewport` (no intervening `Control` container establishing its own -## layout rect) did not reliably render in this harness: sampled pixels came -## back fully transparent `(0,0,0,0)` regardless of the ColorRect's `color`/ -## `size`, even after entering the tree before sizing. `AtlasWindowOverlay` -## itself is a bare `Node2D` using `draw_rect()` for its own background wash -## (`AtlasWindowViewer._draw()`'s own `COLOR_BG` fill) — matching that same, -## already-proven-working `Node2D.draw_rect()` pattern here sidesteps -## whatever `Control`-specific layout/compositing gap caused the ColorRect -## failure, rather than debugging that gap for its own sake. -class _BackgroundRect extends Node2D: - var fill_color: Color = Color.BLACK - var fill_size: Vector2 = Vector2.ZERO - - func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color) - - -static func _mock_window(center: Vector2i = Vector2i.ZERO, n: int = 64) -> Dictionary: - # A checkerboard-ish morphology spread (not all-one-zone) so the built - # composite has genuine color VARIATION, not just "one flat non-background - # color" — closer to what a real terrain response looks like. - var cells: int = n * n - var morphology := PackedByteArray() - var elev_q := PackedByteArray() - morphology.resize(cells) - elev_q.resize(cells) - for i in range(cells): - morphology[i] = (i % 4) as int # cycles through the 4 morphology zones - elev_q[i] = (i * 7) % 100 as int - return { - "center": [center.x, center.y], - "n": n, - "granularity_v2": "District", - "morphology": morphology, - "elev_q": elev_q, - "temp_dc": [], - "moisture_q": PackedByteArray(), - "vegetation": PackedByteArray(), - "glaciation": PackedByteArray(), - } - - -## Renders `overlay` (any Node2D with its own `_draw()` — an -## AtlasWindowOverlay for (a)/(b) below, or a bare `_BackgroundRect` probe for -## the harness sanity check) parented under a Node2D positioned/scaled the -## way AtlasWindowViewer._canvas would be, into a fresh SubViewport, and -## returns the captured Image. `zoom` mirrors AtlasWindowViewer._canvas.scale -## (real `fit_window_view()` output is a small fraction, e.g. ~0.006 for a -## whole-body tile mosaic per live round 4's own repro) — WITHOUT it, a -## tile's real-world extent (TILE_N * cell_px = 102,400 local units) is so -## much larger than any realistic test viewport that a mis-POSITIONED tile -## still overlaps the frame purely by being gigantic, making the position -## math this test exists to catch silently unfalsifiable (confirmed -## directly: an earlier version of this test without a zoom scale kept -## passing even with live round 4's tile-coordinate bug deliberately -## reintroduced). Also confirmed directly: a hand-rolled duplicate of this -## same SubViewport/settle-loop setup (the harness sanity check's ORIGINAL -## standalone version) was measurably less reliable under a real driver than -## going through this shared path — reuse over duplication here isn't just -## tidiness, it's the more reliable rendering path. -func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image: - var sub_viewport := SubViewport.new() - sub_viewport.size = VIEWPORT_SIZE - sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS - sub_viewport.transparent_bg = false - add_child(sub_viewport) - auto_free(sub_viewport) - - var bg := _BackgroundRect.new() - bg.fill_color = COLOR_BG - bg.fill_size = Vector2(VIEWPORT_SIZE) - sub_viewport.add_child(bg) - bg.queue_redraw() - - var canvas := Node2D.new() - canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 # center the composite's local (0,0) - canvas.scale = Vector2(zoom, zoom) - sub_viewport.add_child(canvas) - canvas.add_child(overlay) - overlay.queue_redraw() - - # Bounded settle wait, NOT `await RenderingServer.frame_post_draw` — that - # signal never fires under the dummy/headless driver (confirmed directly: - # a first version of this file using it hung for the full 300s - # tests/run-godot wall-clock cap and was force-killed, producing a FALSE - # "0 tests, passed" result — exactly the silent-hang failure mode the - # `_do_skip`/`_dummy_renderer_active()` gate (this file's header doc) now - # avoids structurally instead). A fixed small number of `process_frame` - # awaits settles real rendering — confirmed sufficient against a real - # driver during this fix's own live verification. - for _i in range(6): - await get_tree().process_frame - - return sub_viewport.get_texture().get_image() - - -## Fraction of `image`'s pixels whose RGB differs from COLOR_BG (alpha -## ignored — the ColorRect background is opaque, everything drawn on top of -## it is what's under test). -static func _non_background_fraction(image: Image) -> float: - var w: int = image.get_width() - var h: int = image.get_height() - if w <= 0 or h <= 0: - return 0.0 - var total: int = w * h - var differing: int = 0 - var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0) - for y in range(h): - for x in range(w): - var px: Color = image.get_pixel(x, y) - var px_rgb: Color = Color(px.r, px.g, px.b, 1.0) - if not px_rgb.is_equal_approx(bg_rgb): - differing += 1 - return float(differing) / float(total) - - -## (a) Single-window path: a District-rung response must render as visibly -## non-background pixels through AtlasWindowOverlay._draw()'s own -## Rect2(0,0,extent,extent) draw call — the "does the OVERLAY actually paint -## something for a real window" half of the gap. Positions it at the -## SubViewport's center via the surrounding Node2D, mirroring _canvas's role -## in the real viewer. -## -## Honest scope note: live round 4's SECOND bug (leaving `_view_offset` -## stale across a rung crossing in `_maybe_reselect_rung()`) lived entirely -## in AtlasWindowViewer's transform bookkeeping, ONE LAYER ABOVE this -## overlay-only test's boundary — it never touched `_draw()` itself, so a -## pure-overlay smoke test structurally cannot reproduce it (there is no -## "stale vs. fresh offset" state to compare inside the overlay alone). That -## regression's coverage is `_maybe_reselect_rung()`'s own unit tests in -## test_atlas_zoom_ladder.gd. This test's job is narrower and still real: -## proving the overlay's draw call itself produces visible output for -## legitimate window data, closing the "the composite Rect2 call is -## silently a no-op" class of bug regardless of which layer caused it. -func test_single_window_draw_produces_visible_pixels() -> void: - # Guarded early-return instead of the _do_skip fuzzer-arg convention: - # gdUnit4 leaks one internal per fuzzer-skipped test, tripping the - # orphan detector (exit 101) and bouncing the push gate even at 0 failures - # (PR gate run 2026-07-22: "2 skipped | 2 orphans | Exit code: 101"). - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() - var stub := _SingleWindowViewerStub.new() - stub.window = _mock_window(Vector2i.ZERO, 32) - overlay.viewer = stub - - var image: Image = await _render_to_image(overlay) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "single-window composite must render VISIBLE non-background pixels — got" - + " only %.2f%% of the frame differing from COLOR_BG. This is exactly the" - + " shape of live round 4's second bug: _view_offset left stale across a" - + " rung crossing pushed the composite off-canvas, so nothing but" - + " background/chrome ever appeared, despite the underlying window data" - + " and draw calls being individually 'correct' in isolation." - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) - - -## (b) Tile-mosaic path: a tile at a district center AWAY from the body's -## own origin must still render as visibly non-background pixels once -## correctly placed via `district_to_canvas_local()`'s shared held_center/ -## held_n convention — this is the path round 4's FIRST and THIRD bugs -## (tile-local-origin math ignoring that convention, and per-tile -## ImageTexture objects with no persistent reference being garbage- -## collected/GPU-desynced before their draw command flushed) would both -## have failed. Uses production-realistic scale (live round 4's own Lendel -## repro: raw circumference ~19,139 districts) — see the tile-center -## comment below for why scale matters here specifically. -func test_tile_mosaic_draw_produces_visible_pixels() -> void: - # Guarded early-return, not _do_skip — see the sibling test's comment. - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() - var stub := _TileModeViewerStub.new() - var tile_n: int = AtlasWindowGeometry.TILE_N - var cell_px: float = stub.get_cell_pixel_size() - stub.held_center = Vector2i.ZERO - stub.held_n = 19139 - # A SINGLE tile chosen so the CORRECT canvas-local formula - # (`district_to_canvas_local()`, anchored at `held_center - held_n/2`) - # lands it centered in the viewport, while the round-4 BUGGY formula - # (anchored at absolute district (0,0) directly) lands it almost - # `held_n/2 * cell_px` local units away — tens of thousands of units at - # this scale, i.e. genuinely fully off a 512x512 viewport, not just - # "shifted but still overlapping" (confirmed by hand-computation: a - # smaller/toy-scale version of this test stayed green with the bug - # reintroduced, because the shift stayed within the viewport bounds - # either way — this scale/center combination is chosen specifically to - # avoid that false-negative). - var half_tile: float = float(tile_n) * 0.5 - var half_body: float = float(stub.held_n) * 0.5 - var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body)) - stub.tiles = [ - {"center": lone_tile_center, "window": _mock_window(lone_tile_center, 64)}, - ] - overlay.viewer = stub - - # A zoom small enough that the buggy-vs-correct shift (~half_body * cell_px - # local units) is comfortably larger than the viewport — see the center - # choice's own doc above for why this specific magnitude matters. - var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px) - var image: Image = await _render_to_image(overlay, zoom) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "tile mosaic must render VISIBLE non-background pixels across its tiles —" - + " got only %.2f%% of the frame differing from COLOR_BG. This is exactly" - + " the shape of live round 4's bugs: (1) tile local-origin computed" - + " relative to absolute district (0,0) instead of the shared" - + " held_center/held_n canvas-local convention pushed the whole mosaic" - + " off-canvas, and (2) even once correctly positioned, an unstored" - + " per-draw-call ImageTexture rendered as a blank/white gap despite" - + " provably-correct CPU-side pixel data — both invisible to any test that" - + " only inspects Dictionary/cache state, never an actual composited pixel." - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) - - -## (c) PR #192 cold-start dossier, BUG 3: a mosaic tile with NO window yet -## (the cold-server "still working" state, every tile in the mosaic at once -## right after enter_orbital() on a real cold server) must render the SAME -## border-fade wash the single-window path already gives its own -## no-composite-yet wait — not a bare COLOR_BG gap that reads as broken. -## Same real-render infrastructure as (a)/(b): a pending tile (`window: null`) -## must still produce visible non-background pixels, proving the wash -## genuinely draws rather than the loop just `continue`-ing past it silently. -func test_pending_tile_gets_a_visible_border_fade_wash() -> void: - if _dummy_renderer_active(): - print(SKIP_REASON) - return - var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() - var stub := _TileModeViewerStub.new() - var tile_n: int = AtlasWindowGeometry.TILE_N - var cell_px: float = stub.get_cell_pixel_size() - stub.held_center = Vector2i.ZERO - stub.held_n = 19139 # GJ380c/Lendel's own raw circumference (live round 4) - # Same centered-tile placement as (b) above, but with `window: null` — - # the pending state this fix targets, instead of a real response. - var half_tile: float = float(tile_n) * 0.5 - var half_body: float = float(stub.held_n) * 0.5 - var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body)) - stub.tiles = [{"center": lone_tile_center, "window": null}] - overlay.viewer = stub - - var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px) - var image: Image = await _render_to_image(overlay, zoom) - var fraction: float = _non_background_fraction(image) - - assert_float(fraction).override_failure_message( - ( - "a pending tile (window == null) must still render a visible border-fade" - + " wash — got only %.2f%% of the frame differing from COLOR_BG, meaning" - + " the tile is a bare background gap during the cold-server wait, which" - + " reads as broken rather than 'still working' (coordinator's closing" - + " question, PR #192 cold-start dossier)." - ) - % (fraction * 100.0) - ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd deleted file mode 100644 index 7cb4d27fb..000000000 --- a/client/tests/test_atlas_window_request.gd +++ /dev/null @@ -1,652 +0,0 @@ -## T-1150 (PR #191 review, Hoshe 4): atlas_window_request.gd had NO test file -## at all before this — direct coverage of the granularity/min_wl_m staleness -## guard, the n-clamp mirror (Tyre C1), and the old-server-shape default -## disposition. Follows test_atlas_window_viewer.gd's own -## "AtlasWindowRequest — cache reuse" section conventions (same -## instantiation pattern: `AtlasWindowRequest.new(owner_stub)`, `add_child()` -## for the debounce Timer, hand-built response dicts) rather than -## re-inventing a shape. -class_name TestAtlasWindowRequest -extends GdUnitTestSuite - -# atlas_window_request.gd has no class_name (review #8 precedent throughout -# this cluster) — preloaded once here, not re-load()ed per test (gdlint -# duplicated-load). -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") - -## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) — -## `u32::MAX`, the RESERVED KEY-SPACE TAG a real server ALWAYS puts in the -## legacy `granularity` slot for every Region response (never a real -## multiplier — District=1/Quarter=4 are the only legal wire multipliers). -## Do NOT "fix" this to 1 — using a convenient value here is EXACTLY the gap -## the live round caught (a mock that diverges from the wire in the one -## field that matters silently un-repros the bug). See -## AtlasWindowRequest's `_echoed_granularity_matches()` doc for the full -## rationale. -const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 - - -## Build a hand-authored DistrictWindowLayer dict, granularity-aware -## (T-1150, extended T-1152/T-1153 for granularity_v2) — mirrors -## test_atlas_window_viewer.gd's own _mock_window(), with -## granularity/min_wl_m/granularity_v2 added as optional params so callers -## can build any rung's echo shape with one helper. -static func _mock_window( - center: Vector2i, - n: int = 2, - granularity: int = 1, - min_wl_m: int = 0, - granularity_v2: String = "District" -) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "granularity": granularity, - "min_wl_m": min_wl_m, - "granularity_v2": granularity_v2, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's -## first-ever response (server/src/atlas/layer_proxy.rs's -## get_or_generate()/serve_district_window(), whole-body cache MISS branch — -## `AtlasLayerResponse { status: Pending, district_window: None, ... }`, -## confirmed directly against that source). `body_id` is the only -## identifying field. -static func _pending_response(body_id: String) -> Dictionary: - return {"body_id": body_id, "status": "Pending", "district_window": null} - - -static func _not_found_response(body_id: String) -> Dictionary: - return {"body_id": body_id, "status": "NotFound", "district_window": null} - - -## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded -## `AtlasLayerStatus::Error(String)` variant's status STRING is always just -## "Error" (the message rides in a separate `error` field). -static func _error_response(body_id: String, message: String = "boom") -> Dictionary: - return {"body_id": body_id, "status": "Error", "error": message, "district_window": null} - - -func _make_request() -> Variant: - var owner_stub := RefCounted.new() - var req = auto_free(AtlasWindowRequest.new(owner_stub)) - add_child(req) - return req - - -# ============================================================================= -# (a) granularity mismatch on the echo -> dropped as stale -# ============================================================================= - - -## The mandatory item-(a) case: request_now() asks at the default district -## granularity (1); a response echoing granularity=4 (quarter) for the SAME -## center/n must be dropped as stale, not accepted — a different rung's -## derive answering a request for a different rung is exactly as stale as a -## mismatched center (T-1150 extends §2's guard to this axis). -## -## **Live-round correction:** the mock MUST carry a mismatched -## `granularity_v2` too (explicit `"Quarter"`, not `_mock_window()`'s -## `"District"` default) — a real Quarter response ALWAYS carries -## `granularity_v2: "Quarter"` on the wire, never the District default this -## test's fixture used to leave implicit. Under the v2-authoritative-when- -## present precedence rule (see on_response()'s own doc), a v2-MATCHING -## response is accepted regardless of what the legacy int says — leaving -## granularity_v2 at its District default here would have made this test -## pass for the wrong reason (an accidentally-matching v2 field masking a -## genuinely mismatched legacy int), exactly the class of gap the live round -## caught in the oversized-orbital round-trip test. -func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - assert_bool(req.is_pending()).is_true() - - var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0, "Quarter") - req.on_response(_mock_response("GJ380c", quarter_window)) - - assert_bool(req.is_pending()).override_failure_message( - "a granularity-mismatched response must be dropped as stale, leaving the district request still pending" - ).is_true() - - -# ============================================================================= -# (a2) granularity_v2 mismatch on the echo -> dropped as stale (T-1152/T-1153, -# the axis the legacy int alone cannot express — Region has no legacy value) -# ============================================================================= - - -## request_now() can now ask for Region explicitly (T-1153's rung-reselect -## caller) — a response echoing "District" for the SAME center/n must be -## dropped as stale, the granularity_v2 twin of test (a) above, and the -## ONLY guard that can catch this specific mismatch (the legacy int is -## DISTRICT_GRANULARITY=1 on BOTH sides here, since Region has no legacy -## representation — see WindowGranularity::legacy_u32()'s doc). -func test_on_response_with_mismatched_granularity_v2_is_dropped_as_stale() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) - assert_bool(req.is_pending()).is_true() - - var district_window: Dictionary = _mock_window(Vector2i(0, 0), 6400, 1, 0, "District") - req.on_response(_mock_response("GJ380c", district_window)) - - assert_bool(req.is_pending()).override_failure_message( - "a granularity_v2-mismatched response (District answering a Region request)" - + " must be dropped as stale, leaving the request still pending" - ).is_true() - - -## The matching case: request_now() asking for Region, answered by a Region -## echo at the SAME (center, n) — must be ACCEPTED and cached under the -## Region key, retrievable on a follow-up request without a new network round -## trip. -## -## **Live-round correction:** the mock's legacy `granularity` field is now -## Dudley's ACTUAL wire sentinel (`WINDOW_GRANULARITY_REGION_KEY` = -## `u32::MAX` = 4294967295), not a convenient `1` — the original version of -## this test used `1`, which coincidentally matched the request's own -## pinned `_granularity` and therefore never exercised the real mismatch a -## live server actually produces. See _echoed_granularity_matches()'s own -## doc (atlas_window_request.gd) for why this is load-bearing: without the -## v2-authoritative-when-present fix, THIS test would have failed with the -## real sentinel — it only passed before because the mock was wrong. -func test_on_response_matching_granularity_v2_region_is_accepted_and_cached() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) - assert_bool(req.is_pending()).is_true() - - var region_window: Dictionary = _mock_window( - Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region" - ) - req.on_response(_mock_response("GJ380c", region_window)) - assert_bool(req.is_pending()).override_failure_message( - "a response carrying the REAL legacy sentinel (u32::MAX) in the old" - + " granularity slot must still be accepted — v2 is authoritative" - + " whenever present, the legacy field must not be compared at all" - ).is_false() - - var received: Array = [] - req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) - req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) - assert_int(received.size()).override_failure_message( - "a second Region request at the same (center, n) must hit the cache" - ).is_equal(1) - assert_bool(req.is_pending()).is_false() - - -## **The direct precedence-rule proof (live-round finding #2, the sharpest -## case):** a response whose `granularity_v2` MATCHES the request but whose -## LEGACY `granularity` field could never possibly match (the Region -## sentinel) must still be ACCEPTED — proving the legacy comparison is -## SKIPPED entirely when v2 is present, not merely "also checked and -## happens to pass." This is the literal shape of the live bug: real server -## responses ALWAYS carry the Region sentinel in the legacy slot, so any -## code path that still consults the legacy field when v2 is already -## authoritative would drop every single one of these, forever. -func test_on_response_v2_match_is_accepted_regardless_of_legacy_field_value() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION) - - var region_window: Dictionary = _mock_window( - Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region" - ) - req.on_response(_mock_response("GJ380c", region_window)) - - assert_bool(req.is_pending()).override_failure_message( - "v2 match must be sufficient on its own — the legacy sentinel value must" - + " never be consulted once granularity_v2 is present on the response" - ).is_false() - - -# ============================================================================= -# (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults -# ============================================================================= - - -## A response from a hypothetical pre-T-1150 server (or any response whose -## district_window dict simply omits the new keys) must decode granularity -## as district (1) and min_wl_m as 0 via the same defaulting on_response() -## already applies — and since request_now()'s own defaults are identical, -## the response is ACCEPTED, not treated as stale just because two keys are -## missing. -func test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(3, 3), 2) - assert_bool(req.is_pending()).is_true() - - # Old-shape window: no "granularity"/"min_wl_m" keys at all. - var old_shape_window := { - "center": [3, 3], - "n": 2, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - req.on_response(_mock_response("GJ380c", old_shape_window)) - - assert_bool(req.is_pending()).override_failure_message( - ( - "an old-server-shape response (missing granularity/min_wl_m) must " - + "default to district/0 and be ACCEPTED, not dropped as stale" - ) - ).is_false() - - var received: Array = [] - req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) - # Re-request the same (body, center, n) — must now be a cache hit, proving - # on_response() actually stored the old-shape window under the - # district/0 key, not silently discarding it. - req.request_now("GJ380c", Vector2i(3, 3), 2) - assert_int(received.size()).is_equal(1) - assert_bool(req.is_pending()).is_false() - - -## **Live-round sibling test (instruction #2's "old-server path stays -## covered"):** a response that carries the LEGACY `granularity` key WITH AN -## EXPLICIT VALUE (1, i.e. genuinely present, not merely defaulted via -## absence — the case test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted -## above doesn't exercise, since it omits the key entirely) but has NO -## `granularity_v2` key at all — the true "hypothetically old, pre-T-1152 -## server" shape — must still be accepted for a plain District request via -## the legacy-comparison FALLBACK branch in `_echoed_granularity_matches()`. -## This is the other half of the v2-authoritative-when-present precedence -## rule: v2 present -> v2 alone decides; v2 ABSENT -> legacy alone decides -## (never both, never neither). -func test_on_response_legacy_only_no_v2_key_still_accepted_for_district() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(4, 4), 2) # defaults to District granularity - assert_bool(req.is_pending()).is_true() - - # Legacy-only shape: "granularity" IS present (district=1), "granularity_v2" - # key is absent entirely — not present-with-a-District-value, ABSENT. - var legacy_only_window := { - "center": [4, 4], - "n": 2, - "granularity": AtlasWindowRequest.DEFAULT_GRANULARITY, - "min_wl_m": 0, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - assert_bool(legacy_only_window.has("granularity_v2")).override_failure_message( - "sanity: this fixture must NOT carry granularity_v2 at all — that's the point" - ).is_false() - - req.on_response(_mock_response("GJ380c", legacy_only_window)) - - assert_bool(req.is_pending()).override_failure_message( - "a legacy-only response (granularity=1 present, granularity_v2 absent) must" - + " still be accepted for a District request via the legacy-fallback branch" - ).is_false() - - -# ============================================================================= -# (c) n-clamp mirror (Tyre C1) — quarter n=32 stores clamped n=16 -# ============================================================================= - - -## **Item (c) as literally scoped by the ticket** ("the clamp-mirror from -## item 1"): `_clamp_window_n_mirror()` reproduces the server's -## `clamp_window_n(raw_n, granularity)` bit-for-bit, INCLUDING the quarter -## n=32 -> 16 case — pinned directly against the static helper, independent -## of the request/response plumbing (`request_now()` has no public -## "request quarter" entry point today; T-1150 is struct/key plumbing only, -## requesting quarter is T-1153's job — see the class-level docstring on -## `_clamp_window_n_mirror()` for why calling `request_now()` at district -## granularity can never itself exercise the quarter branch: it unconditionally -## resets `_granularity` to district BEFORE clamping, by design, since no -## caller can ask for quarter yet). -func test_clamp_window_n_mirror_matches_server_formula_at_quarter_n32() -> void: - assert_int(AtlasWindowRequest._clamp_window_n_mirror(32, 4)).is_equal(16) - # District granularity: the per-axis cap (64) governs, matching the - # server's clamp_window_n_district_granularity_uses_per_axis_cap test. - assert_int(AtlasWindowRequest._clamp_window_n_mirror(640, 1)).is_equal(64) - # Small n well under budget at quarter granularity stays unclamped, - # matching clamp_window_n_quarter_granularity_leaves_small_n_unclamped. - assert_int(AtlasWindowRequest._clamp_window_n_mirror(8, 4)).is_equal(8) - - -## **Item (c), the request/response half:** `request_now()` actually WIRES -## the mirror in (not just defines it) — a request for a district-legal but -## per-axis-oversized `n` (e.g. 640, mirroring the server's own -## `DISTRICT_WINDOW_MAX_N*10` oversized-request test) stores the CLAMPED -## `_n=64`, so a server response echoing the server's OWN clamped n=64 is -## ACCEPTED, not rejected as stale for "not matching" the raw 640 that was -## asked for. This is the exact n-clamp/echo/staleness triangle Tyre C1 -## flagged, exercised through the reachable (district) path today; the -## quarter-specific n=32->16 number is pinned by the formula test above since -## no public API can drive quarter through `request_now()` yet. -func test_oversized_n_request_stores_clamped_n_and_accepts_matching_echo() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(4, 4), 640) - - assert_int(req._n).override_failure_message( - ( - "request_now() must mirror the server's clamp_window_n(640, granularity=1) " - + "== 64 BEFORE storing _n, not store the raw requested 640" - ) - ).is_equal(64) - assert_bool(req.is_pending()).is_true() - - # The server's real response for this request echoes n=64 (its own - # clamp_window_n() result) — must be ACCEPTED, not stale. - var clamped_echo: Dictionary = _mock_window(Vector2i(4, 4), 64, 1, 0) - req.on_response(_mock_response("GJ380c", clamped_echo)) - - assert_bool(req.is_pending()).override_failure_message( - ( - "a response echoing the CLAMPED n=64 must be accepted, since _n was " - + "already clamped to 64 before the request fired" - ) - ).is_false() - - -# ============================================================================= -# (d) Region clamp mirror (T-1152/T-1153) — mirrors -# server/src/atlas/layer_proxy.rs's clamp_window_n_v2 EXACTLY, including the -# Region branch's bounded halving loop. -# -# PR #192 review (Dudley, server-side analysis): the halving loop is -# PROVABLY UNREACHABLE at current constants — the per-axis clamp to -# SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) forecloses it. Brute-forced, -# the max cell_grid_side over ALL reachable (post-per-axis-clamp) n is -# exactly 64 — the wire-cap boundary itself, never over it — so the loop's -# `>` guard is never true for any input. Ruling: the loop STAYS as -# defensive code (a future constant change could make it reachable again), -# but the test suite must not claim it "fires" when it provably doesn't. -# See server/src/atlas/layer_proxy.rs's -# clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs -# for the server-side property-sweep pin this client-side suite mirrors. -# ============================================================================= - - -## District/Quarter through the v2 mirror must be BYTE-IDENTICAL to the -## legacy mirror — the server's own -## `clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter` -## guarantee, restated client-side. -func test_clamp_window_n_mirror_v2_matches_legacy_for_district_and_quarter() -> void: - assert_int( - AtlasWindowRequest._clamp_window_n_mirror_v2(32, AtlasWindowRequest.GRANULARITY_V2_QUARTER) - ).is_equal(AtlasWindowRequest._clamp_window_n_mirror(32, 4)) - assert_int( - AtlasWindowRequest._clamp_window_n_mirror_v2(640, AtlasWindowRequest.GRANULARITY_V2_DISTRICT) - ).is_equal(AtlasWindowRequest._clamp_window_n_mirror(640, 1)) - - -## The clean Region boundary case: n=6,400 (DISTRICT_WINDOW_MAX_N_REGION, -## the per-axis cap exactly) derives cell_grid_side(6400) = round(6400/100) = -## 64, and 64² = 4,096 = WIRE_CAP_CELLS EXACTLY — the halving loop's `>` -## condition is false at the boundary, so this must clamp to EXACTLY 6,400, -## not halve further. This is the server's own -## `clamp_window_n_v2_region_exact_boundary_n6400_uncontested` guarantee, -## restated client-side (WIRE_CAP_CELLS_SQRT * DISTRICTS_PER_REGION is -## DERIVED to land here exactly, per that constant's own doc). -func test_clamp_window_n_mirror_v2_region_boundary_is_exact() -> void: - var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( - AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, AtlasWindowRequest.GRANULARITY_V2_REGION - ) - assert_int(n).is_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) - - -## Region's per-axis cap: a raw `n` far over DISTRICT_WINDOW_MAX_N_REGION -## (mirroring the server's own `region_request_oversized_n_clamps_and_echoes_clamped_n` -## test's `DISTRICT_WINDOW_MAX_N_REGION * 10` shape) must clamp DOWN — never -## trust the wire — and the result must satisfy BOTH invariants the server's -## own test asserts: `n <= DISTRICT_WINDOW_MAX_N_REGION` AND -## `cell_grid_side(n)^2 <= WIRE_CAP_CELLS`. -func test_clamp_window_n_mirror_v2_region_oversized_n_clamps_within_both_bounds() -> void: - var oversized: int = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10 - var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( - oversized, AtlasWindowRequest.GRANULARITY_V2_REGION - ) - assert_int(n).override_failure_message( - "echoed n must be clamped to DISTRICT_WINDOW_MAX_N_REGION, not the raw oversized value" - ).is_less_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) - var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(n) - assert_int(side * side).override_failure_message( - "clamped cell count must never exceed WIRE_CAP_CELLS at Region granularity either" - ).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS) - - -## PR #192 review (Dudley's unreachability finding, applied client-side): the -## halving loop's `>` guard is PROVABLY never true at current constants — the -## per-axis clamp to SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) happens -## FIRST and unconditionally, and cell_grid_side(6400) = 64 lands EXACTLY on -## the wire-cap boundary (64² = WIRE_CAP_CELLS), never over it. A prior -## version of this test claimed n=6,450 "exercises" the loop firing — it does -## not: 6,450 clamps to 6,400 before the loop ever runs, so the test was -## passing on the per-axis clamp alone, not on anything the loop itself did -## (the same mock-diverges-from-reality class of bug hunted in review round -## 2). Reframed as a property sweep, mirroring the server's own -## `clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs` -## (Dudley): for every raw n across the legal range (including values far -## past the per-axis cap), (i) the per-axis-clamped n never gets modified any -## further by the loop — pre-loop n and post-clamp n are byte-identical — -## and (ii) the wire-cap invariant holds regardless. The loop itself stays as -## defensive code (a future constant change could make it reachable again); -## this test documents that it is a no-op today rather than asserting a -## behavior that never actually happens. -func test_clamp_window_n_mirror_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs() -> void: - var sample_raw_ns: Array = [ - 1, 100, 6399, 6400, 6401, 6450, 6500, - AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10, - ] - for raw_n: int in sample_raw_ns: - var pre_loop_n: int = clampi(raw_n, 1, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) - var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( - raw_n, AtlasWindowRequest.GRANULARITY_V2_REGION - ) - assert_int(clamped_n).override_failure_message( - ( - "the per-axis clamp alone must already satisfy the wire cap for" - + " raw_n=%d — the halving loop is provably unreachable at current" - + " constants (max cell_grid_side over all reachable n is exactly" - + " 64, the wire-cap boundary itself), so it must never further" - + " modify what the per-axis clamp already produced" - ) % raw_n - ).is_equal(pre_loop_n) - - var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(clamped_n) - assert_int(side * side).override_failure_message( - "the wire-cap invariant must hold for raw_n=%d regardless" % raw_n - ).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS) - - -## n smaller than one region (n < 100) must clamp its cell-grid side to a -## minimum of 1 — cell_grid_side_for_window()'s own `.max(1)` — never a -## degenerate 0x0 grid, matching WindowGranularity::cell_grid_side's own -## documented minimum. -func test_cell_grid_side_region_mirror_minimum_is_one() -> void: - assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(1)).is_equal(1) - assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(50)).is_equal(1) - - -# ============================================================================= -# PR #192 cold-start round 3 — the single-window half of the launch-shape -# gap: the SAME status-gate bug the tile fan-out has (test_atlas_window_tile_set.gd's -# own regressions) applies equally here, since on_response() is the shared -# class both paths use. A first descent onto a cold body with NO tiles -# (District/Quarter rung, or a small Region body) hits the identical -# whole-body-cache-miss -> status:"Pending" wire shape. -# ============================================================================= - - -## The exact bug, single-window shape: a whole-response Pending on a cold -## first request must increment the retry counter and schedule a re-poll — -## not be silently dropped by the OLD `status != "Ready" -> return` gate. -func test_cold_request_whole_response_pending_increments_retry_count() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - assert_bool(req.is_pending()).is_true() - - req.on_response(_pending_response("GJ380c")) - - assert_bool(req.is_pending()).override_failure_message( - "a whole-response Pending must leave the request still pending, not" - + " silently give up" - ).is_true() - assert_int(req._retries).override_failure_message( - "a whole-response Pending must increment the retry counter — the" - + " exact bug: the OLD status-gate dropped this before ever reaching" - + " the retry-scheduling code, leaving retries at 0 forever" - ).is_equal(1) - - -## Full convergence: a whole-response Pending, then a real Ready for the -## RE-REQUEST, must be accepted — proving the retry loop's own re-request -## actually gets picked up, not just that the counter increments. The -## mid-test retries==1 assertion is what makes this genuinely load-bearing: -## without it, a Ready delivered ANY time after a Pending (retried or not) -## trivially passes this test's final assertion, since accepting a fresh -## Ready response was never the broken behavior — only the retry itself was. -func test_cold_request_converges_after_pending_then_real_ready() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - - req.on_response(_pending_response("GJ380c")) - assert_bool(req.is_pending()).is_true() - assert_int(req._retries).override_failure_message( - "sanity: the retry must have actually been scheduled before this test" - + " waits for it to fire — otherwise the final assertion below would" - + " pass even if the retry never happened at all" - ).is_equal(1) - - await get_tree().create_timer(0.6).timeout # past the first retry delay - - var window: Dictionary = _mock_window(Vector2i(2, 2), 2) - req.on_response(_mock_response("GJ380c", window)) - assert_bool(req.is_pending()).override_failure_message( - "a real Ready response after the pending/retry cycle must be accepted" - ).is_false() - - -## NotFound must give up immediately, not retry. -func test_cold_request_not_found_gives_up_immediately_without_retry() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - - req.on_response(_not_found_response("GJ380c")) - - assert_bool(req.is_pending()).override_failure_message( - "NotFound must give up immediately, not stay pending waiting for a retry" - ).is_false() - assert_int(req._retries).is_equal(0) - - -## Error must give up immediately too. -func test_cold_request_error_gives_up_immediately_without_retry() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - - req.on_response(_error_response("GJ380c")) - - assert_bool(req.is_pending()).override_failure_message( - "Error must give up immediately, not stay pending waiting for a retry" - ).is_false() - assert_int(req._retries).is_equal(0) - - -## PR #193 review (Hoshe): a whole-response Pending for a DIFFERENT body -## must not touch this request's retry state — the body_id guard runs -## BEFORE the status branch in on_response(), so someone else's cold-body -## pending can never burn one of OUR 30 retries (or reschedule our timer). -## Structurally guaranteed by guard ordering today; this test pins the -## ordering, because a refactor that moves the status branch first would -## silently cross-wire every concurrent cold descent (multi-body Atlas -## browsing, or the orbital tile fan-out where all requests share one -## broadcast signal). The final Ready-for-OUR-body assertion proves the -## request is genuinely unaffected, not just un-retried. -func test_pending_for_a_different_body_does_not_touch_retry_state() -> void: - var req = _make_request() - req.request_now("GJ380c", Vector2i(2, 2), 2) - assert_bool(req.is_pending()).is_true() - - req.on_response(_pending_response("OtherBody")) - - assert_int(req._retries).override_failure_message( - "a Pending for a DIFFERENT body must not increment OUR retry counter" - + " — the body_id guard must run before the status branch" - ).is_equal(0) - assert_bool(req.is_pending()).override_failure_message( - "a wrong-body Pending must leave the request still pending its own" - + " response, neither given up nor retried" - ).is_true() - - var window: Dictionary = _mock_window(Vector2i(2, 2), 2) - req.on_response(_mock_response("GJ380c", window)) - assert_bool(req.is_pending()).override_failure_message( - "after ignoring a wrong-body Pending, our OWN Ready must still be" - + " accepted normally — the request state must be genuinely untouched" - ).is_false() - assert_int(req._retries).is_equal(0) - - -# ============================================================================= -# _retry_delay_for() — deterministic exponential backoff + per-tile stagger -# (PR #192 cold-start round 3 hardening: 6 tiles retrying in perfect -# lockstep on a whole-response Pending is a real request-pulse risk even -# though it isn't what caused the starvation bug above). -# ============================================================================= - - -func test_retry_delay_for_first_retry_is_the_initial_delay() -> void: - assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx( - AtlasWindowRequest.INITIAL_RETRY_DELAY, 0.0001 - ) - - -func test_retry_delay_for_doubles_each_retry_until_the_cap() -> void: - assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx(0.5, 0.0001) - assert_float(AtlasWindowRequest._retry_delay_for(2, 0)).is_equal_approx(1.0, 0.0001) - assert_float(AtlasWindowRequest._retry_delay_for(3, 0)).is_equal_approx(2.0, 0.0001) - assert_float(AtlasWindowRequest._retry_delay_for(4, 0)).is_equal_approx(4.0, 0.0001) - # Retry 5 would double past MAX_RETRY_DELAY (8.0) — must clamp, not keep growing. - assert_float(AtlasWindowRequest._retry_delay_for(5, 0)).is_equal_approx( - AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001 - ) - assert_float(AtlasWindowRequest._retry_delay_for(20, 0)).is_equal_approx( - AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001 - ) - - -## The deterministic stagger: tile index i's delay is offset by -## STAGGER_STEP*i on top of the same backoff schedule — directly assertable, -## not a randomized jitter a test would have to tolerance-check. -func test_retry_delay_for_staggers_deterministically_by_tile_index() -> void: - var base: float = AtlasWindowRequest._retry_delay_for(1, 0) - for i in range(6): - var expected: float = base + AtlasWindowRequest.STAGGER_STEP * float(i) - assert_float(AtlasWindowRequest._retry_delay_for(1, i)).override_failure_message( - "tile index %d's first-retry delay must be exactly base + STAGGER_STEP*%d" % [i, i] - ).is_equal_approx(expected, 0.0001) - - -## Six tiles that all went pending in the same frame must NOT all retry at -## the exact same instant — the anti-storm property this hardening exists -## for, pinned directly: every tile's delay for the SAME retry count must be -## strictly increasing with its stagger index. -func test_retry_delay_for_six_tiles_never_collide_on_the_same_retry() -> void: - var delays: Array = [] - for i in range(6): - delays.append(AtlasWindowRequest._retry_delay_for(1, i)) - for i in range(1, delays.size()): - assert_float(delays[i]).override_failure_message( - "tile %d's delay must be strictly greater than tile %d's — a storm" - + " pulse means two tiles retrying at the same instant" % [i, i - 1] - ).is_greater(delays[i - 1]) diff --git a/client/tests/test_atlas_window_tile_set.gd b/client/tests/test_atlas_window_tile_set.gd deleted file mode 100644 index 23f9afc05..000000000 --- a/client/tests/test_atlas_window_tile_set.gd +++ /dev/null @@ -1,434 +0,0 @@ -## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for -## AtlasWindowTileSet — the orbital rest-state multi-window mosaic -## orchestration. Same hand-built-response-dict conventions as -## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is -## about the ORCHESTRATION (N tiles, progressive per-tile arrival, -## teardown), not the tile-grid MATH (already covered directly against -## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd). -class_name TestAtlasWindowTileSet -extends GdUnitTestSuite - -const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") - - -static func _mock_window(center: Vector2i, n: int) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "granularity_v2": "Region", - "morphology": PackedByteArray([1, 2, 3, 4]), - "elev_q": PackedByteArray([10, 20, 30, 40]), - "temp_dc": [0, 0, 0, 0], - "moisture_q": PackedByteArray([0, 0, 0, 0]), - "vegetation": PackedByteArray([0, 0, 0, 0]), - "glaciation": PackedByteArray([0, 0, 0, 0]), - } - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's -## first-ever response — server/src/atlas/layer_proxy.rs's -## get_or_generate()/serve_district_window() on a whole-body cache MISS -## builds `AtlasLayerResponse { status: Pending, district_window: None, ... }` -## (confirmed directly against that source). `body_id` is the ONLY -## identifying field — no center/n/granularity anywhere, matching the real -## wire's total lack of per-request attribution on this specific shape. -static func _pending_response(body_id: String) -> Dictionary: - return {"body_id": body_id, "status": "Pending", "district_window": null} - - -static func _not_found_response(body_id: String) -> Dictionary: - return {"body_id": body_id, "status": "NotFound", "district_window": null} - - -## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded -## `AtlasLayerStatus::Error(String)` variant's status STRING is always just -## "Error" (the message rides in a separate `error` field, not appended to -## the status string), confirmed against that decoder directly. -static func _error_response(body_id: String, message: String = "boom") -> Dictionary: - return {"body_id": body_id, "status": "Error", "error": message, "district_window": null} - - -func _make_tile_set() -> Variant: - var owner_stub := RefCounted.new() - var ts = auto_free(AtlasWindowTileSet.new(owner_stub)) - add_child(ts) - return ts - - -# ============================================================================= -# enter() — tile grid computation + one request per tile -# ============================================================================= - - -## enter() on a real, tiling-sized body must produce the SAME tile count -## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own -## live-round number. -func test_enter_produces_the_expected_tile_count_for_lendel() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - assert_int(ts.get_tile_count()).is_equal(6) - assert_bool(ts.is_multi_tile()).is_true() - - -## PR #192 cold-start round 3 hardening: each tile's own AtlasWindowRequest -## must get a DISTINCT, index-matching `_stagger_index` — the anti-storm -## property (deterministic retry-delay stagger, see -## AtlasWindowRequest._retry_delay_for()'s own doc) depends entirely on this -## wiring; without it every tile silently staggers at index 0 and retries -## in lockstep again, exactly the storm risk this hardening exists to close. -func test_enter_wires_a_distinct_stagger_index_per_tile() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - for i in range(ts._tiles.size()): - var req = ts._tiles[i]["request"] - assert_int(req._stagger_index).override_failure_message( - "tile %d's request must be wired with _stagger_index=%d, matching" - + " its own position in the tile set" % [i, i] - ).is_equal(i) - - -## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case -## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION -## (not just the grid math) handles it without crashing or requesting zero -## tiles. -func test_enter_tiny_body_produces_one_tile() -> void: - var ts = _make_tile_set() - ts.enter("TinyBody", 50.0) - assert_int(ts.get_tile_count()).is_equal(1) - assert_bool(ts.is_multi_tile()).is_false() - - -## Every tile must start with a null window (nothing has arrived yet) and -## the tile set must not report "fully arrived" before any response lands. -func test_enter_all_tiles_start_unarrived() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - for tile: Dictionary in ts.get_tiles(): - assert_that(tile["window"]).is_null() - assert_bool(ts.is_fully_arrived()).is_false() - - -## An empty tile set (never entered) must not report "fully arrived" either -## — an empty AND-over-nothing must not vacuously read true. -func test_empty_tile_set_is_not_fully_arrived() -> void: - var ts = _make_tile_set() - assert_bool(ts.is_fully_arrived()).is_false() - - -# ============================================================================= -# Progressive per-tile arrival (design doc §4: "with visible refinement as -# tiles complete") — each tile's response is independent of every other's. -# ============================================================================= - - -## Delivering ONE tile's response must populate ONLY that tile's window, -## leaving every other tile still null — the direct "progressive, not -## block-on-all" regression. -func test_one_tile_arriving_does_not_affect_the_others() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var tiles: Array = ts.get_tiles() - var first_center: Vector2i = tiles[0]["center"] - var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - var updated_tiles: Array = ts.get_tiles() - assert_that(updated_tiles[0]["window"]).override_failure_message( - "the tile whose response arrived must have its window populated" - ).is_equal(window) - for i in range(1, updated_tiles.size()): - assert_that(updated_tiles[i]["window"]).override_failure_message( - "tile %d must still be unarrived — only tile 0's response was delivered" % i - ).is_null() - - -## tile_ready must fire with the INDEX of the tile that actually arrived — -## the viewer/overlay needs this to know WHICH tile to redraw, not just -## "something changed". -func test_tile_ready_signal_fires_with_the_correct_index() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var received_indices: Array = [] - ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index)) - - var tiles: Array = ts.get_tiles() - var second_center: Vector2i = tiles[1]["center"] - var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - assert_int(received_indices.size()).is_equal(1) - assert_int(received_indices[0]).is_equal(1) - - -## Delivering EVERY tile's response must flip is_fully_arrived() to true — -## the mosaic-complete signal the viewer/legend chrome can use. -func test_all_tiles_arriving_flips_fully_arrived() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var tiles: Array = ts.get_tiles() - for tile: Dictionary in tiles: - var window: Dictionary = _mock_window( - tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - assert_bool(ts.is_fully_arrived()).override_failure_message( - "once every tile's response has arrived, the tile set must report fully arrived" - ).is_true() - - -# ============================================================================= -# PR #192 cold-start round 3 — THE launch-shape regression: a cold body's -# FIRST-EVER response is a wire-accurate whole-response Pending (status -# "Pending", district_window null, body_id only — no center/n/granularity -# anywhere, confirmed against server/src/atlas/layer_proxy.rs directly). -# Driven through the REAL fan-out (SimBridge.atlas_layers_received.emit(), -# reaching every tile via tile_set._on_atlas_layers_received), not a direct -# tile.on_response() call — the coordinator's own regression-discipline ask, -# since a direct per-tile call is implicit attribution a real wire fan-out -# doesn't have. Before the status-gate fix, on_response()'s FIRST line -# (`status != "Ready" -> return`) dropped this response for every tile -# before ever reaching the retry-scheduling code — retries stayed at 0 -# forever, matching the coordinator's own live cold-server capture exactly. -# ============================================================================= - - -## The exact bug: a whole-response Pending on cold entry must increment -## every tile's retry counter and schedule a re-poll — not be silently -## dropped. Fails hard against the pre-fix code (retries stay 0 forever). -func test_cold_entry_whole_response_pending_increments_every_tiles_retry_count() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - - SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) - - for tile_dict: Dictionary in ts._tiles: - var req = tile_dict["request"] - assert_bool(req.is_pending()).override_failure_message( - "every tile must still be pending after a whole-response Pending" - ).is_true() - assert_int(req._retries).override_failure_message( - "a whole-response Pending must increment the retry counter — the" - + " exact bug: the OLD status-gate silently dropped this before" - + " ever reaching the retry-scheduling code, leaving retries at 0 forever" - ).is_equal(1) - - -## The full convergence: repeated whole-response Pendings (simulating a slow -## cold AnalyzeBody), THEN real per-tile Ready responses for the tiles' own -## RE-REQUESTS — every tile must eventually fill. No re-request means this -## hangs (waiting past the retry delay for a re-poll that never happens) or -## fails (has_pending_tiles() never flips false) — exactly the launch-shape -## gap the coordinator named: "every unit test delivered Ready immediately." -func test_cold_entry_converges_after_repeated_pending_then_real_readies() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var tiles: Array = ts.get_tiles() - - # Two consecutive whole-response Pendings — a slow cold derive, not a - # single flip. Real waits so the scheduled retry timers can actually fire. - SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) - await get_tree().create_timer(0.7).timeout # past the first backoff delay - SimBridge.atlas_layers_received.emit(_pending_response("GJ380c")) - await get_tree().create_timer(0.8).timeout # past the second (staggered) delay - - assert_bool(ts.has_pending_tiles()).override_failure_message( - "sanity: still pending after 2 cold cycles" - ).is_true() - for tile_dict: Dictionary in ts._tiles: - var req = tile_dict["request"] - assert_int(req._retries).override_failure_message( - "sanity: each tile's retry counter must have actually incremented" - + " twice — otherwise the final assertion below would pass even if" - + " the retries never happened at all (a fresh Ready was never the" - + " broken behavior, only the retry itself was)" - ).is_equal(2) - - for tile: Dictionary in tiles: - var window: Dictionary = _mock_window( - tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - assert_bool(ts.has_pending_tiles()).override_failure_message( - "after the retries fire and real Readies land, every tile must have adopted its window" - ).is_false() - assert_bool(ts.is_fully_arrived()).is_true() - - -## NotFound must give up IMMEDIATELY, not retry — a body that doesn't exist -## will never resolve by waiting (the coordinator's own "give up on error -## responses, not elapsed patience" framing — this replaces the old -## elapsed-retries-only give-up policy with a correctness-based one for the -## cases where retrying is provably pointless). -func test_cold_entry_not_found_gives_up_immediately_without_retry() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - - SimBridge.atlas_layers_received.emit(_not_found_response("GJ380c")) - - for tile_dict: Dictionary in ts._tiles: - var req = tile_dict["request"] - assert_bool(req.is_pending()).override_failure_message( - "NotFound must give up immediately, not stay pending waiting for a retry" - ).is_false() - assert_int(req._retries).override_failure_message( - "NotFound must never increment the retry counter — retrying a" - + " nonexistent body is provably pointless" - ).is_equal(0) - - -## Error must give up immediately too, same reasoning as NotFound — a -## resolve/IO failure won't resolve itself by polling. -func test_cold_entry_error_gives_up_immediately_without_retry() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - - SimBridge.atlas_layers_received.emit(_error_response("GJ380c")) - - for tile_dict: Dictionary in ts._tiles: - var req = tile_dict["request"] - assert_bool(req.is_pending()).override_failure_message( - "Error must give up immediately, not stay pending waiting for a retry" - ).is_false() - assert_int(req._retries).is_equal(0) - - -# ============================================================================= -# has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier. -# Distinct predicates (both can be true at once, mid-arrival): the viewer's -# self-healing redraw (BUG 1) polls has_pending_tiles(); the "DERIVING -# TERRAIN…" label (BUG 3) polls has_any_tile_arrived() to know when to drop. -# ============================================================================= - - -func test_has_pending_tiles_true_immediately_after_enter() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - assert_bool(ts.has_pending_tiles()).override_failure_message( - "every tile is unarrived right after enter() — has_pending_tiles() must be true" - ).is_true() - - -func test_has_pending_tiles_false_once_every_tile_has_arrived() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - for tile: Dictionary in ts.get_tiles(): - var window: Dictionary = _mock_window( - tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_bool(ts.has_pending_tiles()).is_false() - - -func test_has_pending_tiles_true_while_only_some_tiles_have_arrived() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var first_tile: Dictionary = ts.get_tiles()[0] - var window: Dictionary = _mock_window( - first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_bool(ts.has_pending_tiles()).override_failure_message( - "5 of 6 tiles still unarrived — has_pending_tiles() must stay true" - ).is_true() - - -func test_has_any_tile_arrived_false_immediately_after_enter() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - assert_bool(ts.has_any_tile_arrived()).override_failure_message( - "nothing has arrived right after enter() — has_any_tile_arrived() must be false" - ).is_false() - - -## The exact mid-arrival case both predicates must agree can coexist: one -## tile in, five still pending — the point the "DERIVING TERRAIN…" label -## must drop (has_any_tile_arrived() flips true) while the self-heal must -## keep redrawing (has_pending_tiles() stays true). -func test_has_any_tile_arrived_true_after_a_single_tile_lands() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var first_tile: Dictionary = ts.get_tiles()[0] - var window: Dictionary = _mock_window( - first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_bool(ts.has_any_tile_arrived()).is_true() - assert_bool(ts.has_pending_tiles()).override_failure_message( - "sanity: the other 5 tiles are still pending at the same moment" - ).is_true() - - -func test_has_any_tile_arrived_false_for_an_empty_tile_set() -> void: - var ts = _make_tile_set() - assert_bool(ts.has_any_tile_arrived()).override_failure_message( - "an empty tile set (never entered) must not vacuously report arrival" - ).is_false() - - -## A response for a body the tile set is NOT currently showing (a stale -## response from a body the player has since navigated away from) must not -## be adopted by any tile — the SAME body_id staleness guard every other -## AtlasWindowRequest-based path already relies on (this is inherited for -## free since each tile IS an AtlasWindowRequest, but pinned here as an -## orchestration-level regression too). -func test_response_for_a_different_body_is_ignored() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var tiles: Array = ts.get_tiles() - var window: Dictionary = _mock_window( - tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window)) - - assert_that(ts.get_tiles()[0]["window"]).is_null() - - -# ============================================================================= -# Teardown — re-entering (a fresh body, or the same body again) must not -# leave stale tile request nodes wired up. -# ============================================================================= - - -## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or -## switching to a different body) must replace the tile set entirely — the -## OLD tiles' indices/centers must not linger. -func test_second_enter_replaces_the_tile_set() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var first_count: int = ts.get_tile_count() - assert_int(first_count).is_equal(6) - - ts.enter("TinyBody", 50.0) - assert_int(ts.get_tile_count()).override_failure_message( - "a second enter() must fully replace the tile set, not append to it" - ).is_equal(1) - - -## A response matching an OLD tile set's (body, center) — arriving AFTER a -## second enter() has already torn it down — must not be adopted (or crash): -## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no -## longer references it, so a stale signal (if it could somehow still fire) -## has no live entry left to update. -func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void: - var ts = _make_tile_set() - ts.enter("GJ380c", 6238.4) - var old_tiles: Array = ts.get_tiles() - var old_center: Vector2i = old_tiles[0]["center"] - - ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid - - # A response shaped like it's answering the OLD tile set's first tile — - # must not crash, and must not corrupt the NEW tile set's single tile. - var stale_window: Dictionary = _mock_window( - old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION - ) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window)) - - assert_int(ts.get_tile_count()).is_equal(1) diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd deleted file mode 100644 index 916ac1a49..000000000 --- a/client/tests/test_atlas_window_viewer.gd +++ /dev/null @@ -1,917 +0,0 @@ -## T-1138 (D-226 T-1124 amendment §1-§5): tests for AtlasWindowViewer + its -## companion request/cache orchestration (atlas_window_request.gd) — pure -## logic against hand-built AtlasLayerResponse-shaped dicts, matching the -## ticket's "unit tests against hand-built response dicts" instruction. Live -## end-to-end verification against a real spawned server is separate -## (companion-run evidence, not gdUnit — this file never touches SimBridge's -## live-mode path, only the response-handling/cache/overlay logic that path -## eventually feeds). -class_name TestAtlasWindowViewer -extends GdUnitTestSuite - -# atlas_window_request.gd has no class_name (review #8 precedent throughout -# this cluster) — preloaded once here, not re-load()ed per test (gdlint -# duplicated-load). -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -# T-1142: district_extent()/canonicalize_district_center() — used to derive -# real (cols/rows_half) bounds for the wrap/pole-wall tests below. -const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") -# T-1156 wave 1 round 3: AtlasWindowNatureOverlay has no class_name (matching -# atlas_overlay_bar.gd/atlas_window_request.gd's own no-class_name precedent, -# review #8) — subclassing it (the _CountingNatureOverlay spy below) needs -# the preloaded script's PATH via `extends`, not a global class name. -const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") - - -## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape -## district_grid/region_grid fixtures already use elsewhere in this suite). -static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -# ============================================================================= -# AtlasWindowViewer — entry + overlay defs -# ============================================================================= - - -func test_enter_with_no_response_leaves_window_null_and_pending() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20)) - assert_that(v.get_district_window()).is_null() - - -## Feeding a matching Ready response (via the SAME SimBridge.atlas_layers_received -## routing path the viewer subscribes to in _ready()) must populate the window. -func test_enter_then_matching_response_populates_window() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - var window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - - assert_that(v.get_district_window()).is_equal(window) - - -## A response for a DIFFERENT body must not populate the window — the -## body_id scoping AtlasWindowRequest.on_response() checks. -func test_response_for_different_body_is_ignored() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - var window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window)) - - assert_that(v.get_district_window()).is_null() - - -## A response whose echoed (center, n) does NOT match what was last asked for -## is stale — §2's race-condition guard. Simulates a superseded-by-a-later-pan -## response arriving after the fact. -func test_response_with_mismatched_echo_is_discarded_as_stale() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - var stale_window: Dictionary = _mock_window(Vector2i(99, 99), 2) # wrong center - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window)) - - assert_that(v.get_district_window()).is_null() - - -## §1: an as-yet-underived window rides as `district_window: None` inside a -## Ready response — this is NOT an error, the viewer just keeps waiting -## (get_district_window() stays null, no crash, no window content shown). -func test_ready_response_with_null_district_window_keeps_waiting() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", null)) - assert_that(v.get_district_window()).is_null() - - -func test_overlay_defs_include_the_three_toggle_ids() -> void: - var ids: Array = [] - for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS: - ids.append(d["id"]) - assert_that(ids).contains(["gen_dw_temp", "gen_dw_moisture", "gen_dw_veg"]) - - -## Glaciation is explicitly NOT a toggle id (§5: "an always-on modifier, not -## a toggle") — a regression here would silently re-introduce it as a switch. -func test_overlay_defs_do_not_include_glaciation() -> void: - var ids: Array = [] - for d: Dictionary in AtlasWindowViewer.OVERLAY_DEFS: - ids.append(d["id"]) - assert_that(ids).not_contains(["gen_dw_glaciation", "gen_dw_ice"]) - - -func test_set_overlay_visible_toggles_and_is_overlay_visible_reflects_it() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - assert_bool(v.is_overlay_visible("gen_dw_temp")).is_false() - v.set_overlay_visible("gen_dw_temp", true) - assert_bool(v.is_overlay_visible("gen_dw_temp")).is_true() - - -func test_set_overlay_visible_unknown_id_is_a_noop() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.set_overlay_visible("not_a_real_overlay", true) - assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false() - - -## PR #195 review (Hoshe): named default-visibility pins for the T-1156 -## nature overlays at fresh-viewer construction, per Araminta's ruling — -## RVR ON (rivers are load-bearing geography, a first-time Atlas viewer sees -## them without hunting for a toggle: the single most player-visible behavior -## the feature ships), BAS and ATR OFF (secondary/dev-facing analytical -## layers, opt-in). Without these, a refactor of the _ready() visibility-init -## loop could silently flip the defaults and only a live capture would -## notice — gen_basins was previously covered only incidentally by the -## toggle-redraw spy test above. -func test_nature_overlay_defaults_rivers_on_basins_and_attractors_off() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - assert_bool(v.is_overlay_visible("gen_rivers")).override_failure_message( - "gen_rivers must default ON (Araminta's RVR-on ruling, T-1156 wave 1)" - ).is_true() - assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( - "gen_basins must default OFF (opt-in analytical layer)" - ).is_false() - assert_bool(v.is_overlay_visible("gen_attractors")).override_failure_message( - "gen_attractors must default OFF (dev-facing detail, opt-in)" - ).is_false() - - -## Counts real _draw() invocations on AtlasWindowNatureOverlay — CanvasItem -## exposes no public "is a redraw pending" query in this Godot version -## (confirmed directly: is_queued_for_redraw() does not exist on Node2D here -## — an earlier version of this test assumed it did and failed with -## "Invalid call. Nonexistent function"), so the only reliable signal that -## queue_redraw() actually had an effect is the engine calling _draw() again -## on a subsequent frame. Matches test_atlas_cold_start.gd's own -## _CountingOverlay precedent exactly (same file's own doc: "the only -## reliable signal... is the engine calling _draw() again") — subclasses the -## REAL AtlasWindowNatureOverlay so drawing still runs through genuine -## production code, this spy only adds counting. -class _CountingNatureOverlay extends AtlasWindowNatureOverlay: - var draw_count := 0 - - func _draw() -> void: - draw_count += 1 - super._draw() - - -## Coordinator live-eyeball round 3 (2026-07-23): R1/R2 captures were -## byte-identical because a scratch drive script called -## set_overlay_visible("BAS", true) — the button LABEL, not the overlay id -## ("gen_basins") — which set_overlay_visible()'s own `not -## _overlay_visibility.has(overlay_id): push_warning(...); return` guard -## silently no-ops on. Real callers (atlas_overlay_bar.gd's -## _on_toggle_changed()) always pass def["id"], never the label, so product -## code was never actually broken — but this pins the EXACT gate the -## coordinator asked to verify: toggling gen_basins via the real viewer API -## must (a) flip is_overlay_visible("gen_basins") — the nature overlay's OWN -## draw gate, read live via viewer.is_overlay_visible() at _draw() time, not -## a stale copy — AND (b) actually cause the NATURE overlay (not just the -## terrain overlay/viewer) to redraw on the next frame, proven by swapping in -## a _draw()-counting spy (matching test_atlas_cold_start.gd's -## _CountingOverlay pattern) and confirming draw_count advances past a -## settled baseline after the toggle, with no other gesture. -func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( - "gen_basins must default to OFF (Araminta's ruling — BAS defaults off)" - ).is_false() - - # Swap in the counting spy (matching _CountingOverlay's own swap-after- - # construction shape) so entry's own queue_redraw() calls don't pollute - # the baseline, then let it settle before touching the toggle. - var spy := _CountingNatureOverlay.new(v) - v._nature_overlay.queue_free() - v._nature_overlay = spy - v._canvas.add_child(spy) - - await get_tree().process_frame - await get_tree().process_frame - var baseline: int = spy.draw_count - assert_int(baseline).override_failure_message( - "sanity: the spy must have drawn at least once before the toggle, or" - + " this test can't distinguish 'redrawn BY the toggle' from 'never" - + " drawn at all'" - ).is_greater(0) - - v.set_overlay_visible("gen_basins", true) - await get_tree().process_frame - - assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( - "the toggle must flip the draw gate is_overlay_visible() reads live" - ).is_true() - assert_int(spy.draw_count).override_failure_message( - "the toggle must queue_redraw() the NATURE overlay specifically —" - + " queue_redraw() on the viewer/terrain overlay alone leaves the" - + " nature node's last frame cached (a Node2D child does not redraw" - + " because its sibling did) — draw_count must have advanced past the" - + " baseline (%d)" % baseline - ).is_greater(baseline) - - -## T-1170 B3 (PR #197 review, Hoshe #3): _on_window_ready() now also calls -## _nature_overlay.queue_redraw() (atlas_window_viewer.gd:507) — courses ride -## `DistrictWindowLayer.courses`, the SAME `_window` this handler adopts, so -## a window arrival that never redraws the nature overlay would leave freshly -## arrived courses invisible until an UNRELATED pan/zoom gesture happened to -## redraw it. Same spy-and-baseline shape as -## test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() -## above — this is the exact pattern PR #196 established for "prove a -## specific queue_redraw() call site actually fires", applied to the -## window-arrival call site instead of the toggle call site. -## -## **Isolation note (live finding while writing this test):** `_on_window_ready()` -## ALSO calls `_fit_and_center()` on the first-ever arrival -## (`_awaiting_first_window and not _user_adjusted`), and `_fit_and_center()` -## itself already ends in `_apply_transform()`, which redraws the nature -## overlay through a SEPARATE, pre-existing call site. That path would mask -## a broken/removed line 507 (both call sites fire on a fresh entry's first -## arrival, so removing just one wouldn't drop draw_count below baseline). -## Setting `_user_adjusted = true` before the response arrives — the SAME -## guard a real pan/zoom gesture sets (`_maybe_refloat_window()`/`_zoom_at()`) -## — skips the fit-and-center branch, so ONLY line 507 can be the source of -## any redraw the assertion below observes. This is a real, reachable state -## (any window arrival after the player's first manual pan/zoom), not a -## test-only fiction. -func test_window_arrival_redraws_nature_overlay() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - v._user_adjusted = true # isolate line 507 from the first-arrival fit-and-center redraw - - # Swap in the counting spy AFTER enter() (matching the gen_basins test's - # own "swap after construction, then let it settle" shape) so enter()'s - # own queue_redraw() calls don't pollute the baseline. - var spy := _CountingNatureOverlay.new(v) - v._nature_overlay.queue_free() - v._nature_overlay = spy - v._canvas.add_child(spy) - - await get_tree().process_frame - await get_tree().process_frame - var baseline: int = spy.draw_count - assert_int(baseline).override_failure_message( - "sanity: the spy must have drawn at least once before the window" - + " arrives, or this test can't distinguish 'redrawn BY the arrival'" - + " from 'never drawn at all'" - ).is_greater(0) - - var window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - await get_tree().process_frame - - assert_that(v.get_district_window()).override_failure_message( - "sanity: the response must actually have been adopted (matching echo)" - + " or this test proves nothing about the arrival path specifically" - ).is_equal(window) - assert_int(spy.draw_count).override_failure_message( - "_on_window_ready() must queue_redraw() the NATURE overlay — courses" - + " ride the SAME _window this handler adopts, so a window arrival" - + " that doesn't redraw the nature overlay leaves freshly arrived" - + " courses invisible until an unrelated pan/zoom happens to redraw" - + " it — draw_count must have advanced past the baseline (%d)" % baseline - ).is_greater(baseline) - - -## The RUNG-SWAP arrival case (a later _on_window_ready() call for a -## DIFFERENT granularity_v2 than the one the viewer entered at — e.g. a -## wheel-zoom crossing from District into Quarter) — cheap to cover in the -## SAME test file per the review's own "if cheap" allowance. Confirms the -## redraw fires on EVERY window adoption, not just the first-ever one -## (T-1153's progressive-refinement doc is explicit that _window only ever -## gets REPLACED, never renulled, on a rung swap). -## -## **Uses `_window_request.request_now()` directly, NOT `_enter_at_rung()`** -## — a live finding while writing this test: `_enter_at_rung()` sets -## `_awaiting_first_window = true` again (it's the SAME reset path a fresh -## descent uses), which would route the swap response back through -## `_fit_and_center()`'s OWN redraw call site, masking line 507 exactly like -## the note on the test above. The REAL production rung-swap path, -## `_maybe_reselect_rung()`, never touches `_awaiting_first_window` at all — -## it only calls `_window_request.request_debounced(...)`. `request_now()` -## (the non-debounced sibling, same effect minus the timer) is called -## directly here to update `_window_request`'s own `_granularity_v2` — the -## exact field `_on_window_ready()`'s echo-matching guard reads — mirroring -## the real path's state change without needing a live debounce timer in a -## unit test. -func test_rung_swap_window_arrival_redraws_nature_overlay() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - - # First arrival (District, matches enter()'s own default rung) — settles - # the viewer into a held window, exactly as a real progressive-refinement - # sequence would before a rung swap. Uses the REAL (non-spy) nature - # overlay for this leg — only the swap leg itself needs the spy. - var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - await get_tree().process_frame - assert_that(v.get_district_window()).override_failure_message( - "sanity: the first (District) arrival must have been adopted before" - + " simulating the swap" - ).is_equal(district_window) - - # Now swap in the spy and simulate the RUNG SWAP itself — update the - # request's echoed granularity_v2 to "Quarter" (what - # _maybe_reselect_rung() -> request_debounced() would do on a real - # wheel-zoom crossing) WITHOUT touching _awaiting_first_window, so the - # response below takes the "not first window" branch — the genuinely - # different code path from the test above. - var spy := _CountingNatureOverlay.new(v) - v._nature_overlay.queue_free() - v._nature_overlay = spy - v._canvas.add_child(spy) - v._window_request.request_now( - "GJ380c", Vector2i(10, 20), 2, AtlasWindowRequest.GRANULARITY_V2_QUARTER - ) - - await get_tree().process_frame - await get_tree().process_frame - var baseline: int = spy.draw_count - assert_int(baseline).override_failure_message( - "sanity: the spy must have drawn at least once before the rung-swap" - + " response arrives" - ).is_greater(0) - - var quarter_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - quarter_window["granularity_v2"] = "Quarter" - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", quarter_window)) - await get_tree().process_frame - - assert_that(v.get_district_window()).override_failure_message( - "sanity: the rung-swap response must actually have been adopted" - ).is_equal(quarter_window) - assert_int(spy.draw_count).override_failure_message( - "a RUNG-SWAP window arrival (a later _on_window_ready() call at a" - + " DIFFERENT granularity_v2 than entry) must ALSO redraw the nature" - + " overlay — draw_count must have advanced past the post-first-" - + " arrival baseline (%d)" % baseline - ).is_greater(baseline) - - -# ============================================================================= -# T-1120 capture-API parity (the ticket's explicit note: must survive here too) -# ============================================================================= - - -func test_set_view_and_getters_round_trip() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.set_view(2.5, Vector2(30.0, -10.0)) - assert_that(v.get_view_zoom()).is_equal_approx(2.5, 0.001) - assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0)) - - -## T-1153: MIN_ZOOM widened to 0.0005 (from the pre-ladder 0.5) so a -## gas-giant-scale body's enter_orbital() fit zoom is never itself clamped — -## see MIN_ZOOM's own doc. Values here are chosen well outside the new wide -## range on both ends, not the old range's boundary values. -func test_set_view_clamps_to_min_max_zoom() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.set_view(0.0000001, Vector2.ZERO) - assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.0001) - v.set_view(1000.0, Vector2.ZERO) - assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001) - - -# ============================================================================= -# AtlasWindowRequest — cache reuse (§4's Esc-then-re-enter / pan-back hit) -# ============================================================================= - - -func test_window_request_cache_hit_emits_synchronously_no_pending() -> void: - var owner_stub := RefCounted.new() - var req = auto_free(AtlasWindowRequest.new(owner_stub)) - add_child(req) - - # Prime the cache directly (bypassing the network path) — the ticket's - # own instruction: unit test against hand-built response dicts. - req.get_cache().put("GJ380c", Vector2i(1, 1), 2, _mock_window(Vector2i(1, 1), 2)) - - var received: Array = [] - req.window_ready.connect(func(w: Dictionary) -> void: received.append(w)) - req.request_now("GJ380c", Vector2i(1, 1), 2) - - assert_int(received.size()).is_equal(1) - assert_bool(req.is_pending()).override_failure_message( - "a cache hit must never leave the request pending" - ).is_false() - - -func test_window_request_cache_miss_leaves_pending_true() -> void: - var owner_stub := RefCounted.new() - var req = auto_free(AtlasWindowRequest.new(owner_stub)) - add_child(req) - req.request_now("GJ380c", Vector2i(5, 5), 2) - assert_bool(req.is_pending()).is_true() - - -## on_response() with a matching Ready+window response resolves the pending -## request AND populates the cache — verified by a second request_now() call -## for the same (center, n) becoming a cache hit with zero additional pending. -func test_on_response_resolves_and_populates_cache_for_next_request() -> void: - var owner_stub := RefCounted.new() - var req = auto_free(AtlasWindowRequest.new(owner_stub)) - add_child(req) - - req.request_now("GJ380c", Vector2i(2, 2), 2) - assert_bool(req.is_pending()).is_true() - - var window: Dictionary = _mock_window(Vector2i(2, 2), 2) - req.on_response(_mock_response("GJ380c", window)) - assert_bool(req.is_pending()).is_false() - - # Re-request the SAME (body, center, n) — must be a cache hit, no pending. - req.request_now("GJ380c", Vector2i(2, 2), 2) - assert_bool(req.is_pending()).override_failure_message( - "a second request for an already-resolved window must hit the cache" - ).is_false() - - -# ============================================================================= -# T-1142 item 2: fit-and-center on entry (Jeroen's "postage stamp" finding) -# ============================================================================= - - -## enter() must fit-and-center, NOT reset to the old zoom=1.0/offset=ZERO. -## With a real viewport size set on the Control, the fitted zoom for an -## n=32 default window must scale up past 1.0 (matches -## test_atlas_window_geometry.gd's own fit math, exercised here through the -## real enter() call path instead of the pure function directly). -func test_enter_fits_and_centers_instead_of_resetting_to_zoom_one() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1920.0, 1080.0) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 32) - assert_float(v.get_view_zoom()).override_failure_message( - "an n=32 (512px native) composite in a 1920x1080 viewport must be fitted" - + " (zoom > 1.0), not left at the old zoom=1.0 postage-stamp default" - ).is_greater(1.0) - - -## After enter()'s fit, the offset must not be Vector2.ZERO (the old -## behavior) — it must be the CENTERING offset the fit produces. -func test_enter_offset_is_not_the_old_zero_default() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1920.0, 1080.0) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 32) - assert_that(v.get_view_offset()).override_failure_message( - "a fitted+centered composite in a 1920x1080 viewport should not sit at (0,0)" - ).is_not_equal(Vector2.ZERO) - - -# ============================================================================= -# T-1142 item 3: header carries the body's proper name (cheap half of T-1141) -# ============================================================================= - - -func test_header_location_label_includes_body_proper_name() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c", "proper_name": "Lendel"}, {}, Vector2i(5, 5), 2) - assert_str(v._location_label()).contains("Lendel") - - -## No proper_name on the body dict -> falls back to body_id (matches -## AtlasViewer's own _refresh_screen_header fallback chain exactly). -func test_header_location_label_falls_back_to_body_id() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ903b"}, {}, Vector2i(5, 5), 2) - assert_str(v._location_label()).contains("GJ903b") - - -func test_header_location_label_still_includes_the_coordinates() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c", "proper_name": "Lendel"}, {}, Vector2i(42, -7), 2) - var label: String = v._location_label() - assert_str(label).contains("42") - assert_str(label).contains("-7") - - -# ============================================================================= -# T-1145 item 2: WASD/edge-scroll pan REPLACES drag-pan entirely (Jeroen's -# input-model ruling — LMB-drag broke click semantics with future map -# objects). Testable-shape choice (per the ticket's explicit either/or): -# _apply_pan_delta(direction, delta) is the extracted, testable pan-tick — -# calling it DIRECTLY with a synthetic direction/delta is preferred over -# synthesizing InputEventKey events through _gui_input, because WASD panning -# is NOT event-routed at all (it is Input.is_key_pressed() polling inside -# _process(), see _held_pan_direction()'s own doc) — synthesizing a key EVENT -# would exercise nothing (no _gui_input branch reads WASD), and driving it -# through Godot's actual global Input singleton state (Input.action_press() -# et al) would work but couples every test to mutating engine-global state -# that must then be carefully reset, for zero additional coverage over -# calling the already-extracted pure-ish tick function directly. This -# confirms the HANDLER/tick logic itself (offset movement, pole wall, wrap, -# _user_adjusted, refetch) exactly as the old drag tests did; a live human -# drive (WASD held down, edge-scroll near a real screen edge) is the -## lead's own stated live-verification step for what a real key-repeat/mouse- -## position sequence produces end to end. -# ============================================================================= - - -## _apply_pan_delta() must move _view_offset — the WASD-input-model -## equivalent of the old test_drag_pan_moves_view_offset. -func test_wasd_pan_moves_view_offset() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - # body_radius_km absent -> no pole wall (identity clamp), isolating the - # pan-delta math itself from item 5's clamp in this test. - var offset_before: Vector2 = v.get_view_offset() - - v._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # "D"/east held for one tick - - assert_that(v.get_view_offset()).override_failure_message( - "a pan tick must move _view_offset away from its pre-pan value" - ).is_not_equal(offset_before) - - -## Frame-rate independence (T-1145's explicit requirement): the SAME held -## direction over a LONGER delta must move the view FARTHER — proportionally, -## not by some fixed per-tick step. Two short ticks must (within float -## rounding) equal one long tick of the combined duration. -func test_wasd_pan_is_frame_rate_independent() -> void: - var v1: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v1) - v1.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02) - v1._apply_pan_delta(Vector2(1.0, 0.0), 0.02) - - var v2: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v2) - v2.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - v2._apply_pan_delta(Vector2(1.0, 0.0), 0.04) - - assert_vector(v1.get_view_offset()).override_failure_message( - "two 0.02s ticks must move the view the same distance as one 0.04s tick" - ).is_equal_approx(v2.get_view_offset(), Vector2(0.01, 0.01)) - - -## Diagonal input (e.g. W+D held together) must NOT pan faster than a single -## axis — _apply_pan_delta() normalizes the direction before applying speed. -func test_wasd_diagonal_pan_is_not_faster_than_single_axis() -> void: - var v_diag: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v_diag) - v_diag.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - var before_diag: Vector2 = v_diag.get_view_offset() - v_diag._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # D+W (east+north) held together - var diag_distance: float = before_diag.distance_to(v_diag.get_view_offset()) - - var v_axis: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v_axis) - v_axis.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - var before_axis: Vector2 = v_axis.get_view_offset() - v_axis._apply_pan_delta(Vector2(1.0, 0.0), 0.1) # D (east) alone - var axis_distance: float = before_axis.distance_to(v_axis.get_view_offset()) - - assert_float(diag_distance).override_failure_message( - "diagonal WASD must travel the SAME distance per tick as a single axis, not faster" - ).is_equal_approx(axis_distance, 0.01) - - -## The real scene-tree path: RegionalScreen -> AtlasWindowViewer (T-1153 — -## RegionalScreen is now the WHOLE ladder's nav entry, superseding the -## retired DistrictScreen nav hop; see atlas_app.gd's own doc for why the -## separate "district" screen retired). Unlike drag (which needed -## _gui_input event delivery, hence the old "does an ancestor eat the -## event" test), WASD pan lives in _process() — Godot delivers _process() -## to every node in the tree regardless of Control mouse_filter/ancestry -## (there is no "topmost control" routing for per-frame process callbacks -## the way there is for _gui_input), so there is no equivalent "does the -## screen eat it" question for _process() itself. What DOES still matter -## through the real chain is _is_over_ui()'s edge-scroll suppression and -## visibility gating — pinned directly below instead. -func test_wasd_pan_reaches_viewer_through_regional_screen_chain() -> void: - var screen: RegionalScreen = auto_free(RegionalScreen.new()) - add_child(screen) - screen.enter({"body": {"body_id": "GJ380c"}, "system": {}}) - var offset_before: Vector2 = screen._viewer.get_view_offset() - - screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1) - - assert_that(screen._viewer.get_view_offset()).override_failure_message( - "a pan tick driven through RegionalScreen's child viewer must still move" - + " _view_offset — no ancestor in the real screen chain blocks it" - ).is_not_equal(offset_before) - - -# ============================================================================= -# T-1142 item 5: pole hard wall wired into the (now WASD) pan handler -# ============================================================================= - - -## A window already near the pole, panned FAR toward it, must have its -## offset clamped by the real _apply_pan_delta() path (not just the pure -## function in isolation — this confirms the wiring, not just the math). -## A synthetic small body (NOT GJ380c's real ~6238km radius) is used -## deliberately: with a real body's huge rows_half (~4785 for GJ380c), the -## wall sits so far away that even a long held-key tick never reaches it — -## the wall is real but the test would need an implausibly long hold to -## trigger it. A small synthetic radius (-> a small rows_half) keeps the -## wall reachable by an ordinary tick while exercising the exact same code -## path. -func test_wasd_pan_is_clamped_by_the_pole_wall_when_wired() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 800.0) - # A tiny synthetic radius -> district_extent().rows_half is small (a few - # hundred districts), so the pole wall is within reach of an ordinary - # pan tick. Center 10 districts from the north pole. - var radius_km := 50.0 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var rows_half: int = int(extent["rows_half"]) - v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(0, -rows_half + 10), 32) - - # An absurdly long single tick (500s — no real frame is ever this long, - # deliberately so the UNCLAMPED delta is orders of magnitude larger than - # any plausible wall position, making "was it actually clamped" an - # unambiguous check rather than a fragile near-boundary comparison). - var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom() - v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held - - var message: String = ( - "a pan toward the pole with an unclamped magnitude of %.0f must land" - + " nowhere near that far — the wall must have clamped it" - ) % unclamped_magnitude - assert_float(absf(v.get_view_offset().y)).override_failure_message(message).is_less( - unclamped_magnitude * 0.5 - ) - - -# ============================================================================= -# T-1142 item 6: east-west wrap — canonicalization on entry + cache reuse -# ============================================================================= - - -## enter() canonicalizes an out-of-range center BEFORE it becomes -## _held_center — a column past the body's circumference wraps into range. -func test_enter_canonicalizes_an_out_of_range_center() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6371.0 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var cols: int = int(extent["cols"]) - v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(cols + 50, 0), 32) - - var window: Dictionary = _mock_window(Vector2i(50, 0), 32) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_that(v.get_district_window()).override_failure_message( - "the response must be adopted under the CANONICALIZED center (50, 0)," - + " matching what the server would echo back for the wrapped request" - ).is_equal(window) - - -## A center ONE column past the seam (item 6b): the SAME cache key as its -## twin at column 0 — a full-circumnavigation pan back to the seam must hit -## cache, not re-derive, because both requests canonicalize to the same -## (body, center, n) key. -func test_center_one_column_past_the_seam_shares_a_cache_key_with_its_twin() -> void: - var radius_km := 6371.0 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var cols: int = int(extent["cols"]) - - var v1: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v1) - v1.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(cols, 50), 32) - - var v2: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v2) - v2.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(0, 50), 32) - - # Both must adopt the SAME server response (keyed on the same - # canonicalized center) — proves the cache key (and the outbound - # request) canonicalize identically for the seam and its twin. - var window: Dictionary = _mock_window(Vector2i(0, 50), 32) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) - assert_that(v1.get_district_window()).is_equal(window) - assert_that(v2.get_district_window()).is_equal(window) - - -# ============================================================================= -# _user_adjusted guard (PR #188 review) — the flag exists so auto-fit NEVER -# fights a manually-adjusted view. The one branch that makes that true -# (resize while user-adjusted) had no coverage; both directions pinned here. -# T-1145: the ORIGINAL version drove this through a synthetic drag sequence -# (_gui_input); drag is gone (item 2), so this now drives a WASD press -# instead — via _apply_pan_delta() directly, same testable-shape choice -# documented at the top of the WASD section above (a real key-repeat -# sequence through _gui_input would exercise nothing, since WASD panning -# never goes through _gui_input at all). -# ============================================================================= - - -func test_resize_after_manual_wasd_press_keeps_user_view() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1280.0, 720.0) - v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(10, 20), 32) - - v._apply_pan_delta(Vector2(1.0, -1.0), 0.1) # a single "D+W" tick — sets _user_adjusted - var user_zoom: float = v.get_view_zoom() - var user_offset: Vector2 = v.get_view_offset() - - v.size = Vector2(1600.0, 900.0) - v.notification(Control.NOTIFICATION_RESIZED) - - assert_float(v.get_view_zoom()).override_failure_message( - "resize while user-adjusted must NOT re-fit — zoom belongs to the user" - ).is_equal_approx(user_zoom, 0.0001) - assert_vector(v.get_view_offset()).override_failure_message( - "resize while user-adjusted must NOT re-center — offset belongs to the user" - ).is_equal_approx(user_offset, Vector2(0.001, 0.001)) - - -func test_resize_without_user_adjustment_refits() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1280.0, 720.0) - v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(10, 20), 32) - var fitted_zoom: float = v.get_view_zoom() - - v.size = Vector2(640.0, 360.0) - v.notification(Control.NOTIFICATION_RESIZED) - - assert_float(v.get_view_zoom()).override_failure_message( - "resize with no manual adjustment must re-fit to the new viewport" - ).is_not_equal(fitted_zoom) - - -# ============================================================================= -# T-1145 item 2: edge-scroll suppression — over UI (_is_over_ui reuse) and -# unfocused-window (_app_has_focus, NOTIFICATION_APPLICATION_FOCUS_OUT/IN). -# ============================================================================= - - -## Cursor within EDGE_SCROLL_MARGIN_PX of the left edge -> edge-scrolling. -func test_edge_scroll_detects_cursor_near_the_left_edge() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(10.0, 300.0) # within 24px of x=0 - assert_bool(v._is_cursor_edge_scrolling()).is_true() - - -## Cursor well inside the viewport (nowhere near any edge) -> NOT edge-scrolling. -func test_edge_scroll_does_not_trigger_away_from_any_edge() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(400.0, 300.0) # dead center - assert_bool(v._is_cursor_edge_scrolling()).is_false() - - -## Cursor near the RIGHT edge (not just left) also triggers — all four edges -## are live, not just one. -func test_edge_scroll_detects_cursor_near_the_right_edge() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(795.0, 300.0) # within 24px of x=800 - assert_bool(v._is_cursor_edge_scrolling()).is_true() - - -## The direction produced when edge-scrolling near the left edge must point -## WEST (negative X) — toward the edge the cursor is near, matching WASD's -## own "A pans toward more western content" semantics exactly (same sign -## convention, same _apply_pan_delta() consumer). -func test_edge_scroll_direction_points_toward_the_near_edge() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(5.0, 300.0) - var direction: Vector2 = v._edge_scroll_direction() - assert_float(direction.x).override_failure_message( - "edge-scroll near the LEFT edge must produce a WESTWARD (negative x) direction" - ).is_less(0.0) - assert_float(direction.y).is_equal_approx(0.0, 0.001) - - -## Reuses _is_over_ui() (the ticket's explicit instruction) — this screen's -## own _is_over_ui() always returns false today (no city panel yet, see its -## own doc), so edge-scroll near an edge must still trigger; the POINT of -## this test is pinning that the suppression call-site exists and reads -## _is_over_ui's real return value, not that it currently suppresses -## anything (nothing to suppress against yet on this screen). -func test_edge_scroll_over_ui_uses_is_over_ui() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(5.0, 300.0) - assert_bool(v._is_over_ui(v._last_mouse_pos)).override_failure_message( - "AtlasWindowViewer._is_over_ui() has no UI surface yet (see its own doc) —" - + " this pins that baseline so a future sidebar addition's test failure here" - + " signals the edge-scroll suppression wiring needs a look, not a silent pass" - ).is_false() - assert_bool(v._is_cursor_edge_scrolling()).is_true() - - -## _app_has_focus defaults true (a freshly-entered screen assumes OS focus). -func test_app_focus_defaults_true() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - assert_bool(v._app_has_focus).is_true() - - -## NOTIFICATION_APPLICATION_FOCUS_OUT flips _app_has_focus false, and edge- -## scroll must stop triggering even with the cursor still parked at an edge -## — "if detectable" per the ticket; Godot's own focus notification IS -## directly detectable, so this pins that it is actually wired. -func test_app_focus_out_suppresses_edge_scroll() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(5.0, 300.0) - assert_bool(v._is_cursor_edge_scrolling()).override_failure_message( - "sanity: edge-scroll must be live before focus-out" - ).is_true() - - v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT) - assert_bool(v._app_has_focus).is_false() - assert_bool(v._is_cursor_edge_scrolling()).override_failure_message( - "edge-scroll must be suppressed while the OS window lacks focus" - ).is_false() - - -## NOTIFICATION_APPLICATION_FOCUS_IN restores edge-scroll after a focus-out. -func test_app_focus_in_restores_edge_scroll() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v._last_mouse_pos = Vector2(5.0, 300.0) - v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT) - v.notification(Control.NOTIFICATION_APPLICATION_FOCUS_IN) - assert_bool(v._app_has_focus).is_true() - assert_bool(v._is_cursor_edge_scrolling()).is_true() - - -# ============================================================================= -# T-1145 item 2: WASD reads the PHYSICAL keycode, independent of the -# gameplay move_north/move_south/move_east/move_west InputMap actions those -# SAME keys are already bound to project-wide (D-054). This is a structural -# check, not a live-input one (gdUnit's headless mode does not transport real -# InputEvents, per this suite's own established note) — it pins that -## _held_pan_direction() calls Input.is_key_pressed() (physical keycode), NOT -## Input.is_action_pressed("move_north") or similar, by inspecting that no -## project Input Map action name appears anywhere in this function's own -## reachable behavior. The live independence claim itself (holding W pans -## the map AND does not also queue a gameplay move) is the lead's own -## live-verification step. -# ============================================================================= - - -## project.godot's move_north/move_south/move_east/move_west actions are -## ALREADY bound to W/S/A/D physical keys (confirmed by direct inspection of -## project.godot's [input] section during T-1145 implementation) — this test -## exists purely as a living pin of that fact, so the rationale in -## _held_pan_direction()'s own doc comment (why raw keycodes, not the shared -## action) stays true if the project's key bindings are ever edited. -func test_wasd_keys_are_the_same_physical_keys_as_gameplay_movement_actions() -> void: - var action_to_key: Dictionary = { - "move_north": KEY_W, "move_west": KEY_A, "move_south": KEY_S, "move_east": KEY_D - } - for action: String in action_to_key.keys(): - assert_bool(InputMap.has_action(action)).override_failure_message( - "expected gameplay action '%s' to exist in the project InputMap" % action - ).is_true() - var bound_to_key: bool = false - for input_event: InputEvent in InputMap.action_get_events(action): - if input_event is InputEventKey and (input_event as InputEventKey).physical_keycode == action_to_key[action]: - bound_to_key = true - break - assert_bool(bound_to_key).override_failure_message( - ( - "expected '%s' to be bound to physical keycode %d — if this ever" - + " stops being true, _held_pan_direction()'s own doc comment" - + " (why it reads Input.is_key_pressed() instead of the shared" - + " action) should be re-checked, not silently left stale" - ) % [action, action_to_key[action]] - ).is_true() diff --git a/client/tests/test_atlas_window_water_clip.gd b/client/tests/test_atlas_window_water_clip.gd deleted file mode 100644 index ef16c4294..000000000 --- a/client/tests/test_atlas_window_water_clip.gd +++ /dev/null @@ -1,368 +0,0 @@ -## T-1172: pure-function tests for AtlasWindowWaterClip — the two-waterline -## clip's cell-lookup math (cell_grid_side_for_window/morphology_zone_in_window/ -## resolve_morphology_zone). Split into its own file matching this cluster's -## own "one pure-geometry file, one test file" precedent -## (test_atlas_window_geometry_nature.gd next to atlas_window_geometry.gd's -## nature-overlay additions). -class_name TestAtlasWindowWaterClip -extends GdUnitTestSuite - -const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd") - -const MORPHOLOGY_OPEN_OCEAN: int = 0 -const MORPHOLOGY_LAND: int = 8 # AlluvialPlain — any non-water zone works - - -## A window dict shaped exactly like a real DistrictWindowLayer — `center` -## as a [x,y] array (the msgpack-decoded wire shape, matching every other -## mock window in this cluster's tests), `n` districts wide, `granularity_v2` -## District (1:1 cell:district, the simplest case), and a `grid_side x -## grid_side` morphology array where `grid_side == n`. -static func _mock_district_window( - center: Vector2i, n: int, morphology: PackedByteArray -) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "granularity_v2": "District", - "morphology": morphology, - } - - -# ============================================================================= -# cell_grid_side_for_window — mirrors AtlasWindowOverlay.cell_grid_side_for_window() -# ============================================================================= - - -func test_cell_grid_side_district_is_n_unchanged() -> void: - var w := {"n": 32, "granularity_v2": "District"} - assert_int(AtlasWindowWaterClip.cell_grid_side_for_window(w)).is_equal(32) - - -func test_cell_grid_side_quarter_is_n_times_four() -> void: - var w := {"n": 16, "granularity_v2": "Quarter"} - assert_int(AtlasWindowWaterClip.cell_grid_side_for_window(w)).is_equal(64) - - -func test_cell_grid_side_region_is_n_over_hundred_rounded() -> void: - var w := {"n": 6400, "granularity_v2": "Region"} - assert_int(AtlasWindowWaterClip.cell_grid_side_for_window(w)).is_equal(64) - - -func test_cell_grid_side_region_floors_at_one() -> void: - var w := {"n": 1, "granularity_v2": "Region"} - assert_int(AtlasWindowWaterClip.cell_grid_side_for_window(w)).is_equal(1) - - -func test_cell_grid_side_unknown_granularity_falls_back_to_district() -> void: - var w := {"n": 32} # no granularity_v2 key at all - assert_int(AtlasWindowWaterClip.cell_grid_side_for_window(w)).is_equal(32) - - -# ============================================================================= -# morphology_zone_in_window — single-window cell lookup -# ============================================================================= - - -## A 4x4 District window (n=4, grid_side=4) centered on district (0,0), -## spanning [-2, 2) on both axes. Cell (0,0) [top-left, covering district -## x in [-2,-1), y in [-2,-1)] is water; the rest is land. -static func _mock_4x4_window() -> Dictionary: - var morphology := PackedByteArray() - morphology.resize(16) - for i in range(16): - morphology[i] = MORPHOLOGY_LAND - morphology[0] = MORPHOLOGY_OPEN_OCEAN # row 0, col 0 - return _mock_district_window(Vector2i.ZERO, 4, morphology) - - -func test_morphology_zone_in_window_reads_the_water_cell() -> void: - var w: Dictionary = _mock_4x4_window() - # District (-1.5, -1.5) falls in cell (row 0, col 0) — the water cell. - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2(-1.5, -1.5), w) - assert_int(zone).is_equal(MORPHOLOGY_OPEN_OCEAN) - - -func test_morphology_zone_in_window_reads_a_land_cell() -> void: - var w: Dictionary = _mock_4x4_window() - # District (1.5, 1.5) falls in cell (row 3, col 3) — land. - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2(1.5, 1.5), w) - assert_int(zone).is_equal(MORPHOLOGY_LAND) - - -func test_morphology_zone_in_window_outside_extent_is_no_data() -> void: - var w: Dictionary = _mock_4x4_window() - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2(100.0, 100.0), w) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -func test_morphology_zone_in_window_null_window_is_no_data() -> void: - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2.ZERO, null) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -func test_morphology_zone_in_window_missing_morphology_array_is_no_data() -> void: - var w := {"center": [0, 0], "n": 4, "granularity_v2": "District"} - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2.ZERO, w) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -func test_morphology_zone_in_window_zero_n_is_no_data() -> void: - var w := { - "center": [0, 0], "n": 0, "granularity_v2": "District", "morphology": PackedByteArray() - } - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2.ZERO, w) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -## The exact top-left/bottom-right boundary districts must resolve to the -## edge cells, not silently miss due to an off-by-one in the containment -## test — [-2, 2) is half-open, so -2.0 is IN, 2.0 is OUT. -func test_morphology_zone_in_window_boundary_inclusive_at_min() -> void: - var w: Dictionary = _mock_4x4_window() - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2(-2.0, -2.0), w) - assert_int(zone).is_equal(MORPHOLOGY_OPEN_OCEAN) - - -func test_morphology_zone_in_window_boundary_exclusive_at_max() -> void: - var w: Dictionary = _mock_4x4_window() - var zone: int = AtlasWindowWaterClip.morphology_zone_in_window(Vector2(2.0, 2.0), w) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -# ============================================================================= -# resolve_morphology_zone — the single-window / tile-mode dispatch -# ============================================================================= - - -func test_resolve_single_window_mode_reads_the_window_directly() -> void: - var w: Dictionary = _mock_4x4_window() - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(-1.5, -1.5), false, w, [], 0 - ) - assert_int(zone).is_equal(MORPHOLOGY_OPEN_OCEAN) - - -func test_resolve_single_window_mode_null_window_is_no_data() -> void: - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone(Vector2.ZERO, false, null, [], 0) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -## Tile mode: two tiles, each its own 4x4 window, centered far enough apart -## that a queried district only falls inside ONE of them. -func test_resolve_tile_mode_finds_the_containing_tile() -> void: - var morph_a := PackedByteArray() - morph_a.resize(16) - for i in range(16): - morph_a[i] = MORPHOLOGY_LAND - var tile_a := { - "center": Vector2i(0, 0), "window": _mock_district_window(Vector2i.ZERO, 4, morph_a) - } - - var morph_b := PackedByteArray() - morph_b.resize(16) - for i in range(16): - morph_b[i] = MORPHOLOGY_OPEN_OCEAN - var tile_b := { - "center": Vector2i(100, 0), "window": _mock_district_window(Vector2i(100, 0), 4, morph_b) - } - - var tiles: Array = [tile_a, tile_b] - var zone_in_a: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(0.0, 0.0), true, null, tiles, 0 - ) - var zone_in_b: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(100.0, 0.0), true, null, tiles, 0 - ) - assert_int(zone_in_a).override_failure_message( - "a district position inside tile A's extent must read tile A's own cell" - ).is_equal(MORPHOLOGY_LAND) - assert_int(zone_in_b).override_failure_message( - "a district position inside tile B's extent must read tile B's own cell" - ).is_equal(MORPHOLOGY_OPEN_OCEAN) - - -func test_resolve_tile_mode_position_outside_every_tile_is_no_data() -> void: - var morph := PackedByteArray() - morph.resize(16) - var tile := {"center": Vector2i(0, 0), "window": _mock_district_window(Vector2i.ZERO, 4, morph)} - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(9999.0, 9999.0), true, null, [tile], 0 - ) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -## A tile whose window hasn't arrived yet (`window: null`, matching -## AtlasWindowTileSet.get_tiles()'s own "unarrived" shape) must be skipped, -## not crash — the scan continues to the next tile / falls through to -## MORPHOLOGY_ZONE_NO_DATA. -func test_resolve_tile_mode_skips_unarrived_tiles() -> void: - var unarrived := {"center": Vector2i(0, 0), "window": null} - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(0.0, 0.0), true, null, [unarrived], 0 - ) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -func test_resolve_tile_mode_empty_tile_list_is_no_data() -> void: - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone(Vector2.ZERO, true, null, [], 0) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -## Live round 2 fix (coordinator's trace, T-1172): a tile whose CANONICAL -## center is far from `held_center` (the seam-tile case — exactly Lendel's -## own live repro, tile canonical center 12739 drawn at draw_col=-6400) must -## have its CENTER wrapped toward `held_center_x` — mirroring -## AtlasWindowOverlay._draw_tile_mosaic()'s own `draw_col = -## nearest_wrap_image(center.x, held_center.x, cols)` EXACTLY — before -## testing containment. The query `district` is assumed ALREADY expressed in -## the held-center-wrapped frame (AtlasWindowNatureOverlay._district()'s own -## contract) and is NOT separately re-wrapped. -## -## Original (pre-fix) test asserted the INVERSE — wrapping the query toward -## the tile's raw canonical center — which was the actual bug: it happened -## to land inside the tile's CANONICAL (unwrapped) span by coincidental mod -## arithmetic, silently testing the WRONG real-world location whenever a -## tile needed wrapping to appear on screen at all. Live capture evidence: -## a river dot at district.x=-9569 sitting on the painter's WEST wrap-image -## of a seam tile (canonical center 12739, draw_col=-6400) read a real but -## wrong-location land cell under the old code, and only stopped doing so -## once resolve_morphology_zone() wrapped the TILE's center instead. -func test_resolve_tile_mode_wraps_the_tiles_own_center_toward_held_center() -> void: - var cols := 100 - var morph := PackedByteArray() - morph.resize(16) - for i in range(16): - morph[i] = MORPHOLOGY_OPEN_OCEAN - # Tile's canonical center is column 98 (far east) — but its nearest - # wrap-image to held_center=0 is column -2 (98 - 100), matching the - # Lendel seam tile's own shape (canonical 12739 -> draw_col -6400). - var tile := {"center": Vector2i(98, 0), "window": _mock_district_window(Vector2i(98, 0), 4, morph)} - # Query at column -2.5 — inside the tile's WRAP-IMAGE span [-4, 0), the - # real on-screen location, held_center-relative (the caller's own - # _district() contract) — NOT inside the canonical span [96, 100). - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(-2.5, 0.0), true, null, [tile], cols, 0 - ) - assert_int(zone).override_failure_message( - "the tile's CENTER must be wrapped toward held_center_x (mirroring the" - + " painter's draw_col computation) so a query already expressed in the" - + " held-center frame resolves against the tile's REAL on-screen wrap-image" - ).is_equal(MORPHOLOGY_OPEN_OCEAN) - - -## The INVERSE position — a query at the tile's CANONICAL (unwrapped) span — -## must NOT resolve against this tile once wrapping is applied, since that -## span is no longer where the tile actually draws relative to held_center. -## Pins that the fix doesn't just "also succeed at the old span" by accident. -func test_resolve_tile_mode_does_not_match_the_tiles_stale_canonical_span() -> void: - var cols := 100 - var morph := PackedByteArray() - morph.resize(16) - for i in range(16): - morph[i] = MORPHOLOGY_OPEN_OCEAN - var tile := {"center": Vector2i(98, 0), "window": _mock_district_window(Vector2i(98, 0), 4, morph)} - # Query at column 97 — inside the tile's CANONICAL span [96,100) — but - # that is NOT where this tile is drawn relative to held_center=0 (it's - # drawn at the wrap-image [-4,0) instead), so this must NOT resolve. - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(97.0, 0.0), true, null, [tile], cols, 0 - ) - assert_int(zone).override_failure_message( - "a query at the tile's stale CANONICAL span must not resolve against it" - + " once the tile is wrapped toward held_center — that span is not where" - + " the tile actually draws on screen" - ).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) - - -# ============================================================================= -# T-1172 round 2 (coordinator's "wire-accurate fixture" hardening ask — -# cold-start batch discipline): a fixture derived from an ACTUAL live tile -# response captured during the round-2 investigation, at the REAL wire scale -# (n=6400, grid_side=64, Region granularity) — not a hand-shrunk 4x4 mock. -# The round-2 bug (wrapping the query toward the tile instead of the tile -# toward held_center) passed EVERY test against the small mocks above, -## because those mocks never modeled a tile whose canonical center is FAR -# from held_center — the exact condition the bug needed to manifest. This -# fixture reproduces that condition at production scale, so a future -# regression of the same SHAPE (stub-and-code silently agreeing on a wrong -# convention) can't hide behind "the small tests still pass." -# ============================================================================= - - -## Lendel's own seam tile from the live drive capture that closed T-1172 -## round 2 (client/tmp_drive_clip.gd, SR_LIVE=1 against the worktree release -## server): canonical center (12739, -3200), TILE_N=6400 districts, -## Region granularity (grid_side = round(6400/100) = 64). `cols=19139` -## matches Lendel's real district_extent() circumference. The morphology -## array is NOT the real 4096-byte payload (too large to hand-author) — only -## the ONE cell index the live trace actually resolved for the -## district=-9569.414 repro query (idx=1024, col=0/row=16 — the exact -## INDEX_TRACE line from the live investigation) is given a real value; -## every other cell is left at 0 (OpenOcean), which is irrelevant here since -## this fixture exists to pin the WRAP resolution reaching the CORRECT -## tile/cell pair, not to re-verify the index math itself (already covered -## above and in test_atlas_window_geometry_nature.gd). -static func _lendel_seam_tile_fixture() -> Dictionary: - var morph := PackedByteArray() - morph.resize(4096) - morph[1024] = MORPHOLOGY_LAND # col=0, row=16 — the live-traced cell - return { - "center": Vector2i(12739, -3200), - "window": { - "center": [12739, -3200], - "n": 6400, - "granularity_v2": "Region", - "morphology": morph, - } - } - - -## The exact district position from the live capture that ORIGINALLY exposed -## the round-2 bug (district.x=-9569.414 — a dot visibly sitting on the -## painter's WEST wrap-image of the seam tile). held_center_x=0 (the -## viewer's canonical orbital-frame origin, cols=19139 (Lendel's real -## circumference in districts). Must resolve to the SAME land zone the live -## painter trace independently confirmed for this exact query. -## -## Honest note (found DURING revert-verification, worth recording): for a -## SINGLE tile in isolation, the old (query-wrapped-toward-tile) and new -## (tile-wrapped-toward-held_center) formulas are mathematically GUARANTEED -## to agree whenever local_x lands in-range for both — both reduce to -## `query - tile_center (mod cols)`, and a valid `local_x` is unique in -## `[0, n)`. This single-tile fixture therefore does NOT independently -## distinguish old from new (confirmed: it still passes with the pre-fix -## code) — it locks in the real wire-scale numbers as a realistic regression -## fixture (shared index formula, tile shape, wrap arithmetic all exercised -## together), not as the old-vs-new discriminator. The tests that DO reliably -## catch the round-2 regression are -## test_resolve_tile_mode_wraps_the_tiles_own_center_toward_held_center and -## test_resolve_tile_mode_does_not_match_the_tiles_stale_canonical_span above -## (confirmed: the latter fails by name against the reverted code) — the -## real-world bug's actual mechanism was the MULTI-TILE SCAN ORDER matching -## the WRONG tile's data before reaching the right one, not a single-tile -## formula divergence; a true multi-tile live reproduction would need the -## full 6-tile fixture, impractical to hand-author at full 4096-cell scale. -func test_wire_accurate_lendel_seam_tile_resolves_correctly() -> void: - var tile: Dictionary = _lendel_seam_tile_fixture() - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(-9569.414, -4784.793), true, null, [tile], 19139, 0 - ) - assert_int(zone).override_failure_message( - "the live T-1172 round 2 repro position must resolve against the seam" - + " tile's WRAPPED (on-screen) image and read the real traced land zone" - + " — a regression here reproduces the ORIGINAL over-ocean-dots bug" - ).is_equal(MORPHOLOGY_LAND) - - -## The SAME fixture, queried at a position that legitimately falls OUTSIDE -## even the wrapped tile's span (nowhere near either wrap-image) — must fail -## open (NO_DATA), not silently match by coincidental mod arithmetic (the -## general shape of the original bug, pinned generically here in case a -## future change reintroduces a different mod-arithmetic coincidence). -func test_wire_accurate_lendel_seam_tile_out_of_range_query_is_no_data() -> void: - var tile: Dictionary = _lendel_seam_tile_fixture() - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - Vector2(500.0, 500.0), true, null, [tile], 19139, 0 - ) - assert_int(zone).is_equal(AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA) diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd deleted file mode 100644 index 05d45623f..000000000 --- a/client/tests/test_atlas_zoom_ladder.gd +++ /dev/null @@ -1,952 +0,0 @@ -## T-1153 (D-226 T-1143-rulings amendment): tests for the continuous -## cursor-anchored zoom ladder — enter_orbital() (the canonical planetary -## frame), progressive refinement (held composite survives a rung-crossing -## request), the full-zoom-out reset (Jeroen's HARD condition), rung -## reselection on zoom, and E/W wrap + pole-wall clamps at Region -## granularity. Split out of test_atlas_window_viewer.gd (which owns the -## pre-T-1153 window-viewer behavior — entry, cache reuse, WASD/edge-scroll, -## fit-and-center) purely for file-length reasons (gdlint max-file-lines); -## same instantiation/mock-response conventions as that file, not a -## different testing philosophy. -class_name TestAtlasZoomLadder -extends GdUnitTestSuite - -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) — -## `u32::MAX`, a RESERVED KEY-SPACE TAG the real server ALWAYS puts in the -## legacy `granularity` slot for every Region response (never a real -## multiplier — District=1/Quarter=4 are the only legal wire multipliers). -## Do NOT "fix" this to 1 — that would silently un-repro the live-round bug -## this constant exists to guard against (a real server's actual wire byte, -## not a convenient test value). See _echoed_granularity_matches()'s own doc -## (atlas_window_request.gd) for why this value can NEVER equal a client's -## stored `_granularity` (which stays pinned at DISTRICT_GRANULARITY=1 for -## every rung a T-1152-aware client requests) — that mismatch is exactly -## what silently dropped every Region response before the v2-authoritative -## fix. -const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 - - -## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors -## test_atlas_window_viewer.gd's own _mock_window(). -static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary: - return { - "center": [center.x, center.y], - "n": n, - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -# ============================================================================= -# T-1153: enter_orbital() — the canonical planetary frame, the ladder's TOP -# REST STATE (Jeroen's HARD condition, D-226 T-1143-rulings amendment). -# ============================================================================= - - -## enter_orbital() must center on district (0,0) — "district (0,0) sits at -## lon 0 / the equator" (AtlasDescendGeometry's own doc). -func test_enter_orbital_centers_on_the_canonical_origin() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) - assert_that(v._held_center).is_equal(Vector2i.ZERO) - - -## enter_orbital() must request at Region granularity — the orbital view IS -## the Region rung at high n, not a separate screen/mode (the ticket's own -## framing). -func test_enter_orbital_requests_region_granularity() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) - assert_str(v._held_granularity_v2).is_equal("Region") - - -## **Superseded by live round 3's tiling fix — retargeted, not deleted.** -## GJ380c/Lendel (radius 6238.4 km) was the ORIGINAL single-window C1 repro -## (raw cols ~19,139 vs. the 6,400 clamp ceiling) — but that SAME threshold -## (`DISTRICT_WINDOW_MAX_N_REGION * DISTRICT_M` = the coverage ceiling -## `compute_tile_grid()` tiles past) means any body needing the n-clamp ALSO -## needs tiling: there is no real body where enter_orbital() takes the -## single-window path with a raw `n` big enough to require clamping. -## GJ380c now correctly enters TILE mode (test_enter_orbital_n_is_the_clamped_value_not_raw_circumference's -## old assertion on a single clamped `_held_n` no longer applies — see -## test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent below for -## what `_held_n` means in tile mode instead). The single-window clamp-mirror -## fix itself remains covered: `_enter_at_rung()`'s own doc/the clamp -## mirror's unit tests (test_atlas_window_request.gd) pin the formula -## directly, and test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement -## exercises the SAME clamp-mirror lesson at the reselect (not entry) -## boundary, which single-window mode still reaches on the way DOWN from a -## tile-mode zoom-in. -func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var raw_cols: int = int(extent["cols"]) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - - assert_bool(v.is_tile_mode()).override_failure_message( - "GJ380c/Lendel needs tiling — enter_orbital() must have entered tile mode" - ).is_true() - # In TILE mode, _held_n is the WHOLE body's extent (unclamped) — each - # TILE clamps its own request independently inside AtlasWindowTileSet - # (see that file's own tests), so _held_n here is NOT expected to equal - # any single clamped value the way single-window mode's is. - assert_int(v._held_n).is_equal(raw_cols) - - -## Coordinator live-eyeball dossier (2026-07-23, suspect 2 — DIAGNOSED FALSE -## but pinned as a regression guard anyway per the coordinator's own -## instruction): AtlasWindowNatureOverlay's _draw() reads -## viewer.get_held_granularity_v2() to key its per-rung policy tables -## (RIVER_CLASS_VISIBLE_BY_RUNG etc.) — if that accessor returned anything -## other than the EXACT string "Region" while is_tile_mode() is true (an -## empty string, a stale District default, a different-cased tag...), the -## visibility tables would silently return their empty/default disposition -## and NOTHING would draw, indistinguishable from the live captures' -## "literally zero river dots" symptom. Live drive-script evidence -## (NATURE_DEBUG print, since removed) confirmed this was NOT the actual bug -## — get_held_granularity_v2() already correctly returns "Region" in tile -## mode — but this test makes that fact load-bearing instead of merely -## observed once, so a future refactor of _enter_tile_mode()'s -## _held_granularity_v2 assignment trips a named failure here. -func test_get_held_granularity_v2_is_exactly_region_string_in_tile_mode() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - - assert_bool(v.is_tile_mode()).override_failure_message( - "this test's premise requires tile mode — Lendel must still need tiling" - ).is_true() - assert_str(v.get_held_granularity_v2()).override_failure_message( - "AtlasWindowNatureOverlay's _draw() keys its ENTIRE per-rung policy off" - + " this exact string — anything other than the literal 'Region' silently" - + " empties every visibility table and draws nothing, indistinguishable" - + " from the live-capture symptom (zero river dots at the orbital rest state)" - ).is_equal("Region") - - -## **The live-round-3 regression, end to end for TILE mode:** enter_orbital() -## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response -## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old -## granularity slot — exactly what a real server sends) must be ACCEPTED -## into that tile's own slot — not silently dropped. This exercises BOTH -## live-round fixes (the v2-authoritative precedence AND per-tile clamping) -## through the tile-set path specifically, complementing -## test_atlas_window_tile_set.gd's own more granular orchestration tests. -func test_enter_orbital_tile_mode_accepts_a_wire_accurate_tile_response() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - - var tile_set = v.get_tile_set() - var tiles: Array = tile_set.get_tiles() - assert_int(tiles.size()).is_greater(1) - var first_tile_center: Vector2i = tiles[0]["center"] - - var tile_window: Dictionary = { - "center": [first_tile_center.x, first_tile_center.y], - "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, - "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) - - assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message( - "a wire-accurate response (clamped n, Region granularity_v2, the legacy" - + " sentinel) for the first tile must be ACCEPTED into that tile's slot" - ).is_equal(tile_window) - - -## A no-radius body (tiny test body) has no circumference concept — -## enter_orbital() falls back to the District-rung default window rather -## than crashing or deriving a degenerate n. -func test_enter_orbital_no_radius_body_falls_back_to_district() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter_orbital({"body_id": "GJ380c"}, {}) - assert_str(v._held_granularity_v2).is_equal("District") - assert_int(v._held_n).is_equal(AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N) - - -# ============================================================================= -# T-1153: progressive refinement — the held composite survives until the -# replacement arrives (§6 "no mode flip": never a blank frame, never a -# clear-then-redraw). -# ============================================================================= - - -## The core acceptance test: once a window is held, a request for a -## DIFFERENT rung being in-flight must NOT clear `_window` — the old -## composite stays exactly what get_district_window() returns until the new -## rung's response actually arrives and is adopted. -func test_held_window_survives_while_a_different_rung_request_is_in_flight() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - assert_that(v.get_district_window()).is_equal(district_window) - - # Simulate a rung-reselect firing a NEW (Region) request without the - # response having arrived yet — direct call, mirroring what - # _maybe_reselect_rung() does internally. - v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") - - assert_that(v.get_district_window()).override_failure_message( - "the OLD composite must survive while a different-rung request is in" - + " flight — no blank frame, no premature clear" - ).is_equal(district_window) - - -## Once the new rung's response actually arrives (matching the CURRENTLY -## in-flight request's granularity_v2), it swaps in — the composite reference -## changes from the old rung's window to the new one. -func test_new_rung_window_swaps_in_once_it_arrives() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - - v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") - var region_window: Dictionary = { - "center": [10, 20], "n": 2, "granularity_v2": "Region", - "morphology": PackedByteArray([1, 2, 3, 4]), - "elev_q": PackedByteArray([10, 20, 30, 40]), - "temp_dc": [0, 0, 0, 0], - "moisture_q": PackedByteArray([0, 0, 0, 0]), - "vegetation": PackedByteArray([0, 0, 0, 0]), - "glaciation": PackedByteArray([0, 0, 0, 0]), - } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", region_window)) - - assert_that(v.get_district_window()).override_failure_message( - "once the new rung's matching response arrives, it must swap in" - ).is_equal(region_window) - assert_str(v._held_granularity_v2).is_equal("Region") - - -## refresh() clear()s via queue_free() (deferred, not synchronous) — a legend -## that has refreshed more than once in the same frame (build-time refresh at -## _ready(), then an entry-time refresh) can have STALE not-yet-freed -## children still parented alongside the new ones. add_component() always -## APPENDS, so the current ImplantHeader is the LAST one in the list, never -## assumed to be [0]. -static func _current_legend_header(legend_panel) -> ImplantHeader: - var children: Array = legend_panel.get_implant_children() - for i in range(children.size() - 1, -1, -1): - if children[i] is ImplantHeader: - return children[i] - return null - - -## PR #192 review (Araminta, BLOCKING): the legend subtitle used to hardcode -## District's own "2.048 km/cell" — a 100x lie whenever the viewer actually -## holds Region (204.8 km/cell). While in the orbital tile-mode rest state -## (Region granularity), the legend must read Region's real spacing, not the -## stale District literal. -func test_legend_subtitle_reflects_region_spacing_in_tile_mode() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling, so enters at Region - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - assert_str(v._held_granularity_v2).is_equal("Region") - - var header: ImplantHeader = _current_legend_header(v._legend_panel) - assert_str(header._subtitle_label.text).override_failure_message( - "legend subtitle must reflect Region's real 204.800 km/cell spacing while" - + " the viewer holds Region granularity, not a hardcoded District figure" - ).contains("204.800 km/cell") - - -## Same bug, the other direction: after crossing INTO a single-window District -## rung, the legend must re-render with District's own spacing — proving the -## legend actually refreshes on a rung change rather than being stuck at -## whatever it showed on the FIRST refresh() call (T-1153's _build_legend_panel() -## fires one at _ready() time, before any real rung is held). -func test_legend_subtitle_reflects_district_spacing_after_crossing_in() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - assert_str(v._held_granularity_v2).is_equal("District") - - var header: ImplantHeader = _current_legend_header(v._legend_panel) - assert_str(header._subtitle_label.text).override_failure_message( - "legend subtitle must re-render at District's own 2.048 km/cell spacing" - + " once the viewer holds a District-rung window — proving refresh() is" - + " actually wired to the rung change, not just called once at build time" - ).contains("2.048 km/cell") - - -## A response for a rung OTHER than what's currently requested (e.g. a -## District response arriving after the viewer has already moved on to a -## Region request — a rapid wheel-zoom race) must be discarded as stale, the -## held composite untouched. -func test_stale_rung_response_after_moving_on_is_discarded() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) - var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - - v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region") - # A LATE district-rung response for the same (center, n) arrives after the - # viewer has already moved on to requesting Region — must be dropped. - var late_district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) - late_district_window["morphology"] = PackedByteArray([9, 9, 9, 9]) # distinguishable payload - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", late_district_window)) - - assert_that(v.get_district_window()).override_failure_message( - "a stale response for a rung the viewer has since moved on from must be discarded" - ).is_equal(district_window) - - -# ============================================================================= -# T-1153: full-zoom-out reset (Jeroen's HARD condition). -# ============================================================================= - - -## Directly at the canonical frame already (center (0,0), Region granularity) -## must be a no-op — never re-fights a player zooming back IN from the top. -func test_reset_to_canonical_frame_is_noop_when_already_there() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) - v._held_center = Vector2i.ZERO - v._held_granularity_v2 = "Region" - var fired: bool = v._maybe_reset_to_canonical_frame() - assert_bool(fired).override_failure_message( - "already at the canonical frame — the reset must not re-fire" - ).is_false() - - -## A no-radius body must never trigger the reset (no circumference concept — -## matches enter_orbital()'s own guard). -func test_reset_to_canonical_frame_never_fires_for_no_radius_body() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(5, 5), 2) - var fired: bool = v._maybe_reset_to_canonical_frame() - assert_bool(fired).is_false() - - -## Away from the canonical frame (a drifted District-rung pan/zoom state) -## with a fully-zoomed-out world extent must reset — the direct wiring test -## for Jeroen's HARD condition: enter() at a far-off center, then force the -## view zoom low enough that the displayed extent covers the whole body. -func test_reset_to_canonical_frame_fires_and_re_centers_when_fully_zoomed_out() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - var radius_km := 50.0 # tiny synthetic body — small circumference, reachable by a modest zoom-out - v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(500, 10), 32) - # Force a very low zoom — a huge displayed world extent, comfortably over - # this tiny body's whole circumference. - v._view_zoom = AtlasWindowViewer.MIN_ZOOM - - var fired: bool = v._maybe_reset_to_canonical_frame() - - assert_bool(fired).override_failure_message( - "a fully-zoomed-out view on a real-radius body must trigger the reset" - ).is_true() - assert_that(v._held_center).override_failure_message( - "the reset must re-center on the canonical origin (0,0)" - ).is_equal(Vector2i.ZERO) - assert_str(v._held_granularity_v2).override_failure_message( - "the reset must land on the Region rung — the ladder's top rest state" - ).is_equal("Region") - - -## Live round 5's OWN repro, end to end: enter a TILING body's canonical -## frame, wheel-zoom IN far enough to cross out of tile mode (leaving -## `_held_granularity_v2` STALE at "Region" — a real, expected lag per -## `_maybe_reselect_rung()`'s own "does NOT touch _held_granularity_v2" -## doc, not a bug in that function), then wheel-zoom back OUT past the -## fully-zoomed-out threshold. The reset must fire and land EXACTLY on -## enter_orbital()'s own fit zoom for this body/viewport — not merely -## re-center while leaving `_view_zoom` wherever continued `_zoom_at()` -## scaling left it. Before the fix, the stale "Region" granularity -## satisfied the guard's OLD (center + granularity only) check forever, -## so the reset never fired again and `_view_zoom` kept shrinking via -## plain multiplication all the way to MIN_ZOOM. -func test_reset_after_crossing_out_and_back_snaps_to_the_canonical_fit_zoom() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: Lendel must enter tile mode — this repro needs a TILING body," - + " since that's where _held_granularity_v2 can lag is_tile_mode()" - ).is_true() - - # Zoom IN far enough to cross out of tile mode (matching - # test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen's - # own gesture shape). - var cursor_pos := Vector2(800.0, 450.0) - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: this test needs to actually leave tile mode before zooming back out" - ).is_false() - assert_str(v._held_granularity_v2).override_failure_message( - "sanity: _held_granularity_v2 must be STALE at Region here (no mock response" - + " ever adopted a new value) — this is the exact lagging-field condition" - + " the guard fix targets, not an artificial setup" - ).is_equal("Region") - - # Zoom back OUT past the fully-zoomed-out threshold — the reset must fire - # (possibly after a few more _zoom_at() ticks, matching a real wheel - # gesture rather than asserting it fires on the very first step back). - for _i in range(200): - v._zoom_at(cursor_pos, 1.0 / 1.05) - if v.is_tile_mode(): - break - - assert_bool(v.is_tile_mode()).override_failure_message( - "zooming back out past the threshold must re-fire the reset and land back" - + " in tile mode — the stale-granularity guard bug left this permanently false" - ).is_true() - assert_that(v._held_center).is_equal(Vector2i.ZERO) - - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var n: int = int(extent["cols"]) - var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( - v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM - ) - assert_float(v._view_zoom).override_failure_message( - ( - "post-reset _view_zoom (%.6f) must equal enter_orbital()'s own fit zoom" - + " (%.6f) for this body/viewport — Jeroen's condition is the ORIGINAL" - + " frame (center AND offset AND fit zoom), not merely re-centered at" - + " whatever zoom continued _zoom_at() scaling left behind" - ) - % [v._view_zoom, expected_fit["zoom"]] - ).is_equal_approx(float(expected_fit["zoom"]), 0.000001) - - -## Live round 5's OWN live-drive repro, exactly: a REAL wheel gesture does -## NOT stop the instant the reset first fires — the coordinator's own -## tmp_drive_ladder.gd keeps sending wheel-down ticks toward a fixed target -## zoom (0.004, chosen below the fit zoom) regardless of the reset. This -## test reproduces that shape directly: continue zooming out PAST the point -## where the reset first re-enters tile mode, all the way to a target zoom -## BELOW the fit value. Before the round-5 fix, `_view_zoom` drifted back -## down from the fit value on every subsequent `_zoom_at()` tick while the -## mode/center/granularity guard read "already canonical" and silently let -## it drift, landing on whatever the LOOP's target zoom happened to be -## instead of the fit value. **Live round 6 update:** the MECHANISM that -## now holds this assertion changed — `_zoom_at()`'s own zoom FLOOR (not a -## re-firing reset) is what keeps `_view_zoom` pinned at fit through -## continued zoom-out ticks; see `_maybe_reset_to_canonical_frame()`'s own -## doc for why re-firing on every tick caused a request storm. This test's -## own assertions are unchanged — only the doc below was updated to match. -func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var n: int = int(extent["cols"]) - var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view( - v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM - ) - var fit_zoom: float = float(expected_fit["zoom"]) - - # Zoom IN far enough to leave tile mode (same shape as the test above). - var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).is_false() - - # Zoom back OUT toward a target BELOW the fit zoom — matching - # tmp_drive_ladder.gd's own `_zoom_until(wv, 0.004, false)` exactly - # (Lendel's own fit zoom is ~0.00627, comfortably above this target), - # WITHOUT stopping early the moment tile mode is first regained. A real - # wheel gesture has no way to know when the reset internally fires. - var target_zoom := 0.004 - for _i in range(200): - if v._view_zoom <= target_zoom: - break - v._zoom_at(cursor_pos, 1.0 / 1.05) - - assert_bool(v.is_tile_mode()).override_failure_message( - "after continued zoom-out past the reset point, the view must settle back" - + " into tile mode — a genuinely re-snapped canonical frame can't have zoomed" - + " OUT further than the fit value in the first place" - ).is_true() - assert_float(v._view_zoom).override_failure_message( - ( - "post-reset _view_zoom (%.6f) must equal the canonical fit zoom (%.6f) even" - + " though the wheel gesture continued past the point where the reset first" - + " fired (target was %.6f, BELOW the fit zoom) — _zoom_at()'s own zoom floor" - + " must keep pinning it at fit through every subsequent tick, not just once" - ) - % [v._view_zoom, fit_zoom, target_zoom] - ).is_equal_approx(fit_zoom, 0.000001) - - -## Live round 6's ANTI-STORM test — the exact repro the coordinator's live -## drive caught: drive a REAL continued zoom-out gesture (via `_zoom_at()`, -## the same call path the live drive uses — NOT calling -## `_maybe_reset_to_canonical_frame()` directly with unchanged state, which -## trivially can't reproduce the drift the storm depends on) many ticks past -## the point where the reset first fires — asserts ZERO additional tile-set -## entries occur across the WHOLE gesture. Spies on `AtlasWindowTileSet`'s -## own child `AtlasWindowRequest` node INSTANCES (captured right after the -## FIRST reset) — a fresh `enter_orbital()` call tears down (`queue_free()`s) -## every one of them and creates BRAND NEW ones, so "the same node -## instances are still alive and still the tile set's children after 100 -## more ticks" is a direct, non-invasive proxy for "the reset never fired -## again" — no new production instrumentation needed. Before the round-6 -## fix, `_zoom_at()`'s continued multiplicative zoom-out drifted `_view_zoom` -## below fit on every subsequent tick, the level-triggered guard read "not -## already there" every time, and `enter_orbital()` fired repeatedly: -## tearing down and recreating the tile set (and its 6 request nodes) every -## tick — exactly the "889 of 897 wire responses arrived during one -## zoom-out phase" storm. -func test_reset_evaluated_repeatedly_at_canonical_frame_issues_zero_additional_requests() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - - # Zoom IN far enough to leave tile mode, then zoom back OUT past the - # first reset — same shape as the round-5 continued-zoom-out test, but - # this time spying on the tile set across the WHOLE remaining gesture - # instead of only checking the final zoom value. - var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).is_false() - - for _i in range(200): - v._zoom_at(cursor_pos, 1.0 / 1.05) - if v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: the first reset must have fired before spying on the tile set" - ).is_true() - - var tile_set = v.get_tile_set() - var original_requests: Array = tile_set.get_children() - assert_int(original_requests.size()).override_failure_message( - "sanity: the first reset must have created real tile-request child nodes to spy on" - ).is_greater(0) - - # Continue the SAME zoom-out gesture 100 MORE ticks past the first - # reset — a real wheel gesture has no way to stop exactly at the reset - # point, and holding the wheel down (or residual scroll momentum) keeps - # sending ticks. None of these must tear down/recreate the tile set. - for _i in range(100): - v._zoom_at(cursor_pos, 1.0 / 1.05) - - var current_requests: Array = tile_set.get_children() - assert_int(current_requests.size()).override_failure_message( - "the tile set's child count must be unchanged after 100 more continued" - + " zoom-out ticks — a changed count means teardown/recreate happened" - ).is_equal(original_requests.size()) - for i in range(original_requests.size()): - assert_bool(is_instance_valid(original_requests[i])).override_failure_message( - "original tile-request node #%d must still be alive — a storm would have" - + " queue_free()'d it and created a fresh one" % i - ).is_true() - assert_bool(is_same(original_requests[i], current_requests[i])).override_failure_message( - ( - "tile-request node #%d must be the SAME instance as right after the" - + " first reset — a different object at the same index means the tile" - + " set was torn down and recreated (a storm), even if the count" - + " coincidentally matches" - ) - % i - ).is_true() - - -## Live round 6's BLACK-ENTRY repro: enter_orbital(), then deliver the six -## wire-accurate tile responses WHILE a REAL continued zoom-out gesture (via -## `_zoom_at()`, matching the live drive's actual input shape — a held -## wheel-down keeps sending ticks concurrently with responses streaming in -## from the server) is in flight — asserts all six are accepted and HELD -## (tile set stable throughout, no teardown between delivery and the final -## assertion). Before the round-6 fix, the level-triggered guard fired on -## every zoom-out tick once `_view_zoom` drifted below fit, tearing down the -## tile set mid-delivery and orphaning responses addressed to now-freed -## request nodes — nothing ever accumulated, and the mosaic stayed black -## even though the server dutifully answered every request. -func test_six_tile_responses_survive_concurrent_reset_evaluation_and_are_held() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — 6 tiles, the live-repro shape - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - - var tile_set = v.get_tile_set() - var tiles: Array = tile_set.get_tiles() - assert_int(tiles.size()).override_failure_message( - "sanity: Lendel must produce Lendel's own real tile count (6) for this" - + " repro to be faithful, not a smaller synthetic count" - ).is_equal(6) - - # Same continued zoom-out gesture as the anti-storm test above — leave - # tile mode, cross back into it (the first reset), then KEEP sending - # zoom-out ticks (a real held wheel has no way to stop exactly at the - # reset point). Responses are delivered interleaved with these ticks, - # exactly matching the live drive's concurrent shape. - var cursor_pos := Vector2(1100.0, 300.0) - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).is_false() - for _i in range(200): - v._zoom_at(cursor_pos, 1.0 / 1.05) - if v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: the first reset must have fired before delivering responses" - ).is_true() - - for i in range(tiles.size()): - var center: Vector2i = tiles[i]["center"] - var tile_window: Dictionary = { - "center": [center.x, center.y], - "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, - "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) - # Interleave several MORE continued zoom-out ticks, matching the live - # drive's per-frame cadence — none of these must tear anything down. - for _tick in range(5): - v._zoom_at(cursor_pos, 1.0 / 1.05) - - var final_tiles: Array = tile_set.get_tiles() - assert_int(final_tiles.size()).override_failure_message( - "the tile set must still have all 6 tile slots — a storm mid-delivery" - + " would have torn it down and rebuilt it with fresh (unfulfilled) slots" - ).is_equal(6) - for i in range(final_tiles.size()): - assert_that(final_tiles[i]["window"]).override_failure_message( - ( - "tile #%d's window must be HELD (non-null) — all six wire-accurate" - + " responses delivered during a concurrent continued zoom-out gesture" - + " must survive to be accepted, not be silently dropped by an" - + " orphaning teardown" - ) - % i - ).is_not_null() - - -## Not fully zoomed out (a normal District-rung view) must NOT trigger the -## reset — only reaching the top of the ladder resets, not every zoom step. -func test_reset_to_canonical_frame_does_not_fire_when_not_fully_zoomed_out() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(500, 10), 32) - v._view_zoom = 1.0 # a normal, non-extreme zoom — nowhere near full planetary coverage - - var fired: bool = v._maybe_reset_to_canonical_frame() - - assert_bool(fired).override_failure_message( - "an ordinary District-rung view must not trigger the top-rest-state reset" - ).is_false() - - -# ============================================================================= -# T-1153: rung reselection — _zoom_at() crossing a rung threshold fires a -# new request without touching the held composite. -# ============================================================================= - - -## Zooming OUT far enough from a District-rung window (small n, so a modest -## zoom-out already covers a huge world extent) must fire a coarser-rung -## request — the wheel-zoom-driven wiring test for _maybe_reselect_rung(). -func test_zoom_out_past_district_threshold_requests_a_coarser_rung() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 600.0) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 2) # n=2 — a tiny window, easy to overshoot - var district_window: Dictionary = _mock_window(Vector2i(0, 0), 2) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - assert_str(v._window_request.get_granularity_v2()).is_equal("District") - - # A big zoom-OUT factor (well under 1.0) from a tiny n=2 window blows the - # displayed world extent WAY past District's threshold. - v._zoom_at(Vector2(400.0, 300.0), 0.01) - - assert_str(v._window_request.get_granularity_v2()).override_failure_message( - "zooming out far enough from a small District window must re-request a coarser rung" - ).is_not_equal("District") - # The OLD composite must still be what's held — progressive refinement, - # not a block-on-derive clear. - assert_that(v.get_district_window()).is_equal(district_window) - - -## Zooming IN on a District-rung window (well within its own legal coverage -## band, `(32,768 m, 131,072 m]` per select_rung()'s redesigned per-rung -## ceiling model — viewport-independent since `canvas_px` no longer affects -## selection) must NOT trigger a rung change — this is the "zoom is -## client-side on the already-held composite" case, unchanged for in-rung -## zoom. Sets _view_zoom DIRECTLY to a value inside District's band (rather -## than relying on enter()'s COVER auto-fit, which for a small n can already -## sit right at Quarter's own threshold — a fit's zoom level is a -## display-density choice independent of what rung selection would pick from -## scratch, and this test is specifically about a SINGLE zoom-in STEP not -## crossing a boundary, not about where the auto-fit itself lands). The -## small 100x80 viewport here is incidental (any size works under the new -## viewport-independent model) — kept small only because that's what the -## original version of this test used. -func test_zoom_in_within_district_threshold_does_not_change_rung() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(100.0, 80.0) - v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32) - var district_window: Dictionary = _mock_window(Vector2i(0, 0), 32) - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) - v._view_zoom = 0.10666666666666667 # E=120,000m at C=100px — inside District's legal band - v._apply_transform() - - v._zoom_at(Vector2(50.0, 40.0), 1.15) # a single ordinary zoom-in step - - assert_str(v._window_request.get_granularity_v2()).override_failure_message( - "a single ordinary zoom-in step must not cross a rung threshold" - ).is_equal("District") - - -## **Live round 3 regression, the direct end-to-end fix target:** a real -## wheel-zoom gesture (many `_zoom_at()` ticks, matching the shape a -## continuous mouse-wheel scroll actually produces) crossing from the -## Region rest state down through District into Quarter territory must (i) -## fire a request at the NEW granularity — `_window_request.get_granularity_v2()` -## must have changed by the end of the gesture — and (ii) accept a -## WIRE-ACCURATE response for that request: echoing the REQUEST's own -## (already re-centered, already re-clamped) center/n, which the live round -## found DIFFERS from the ORIGINAL held center (screen-center-anchored -## refinement re-centers on wherever the cursor currently maps to, not -## wherever the player started) — this is the "second latent drop" the -## coordinator specifically flagged: comparing the echo against a STALE -## `_held_center` (frozen at the pre-crossing value) rather than the -## request's own center would silently drop this response too. -## **Live round 3 update:** GJ380c/Lendel now enters TILE mode via -## enter_orbital() (bug B's fix), so this test starts from THERE — zooming -## in far enough crosses Region's coverage ceiling and must LEAVE tile mode -## for the single-window path at the new (finer) rung, exactly the -## `_maybe_reselect_rung()` "leaving_tile_mode" branch this test exercises. -func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).override_failure_message( - "GJ380c/Lendel must enter tile mode at the orbital rest state (live round 3)" - ).is_true() - - # A real wheel-zoom gesture: many ticks, cursor OFF-CENTER (so cursor- - # anchored zoom genuinely drifts the screen-to-district mapping away from - # the canonical origin, not just scaling in place) — matching the live - # drive's actual input shape, not a single synthetic jump. Zooming in far - # enough must cross OUT of Region's coverage ceiling, leaving tile mode. - var cursor_pos := Vector2(1100.0, 300.0) # off-center, biased toward one quadrant - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - - # (i) Tile mode must have been LEFT, and a request must have gone out at - # a NEW (finer) granularity via the single-window path. - assert_bool(v.is_tile_mode()).override_failure_message( - "zooming in far enough must leave tile mode for the single-window path" - ).is_false() - var request_granularity: String = v._window_request.get_granularity_v2() - assert_str(request_granularity).override_failure_message( - "leaving tile mode must fire a request at a new (finer) granularity" - ).is_not_equal("Region") - - # (ii) The request's own center/n — read AFTER leaving tile mode, so this - # is whatever _maybe_reselect_rung() actually computed — is what a - # wire-accurate response must echo to be accepted. - var request_center: Vector2i = v._window_request._center - var request_n: int = v._window_request._n - - var refinement_window: Dictionary = { - "center": [request_center.x, request_center.y], - "n": request_n, - "granularity_v2": request_granularity, - "morphology": PackedByteArray([1, 2, 3, 4]), - "elev_q": PackedByteArray([10, 20, 30, 40]), - "temp_dc": [0, 0, 0, 0], - "moisture_q": PackedByteArray([0, 0, 0, 0]), - "vegetation": PackedByteArray([0, 0, 0, 0]), - "glaciation": PackedByteArray([0, 0, 0, 0]), - } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", refinement_window)) - - assert_that(v.get_district_window()).override_failure_message( - "a wire-accurate refinement response (echoing the REQUEST's own center/n/" - + " granularity after leaving tile mode) must be ACCEPTED — comparing" - + " against a stale/wrong reference instead of the request's own would" - + " silently drop this response forever" - ).is_equal(refinement_window) - assert_str(v._held_granularity_v2).is_equal(request_granularity) - - -## Live round 4's SECOND bug, pinned directly: `_maybe_reselect_rung()` must -## recompute `_view_offset` (via AtlasWindowGeometry. -## recompute_offset_for_held_n_change()) the instant `_held_n` changes across -## a rung crossing — leaving it untouched (the round-4 bug) means the single- -## window `Rect2(0,0,extent)` draw call renders at whatever screen position -## the OLD (Region-scale) offset happened to put canvas-local (0,0), which -## for a whole-body `held_n` vs. a 64-district District `held_n` is tens or -## hundreds of thousands of px away from the viewport — the exact "pitch -## black" repro. Asserts the NEW held window's own extent actually overlaps -## the viewport after the crossing, the concrete on-screen consequence a -## stale offset breaks. -func test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(1600.0, 900.0) - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_bool(v.is_tile_mode()).is_true() - - var cursor_pos := Vector2(1100.0, 300.0) - for _i in range(60): - v._zoom_at(cursor_pos, 1.15) - if not v.is_tile_mode(): - break - assert_bool(v.is_tile_mode()).override_failure_message( - "sanity: this test needs to actually cross out of tile mode to exercise" - + " the held_n change _maybe_reselect_rung() must react to" - ).is_false() - - # The new (post-crossing) window's screen-space rect, using the SAME - # formula the overlay's single-window _draw() itself uses - # (Rect2(0,0,extent,extent) in canvas-local space, then _canvas's own - # position/scale transform — _view_offset/_view_zoom here mirror that - # exactly, since _apply_transform() is what sets _canvas.position/scale). - var extent_screen: float = float(v._held_n) * v.CELL_PIXEL_SIZE * v._view_zoom - var screen_top_left: Vector2 = v._view_offset - var screen_bottom_right: Vector2 = screen_top_left + Vector2(extent_screen, extent_screen) - var viewport_rect := Rect2(Vector2.ZERO, v.size) - var window_rect := Rect2(screen_top_left, Vector2(extent_screen, extent_screen)) - - assert_bool(viewport_rect.intersects(window_rect)).override_failure_message( - ( - "the new (post-crossing) held window's screen rect %s must overlap the" - + " viewport %s — a stale _view_offset (never recomputed for the new" - + " held_n=%d) is exactly live round 4's 'pitch black' bug: the composite" - + " renders somewhere entirely off-canvas despite request/response/data" - + " all being individually correct" - ) - % [window_rect, viewport_rect, v._held_n] - ).is_true() - - -# ============================================================================= -# T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent- -# relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless -# of which rung's data is actually held), so they must keep working -# unmodified at Region granularity, not just District/Quarter. -# ============================================================================= - - -## The pole wall, wired through the real _apply_pan_delta() path, must still -## clamp at Region granularity — same mechanism as the existing District-rung -## test (test_wasd_pan_is_clamped_by_the_pole_wall_when_wired), just entered -## via enter_orbital() instead of enter(). -func test_pole_wall_clamps_at_region_granularity_too() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 800.0) - var radius_km := 50.0 # tiny synthetic body — pole wall reachable by an ordinary tick - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_str(v._held_granularity_v2).is_equal("Region") - - var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom() - v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held, an absurdly long tick - - assert_float(absf(v.get_view_offset().y)).override_failure_message( - "the pole wall must still clamp an extreme pan at Region granularity" - ).is_less(unclamped_magnitude * 0.5) - - -## East-west wrap (canonicalize_district_center()) must still apply to the -## pan-edge refloat's resulting center at Region granularity — a pan that -## carries the screen-center column past the body's circumference must wrap -## into [0, cols), never run away to an out-of-range column, exactly as the -## District-rung wrap tests already pin (T-1142 item 6a). At Region's own -## enormous held_n (a whole circumference), an ORDINARY pan tick's -## canvas-space delta is negligible relative to the window's half-extent -## (confirmed: ~0.08 districts per 5-second tick vs. a ~9,772-district -## half-window) — so this drives _maybe_refloat_window() DIRECTLY off a -## manually-set _view_offset large enough to genuinely cross the held -## window's edge, the same "exercise the actual edge-crossing branch, not -## just its no-op early-return" discipline _maybe_refloat_window()'s own -## inside-check comment describes. -func test_pan_edge_refloat_wraps_columns_at_region_granularity_too() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - v.size = Vector2(800.0, 800.0) - var radius_km := 6371.0 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var cols: int = int(extent["cols"]) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_str(v._held_granularity_v2).is_equal("Region") - - # Force the held center to sit one column short of the wrap seam, then - # shift the CANVAS offset by more than half the window's own on-screen - # extent — enough to move the screen-center's mapped column past the - # window's far edge (i.e. past `cols`, crossing the seam) regardless of - # Region's huge held_n. - v._held_center = Vector2i(cols - 1, 0) - var half_window_screen_px: float = float(v._held_n) * v.get_cell_pixel_size() * v.get_view_zoom() * 0.5 - v._view_offset = v.get_view_offset() - Vector2(half_window_screen_px * 1.5, 0.0) - v._maybe_refloat_window() - - assert_int(v._held_center.x).override_failure_message( - "a pan crossing the antimeridian at Region granularity must wrap the" - + " resulting center into [0, cols), never run past cols" - ).is_less(cols) - assert_int(v._held_center.x).is_greater_equal(0) diff --git a/client/tests/test_regional_screen.gd b/client/tests/test_regional_screen.gd deleted file mode 100644 index 0ea2d32ab..000000000 --- a/client/tests/test_regional_screen.gd +++ /dev/null @@ -1,90 +0,0 @@ -## PR #192 cold-start dossier (BUG 2): RegionalScreen.enter() tests. Split -## into its own file rather than folded into test_atlas_zoom_ladder.gd — -## these exercise the NAV-LAYER re-entry guard (RegionalScreen itself), not -## AtlasWindowViewer's own zoom-ladder mechanics that suite already owns. -class_name TestRegionalScreen -extends GdUnitTestSuite - -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") - -## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors -## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the -## real wire value matters, not a convenient placeholder). -const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295 - - -static func _mock_response(body_id: String, window: Variant) -> Dictionary: - return {"body_id": body_id, "status": "Ready", "district_window": window} - - -## BUG 2 root cause: ImplantApp._on_screen_changed() calls enter() -## UNCONDITIONALLY on every screen_changed, including a repeat -## nav.push("regional", ...) landing on the SAME screen already showing -## (reachable from more than one input path — body-click, panel+Enter — and -## plausible for a player to trigger twice on a slow cold server before the -## first descent settles). Without RegionalScreen's own guard, a repeat -## entry re-ran the FULL enter_orbital() teardown/rebuild — tearing down -## every in-flight tile request node and rebuilding fresh (null-window) ones -## — orphaning whatever had already arrived, plus refreshing the legend -## from scratch each time (the confirmed ~10x legend stack). Proven here by -## an ARRIVED tile's data: a teardown+rebuild resets it to null; a genuine -## no-op leaves it exactly as it was — `is_same()` on `_tile_set` itself -## can't tell (that Node is a fixed field, never reassigned — only its -## INTERNAL request children get torn down and rebuilt). -func test_repeat_enter_for_the_same_body_does_not_orphan_an_arrived_tile() -> void: - var screen: RegionalScreen = auto_free(RegionalScreen.new()) - add_child(screen) - var body: Dictionary = {"body_id": "GJ380c", "body_radius_km": 6238.4} - screen.enter({"body": body, "system": {}}) - - var tile_set = screen._viewer.get_tile_set() - var first_tile_center: Vector2i = tile_set.get_tiles()[0]["center"] - var arrived_window: Dictionary = { - "center": [first_tile_center.x, first_tile_center.y], - "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, - "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, - "granularity_v2": "Region", - "morphology": PackedByteArray([8, 14, 0, 1]), - "elev_q": PackedByteArray([40, 90, 5, 60]), - "temp_dc": [120, 95, -32768, 60], - "moisture_q": PackedByteArray([50, 30, 90, 20]), - "vegetation": PackedByteArray([2, 1, 6, 3]), - "glaciation": PackedByteArray([0, 0, 1, 2]), - } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", arrived_window)) - assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message( - "sanity: the first tile's response must have been adopted before the repeat enter()" - ).is_equal(arrived_window) - - screen.enter({"body": body, "system": {}}) - - assert_that(screen._viewer.get_tile_set().get_tiles()[0]["window"]).override_failure_message( - "a repeat enter() for the SAME body must not orphan an already-arrived" - + " tile — a teardown+rebuild resets every tile's window back to null," - + " which is the confirmed source of the cold-start legend stacking bug" - ).is_equal(arrived_window) - - -## A GENUINE body change (different body_id) must still enter fresh — the -## guard is scoped to "the same body, re-entered", never a blanket "ignore -## the second enter() call ever". -func test_enter_for_a_different_body_still_re_enters() -> void: - var screen: RegionalScreen = auto_free(RegionalScreen.new()) - add_child(screen) - screen.enter({"body": {"body_id": "GJ380c", "body_radius_km": 6238.4}, "system": {}}) - - screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}}) - - assert_str(screen._viewer.get_body_id()).override_failure_message( - "a genuinely different body must still re-enter — not be swallowed by" - + " the same-body guard" - ).is_equal("OtherBody") - - -## get_body_id() itself: empty before any entry, the entered body's id after. -func test_get_body_id_reflects_the_currently_held_body() -> void: - var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) - add_child(v) - assert_str(v.get_body_id()).is_equal("") - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) - assert_str(v.get_body_id()).is_equal("GJ380c") diff --git a/client/tests/test_step_canvas_annotation_layer.gd b/client/tests/test_step_canvas_annotation_layer.gd new file mode 100644 index 000000000..5c15967d7 --- /dev/null +++ b/client/tests/test_step_canvas_annotation_layer.gd @@ -0,0 +1,76 @@ +## T-1182 tests: StepCanvasAnnotationLayer — the unscaled screen-space +## sibling's world->screen placement math (course polylines, settlement +## markers) and course visibility/terminus handling. Draw-call correctness +## itself needs a live render pass (this cluster's existing "state-level is +## fine" allowance, per test_atlas_descend_entry.gd's own precedent) — these +## tests pin the FRAME state (_world_to_local, _cell_center_world_m) a draw +## call would read from, without requiring a SubViewport. +class_name TestStepCanvasAnnotationLayer +extends GdUnitTestSuite + +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + + +func test_set_frame_stores_the_frame_and_triggers_no_crash_on_draw() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var canvas := { + "width": 4, + "height": 4, + "courses": [], + "settlement_id": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + } + layer.set_frame(canvas, Vector2(1000.0, 2000.0), "District", Vector2i(4, 4)) + # No assertion beyond "did not crash" — set_frame()/queue_redraw() with a + # well-formed empty-feature canvas is the baseline no-op path every + # richer test below builds on. + assert_object(layer).is_not_null() + + +func test_clear_frame_drops_the_held_canvas() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + layer.set_frame({"width": 1, "height": 1, "courses": []}, Vector2.ZERO, "Chunk", Vector2i(1, 1)) + layer.clear_frame() + assert_that(layer._canvas).is_null() + + +## _cell_center_world_m() is the inverse of step_canvas.rs's own per-cell +## placement (center_world_m + (col - half_w) * step_m) — a settlement id +## read from cell (col, row) must map back to the world point that cell was +## actually derived at. +func test_cell_center_world_m_matches_the_servers_own_per_cell_placement() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + layer.set_frame({"width": 4, "height": 4, "courses": []}, Vector2(0.0, 0.0), "District", Vector2i(4, 4)) + + # half_w = half_h = 2; spacing = 2048. Cell (0,0) -> (0-2)*2048 = -4096 on + # both axes; cell (2,2) (the center-ish cell) -> (2-2)*2048 = 0. + assert_that(layer._cell_center_world_m(0, 0)).is_equal(Vector2(-4096.0, -4096.0)) + assert_that(layer._cell_center_world_m(2, 2)).is_equal(Vector2.ZERO) + + +func test_cell_center_world_m_offsets_by_the_frames_world_center() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + layer.set_frame( + {"width": 2, "height": 2, "courses": []}, Vector2(10_000.0, 20_000.0), "Chunk", Vector2i(2, 2) + ) + # half_w = half_h = 1; spacing = 64. Cell (1,1) -> center + (1-1)*64 = center. + assert_that(layer._cell_center_world_m(1, 1)).is_equal(Vector2(10_000.0, 20_000.0)) + + +## _world_to_local() delegates to StepCanvasTransport.world_m_to_canvas_local +## with the layer's OWN held frame — this pins that the layer actually reads +## its stored _world_center/_rung/_extent_cells, not stale defaults. +func test_world_to_local_uses_the_held_frame() -> void: + var layer: StepCanvasAnnotationLayer = auto_free(StepCanvasAnnotationLayer.new()) + add_child(layer) + var world_center := Vector2(5_000.0, -3_000.0) + var extent := Vector2i(32, 32) + layer.set_frame({"width": 32, "height": 32, "courses": []}, world_center, "Quarter", extent) + + var expected: Vector2 = StepCanvasTransport.world_m_to_canvas_local( + world_center, world_center, "Quarter", extent + ) + assert_that(layer._world_to_local(world_center)).is_equal_approx(expected, Vector2(0.01, 0.01)) diff --git a/client/tests/test_step_canvas_cache.gd b/client/tests/test_step_canvas_cache.gd new file mode 100644 index 000000000..7eebe7579 --- /dev/null +++ b/client/tests/test_step_canvas_cache.gd @@ -0,0 +1,123 @@ +## T-1182 tests: step_canvas_cache.gd — the client-side in-memory LRU cache +## for decoded step-canvas payloads. Keyed on (body_id, rung, center, extent, +## min_wl_m), the exact tuple server/src/atlas/step_canvas.rs's own +## StepCanvasCache keys on. Mirrors test_atlas_window_cache.gd's own +## conventions (the surviving LRU shape this file is adapted from). +class_name TestStepCanvasCache +extends GdUnitTestSuite + +const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd") + + +func test_make_key_distinguishes_body_rung_center_and_extent() -> void: + var k1 := StepCanvasCache.make_key("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64)) + var k2 := StepCanvasCache.make_key("GJ1d", "District", Vector2i(10, 20), Vector2i(64, 64)) + var k3 := StepCanvasCache.make_key("GJ1c", "Chunk", Vector2i(10, 20), Vector2i(64, 64)) + var k4 := StepCanvasCache.make_key("GJ1c", "District", Vector2i(11, 20), Vector2i(64, 64)) + var k5 := StepCanvasCache.make_key("GJ1c", "District", Vector2i(10, 20), Vector2i(32, 32)) + assert_str(k1).is_not_equal(k2) + assert_str(k1).is_not_equal(k3) + assert_str(k1).is_not_equal(k4) + assert_str(k1).is_not_equal(k5) + + +## Global collapses center/extent to a fixed sentinel regardless of what's +## passed — every Global request for the same body_id must land on ONE slot. +func test_make_key_global_ignores_center_and_extent() -> void: + var k1 := StepCanvasCache.make_key("GJ1c", "Global", Vector2i(10, 20), Vector2i(64, 64)) + var k2 := StepCanvasCache.make_key("GJ1c", "Global", Vector2i(999, -999), Vector2i(1, 1)) + assert_str(k1).is_equal(k2) + + +func test_miss_returns_null_and_has_reports_false() -> void: + var cache := StepCanvasCache.new() + assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_null() + assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false() + + +func test_put_then_get_round_trips_exact_canvas() -> void: + var cache := StepCanvasCache.new() + var canvas := {"width": 64, "height": 64} + cache.put("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64), canvas) + assert_bool(cache.has("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_true() + assert_that(cache.get_canvas("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_equal( + canvas + ) + + +## D-227: a canvas fetched once is valid FOREVER for that exact key — no +## expiry, no invalidation path. +func test_cached_canvas_never_expires() -> void: + var cache := StepCanvasCache.new() + var canvas := {"width": 1} + cache.put("GJ1c", "Chunk", Vector2i.ZERO, Vector2i(1, 1), canvas) + for _i in range(50): + assert_that(cache.get_canvas("GJ1c", "Chunk", Vector2i.ZERO, Vector2i(1, 1))).is_equal(canvas) + + +func test_different_rungs_at_identical_center_extent_do_not_collide() -> void: + var cache := StepCanvasCache.new() + var district_canvas := {"rung": "District"} + var chunk_canvas := {"rung": "Chunk"} + cache.put("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64), district_canvas) + cache.put("GJ1c", "Chunk", Vector2i(10, 20), Vector2i(64, 64), chunk_canvas) + assert_int(cache.size()).is_equal(2) + assert_that(cache.get_canvas("GJ1c", "District", Vector2i(10, 20), Vector2i(64, 64))).is_equal( + district_canvas + ) + assert_that(cache.get_canvas("GJ1c", "Chunk", Vector2i(10, 20), Vector2i(64, 64))).is_equal( + chunk_canvas + ) + + +func test_put_overwrites_existing_key() -> void: + var cache := StepCanvasCache.new() + cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), {"v": 1}) + cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), {"v": 2}) + assert_int(cache.size()).is_equal(1) + assert_that(cache.get_canvas("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_equal( + {"v": 2} + ) + + +func test_eviction_drops_least_recently_used_on_overflow() -> void: + var cache := StepCanvasCache.new(2) + cache.put("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64), {"id": "a"}) + cache.put("GJ1c", "District", Vector2i(1, 0), Vector2i(64, 64), {"id": "b"}) + cache.put("GJ1c", "District", Vector2i(2, 0), Vector2i(64, 64), {"id": "c"}) + + assert_int(cache.size()).is_equal(2) + assert_bool(cache.has("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64))).override_failure_message( + "oldest entry should have been evicted" + ).is_false() + assert_bool(cache.has("GJ1c", "District", Vector2i(2, 0), Vector2i(64, 64))).is_true() + + +func test_get_touches_entry_and_protects_it_from_eviction() -> void: + var cache := StepCanvasCache.new(2) + cache.put("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64), {"id": "a"}) + cache.put("GJ1c", "District", Vector2i(1, 0), Vector2i(64, 64), {"id": "b"}) + cache.get_canvas("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + cache.put("GJ1c", "District", Vector2i(2, 0), Vector2i(64, 64), {"id": "c"}) + + assert_bool(cache.has("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64))).override_failure_message( + "touched entry should survive eviction" + ).is_true() + assert_bool(cache.has("GJ1c", "District", Vector2i(1, 0), Vector2i(64, 64))).override_failure_message( + "untouched entry should be the one evicted" + ).is_false() + + +func test_max_entries_clamped_to_at_least_one() -> void: + var cache := StepCanvasCache.new(0) + cache.put("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64), {"id": "a"}) + cache.put("GJ1c", "District", Vector2i(1, 0), Vector2i(64, 64), {"id": "b"}) + assert_int(cache.size()).is_equal(1) + + +func test_clear_empties_the_cache() -> void: + var cache := StepCanvasCache.new() + cache.put("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64), {"id": "a"}) + cache.clear() + assert_int(cache.size()).is_equal(0) + assert_bool(cache.has("GJ1c", "District", Vector2i.ZERO, Vector2i(64, 64))).is_false() diff --git a/client/tests/test_step_canvas_colorize.gd b/client/tests/test_step_canvas_colorize.gd new file mode 100644 index 000000000..d1f7978ae --- /dev/null +++ b/client/tests/test_step_canvas_colorize.gd @@ -0,0 +1,128 @@ +## T-1182 tests: step_canvas_colorize.gd — per-cell colorize mapping (c1 +## ruling: CPU Image.set_pixel, reusing AtlasOverlayColors' existing ramp +## functions verbatim). Builds small synthetic L8 Images directly (no PNG +## decode round-trip needed here — that's step_canvas_terrain_layer's own +## job, covered separately) to pin the CellPlanes -> Color mapping in +## isolation. +class_name TestStepCanvasColorize +extends GdUnitTestSuite + +const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd") +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + + +static func _l8_image(values: Array, width: int, height: int) -> Image: + var img := Image.create(width, height, false, Image.FORMAT_L8) + for i in range(values.size()): + var col: int = i % width + var row: int = i / width + var v: float = float(values[i]) / 255.0 + img.set_pixel(col, row, Color(v, v, v, 1.0)) + return img + + +static func _planes( + morphology: Array, elev_q: Array, moisture_q: Array, vegetation: Array, glaciation: Array, + temp_dc: Array, width: int, height: int +) -> StepCanvasColorize.CellPlanes: + var p := StepCanvasColorize.CellPlanes.new() + p.width = width + p.height = height + p.morphology = _l8_image(morphology, width, height) + p.elev_q = _l8_image(elev_q, width, height) + p.moisture_q = _l8_image(moisture_q, width, height) + p.vegetation = _l8_image(vegetation, width, height) + p.glaciation = _l8_image(glaciation, width, height) + p.temp_dc = temp_dc + return p + + +func test_base_color_reads_morphology_and_elevation_together() -> void: + var planes := _planes([8], [50], [0], [0], [0], [], 1, 1) + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, "") + var base: Color = AtlasOverlayColors.district_window_morphology_color(8) + var expected: Color = AtlasOverlayColors.district_window_elevation_lightness(base, 50) + _assert_color_approx(got, expected) + + +func test_temp_toggle_replaces_base_reading_entirely() -> void: + var planes := _planes([8], [50], [0], [0], [0], [120], 1, 1) + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_TEMP) + var expected: Color = AtlasOverlayColors.region_temp_color(120) + _assert_color_approx(got, expected) + + +func test_temp_toggle_airless_sentinel_is_transparent() -> void: + var planes := _planes([8], [50], [0], [0], [0], [-32768], 1, 1) + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_TEMP) + assert_that(got).is_equal(Color.TRANSPARENT) + + +func test_moisture_toggle_ramps_dry_to_wet() -> void: + var planes := _planes([8], [50], [100], [0], [0], [], 1, 1) + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_MOISTURE) + _assert_color_approx(got, StepCanvasColorize.COLOR_MOISTURE_WET) + + +func test_vegetation_toggle_marine_is_transparent() -> void: + var planes := _planes([8], [50], [0], [6], [0], [], 1, 1) # VEGETATION_MARINE = 6 + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, StepCanvasColorize.TOGGLE_VEGETATION) + assert_that(got).is_equal(Color.TRANSPARENT) + + +func test_glaciation_modifier_tints_the_base_layer() -> void: + var planes_no_ice := _planes([8], [50], [0], [0], [0], [], 1, 1) + var planes_ice_cap := _planes([8], [50], [0], [0], [4], [], 1, 1) # grade 4 = ice cap + var base_color: Color = StepCanvasColorize.cell_color(planes_no_ice, 0, 0, "") + var tinted: Color = StepCanvasColorize.cell_color(planes_ice_cap, 0, 0, "") + assert_that(tinted).is_not_equal(base_color) + + +func test_glaciation_grade_zero_is_a_no_op() -> void: + var planes := _planes([8], [50], [0], [0], [0], [], 1, 1) + var base_color: Color = AtlasOverlayColors.district_window_elevation_lightness( + AtlasOverlayColors.district_window_morphology_color(8), 50 + ) + var got: Color = StepCanvasColorize.cell_color(planes, 0, 0, "") + _assert_color_approx(got, base_color) + + +func test_null_plane_falls_back_to_zero_rather_than_crashing() -> void: + var p := StepCanvasColorize.CellPlanes.new() + p.width = 1 + p.height = 1 + p.morphology = null + p.elev_q = null + p.moisture_q = null + p.vegetation = null + p.glaciation = null + p.temp_dc = [] + # Must not crash — morphology/elev_q both read as 0 (OpenOcean, elev 0). + var got: Color = StepCanvasColorize.cell_color(p, 0, 0, "") + assert_that(got).is_equal( + AtlasOverlayColors.district_window_elevation_lightness( + AtlasOverlayColors.district_window_morphology_color(0), 0 + ) + ) + + +func test_out_of_bounds_cell_index_returns_zero_not_a_crash() -> void: + var planes := _planes([8], [50], [0], [0], [0], [], 1, 1) + var got: Color = StepCanvasColorize.cell_color(planes, 99, 99, "") + assert_that(got).is_equal( + AtlasOverlayColors.district_window_elevation_lightness( + AtlasOverlayColors.district_window_morphology_color(0), 0 + ) + ) + + +## gdUnit4's generic assert_that() has no Color-typed is_equal_approx() — +## per-channel float comparison, matching test_atlas_window_colors.gd's own +## _assert_color_approx() precedent (this is a deliberate duplicate, same +## rationale that file's own header gives for its palette-constant copies: +## each file owns its own small pure helper rather than a cross-suite import). +func _assert_color_approx(actual: Color, expected: Color) -> void: + assert_float(actual.r).is_equal_approx(expected.r, 0.01) + assert_float(actual.g).is_equal_approx(expected.g, 0.01) + assert_float(actual.b).is_equal_approx(expected.b, 0.01) + assert_float(actual.a).is_equal_approx(expected.a, 0.01) diff --git a/client/tests/test_step_canvas_protocol.gd b/client/tests/test_step_canvas_protocol.gd new file mode 100644 index 000000000..558b5524c --- /dev/null +++ b/client/tests/test_step_canvas_protocol.gd @@ -0,0 +1,212 @@ +## T-1182 tests: StepCanvasRequest/StepCanvasResponse wire codec +## (step_canvas_protocol.gd, delegated via protocol.gd) — the D-255(c) +## tagged-envelope carrier. Mirrors test_atlas_data_delivery.gd's own +## encode/decode round-trip + decode_inbound classification conventions. +class_name TestStepCanvasProtocol +extends GdUnitTestSuite + + +# ============================================================================= +# Encode +# ============================================================================= + + +func test_encode_step_canvas_request_carries_discriminator_and_fields() -> void: + var bytes := Protocol.encode_step_canvas_request( + "GJ380c", "District", Vector2i(10, 20), Vector2i(64, 64), 512 + ) + assert_int(bytes.size()).is_greater(0) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status == null).is_true() + var raw: Dictionary = decoded.value + assert_bool(raw.get("step_canvas")).is_true() + assert_str(raw.get("body_id")).is_equal("GJ380c") + assert_str(raw.get("rung")).is_equal("District") + assert_that(raw.get("center")).is_equal([10, 20]) + assert_that(raw.get("extent")).is_equal([64, 64]) + assert_int(raw.get("min_wl_m")).is_equal(512) + + +## The rung is sent as a bare string (rmp_serde's unit-variant convention), +## never a raw integer — a Global request must carry the literal tag +## "Global", matching step_canvas.rs's own StepCanvasRung enum encoding. +func test_encode_step_canvas_request_global_rung_is_bare_string() -> void: + var bytes := Protocol.encode_step_canvas_request( + "GJ380c", "Global", Vector2i.ZERO, Vector2i.ZERO, 0 + ) + var decoded = Messagepack.decode(bytes) + assert_str(decoded.value.get("rung")).is_equal("Global") + + +# ============================================================================= +# Decode — status variants +# ============================================================================= + + +func test_step_canvas_response_from_raw_decodes_ready_status() -> void: + var raw := { + "body_id": "GJ380c", + "rung": "District", + "center": [10, 20], + "extent": [64, 64], + "min_wl_m": 0, + "status": "Ready", + "canvas": null, + } + var decoded = Protocol.step_canvas_response_from_raw(raw) + assert_str(decoded["status"]).is_equal("Ready") + assert_str(decoded["error"]).is_equal("") + assert_str(decoded["rung"]).is_equal("District") + assert_that(decoded["center"]).is_equal(Vector2i(10, 20)) + assert_that(decoded["extent"]).is_equal(Vector2i(64, 64)) + + +func test_step_canvas_response_from_raw_decodes_pending_status() -> void: + var raw := {"body_id": "GJ380c", "rung": "Chunk", "status": "Pending"} + var decoded = Protocol.step_canvas_response_from_raw(raw) + assert_str(decoded["status"]).is_equal("Pending") + assert_that(decoded["canvas"]).is_null() + + +func test_step_canvas_response_from_raw_decodes_error_status() -> void: + var raw := { + "body_id": "GJ380c", "rung": "Region", "status": {"Error": "no heightmap"} + } + var decoded = Protocol.step_canvas_response_from_raw(raw) + assert_str(decoded["status"]).is_equal("Error") + assert_str(decoded["error"]).is_equal("no heightmap") + + +## Not a step-canvas response (no "rung" key) -> null, so decode_inbound's +## dispatch doesn't misroute a plain AtlasLayerResponse here. +func test_step_canvas_response_from_raw_returns_null_without_rung_key() -> void: + var raw := {"body_id": "GJ380c", "status": "Ready"} + assert_that(Protocol.step_canvas_response_from_raw(raw)).is_null() + + +# ============================================================================= +# decode_inbound classification — the "rung" discriminator must win BEFORE +# the generic "status"-only AtlasLayerResponse fallback (a step-canvas +# response also carries "status"). +# ============================================================================= + + +func test_decode_inbound_classifies_step_canvas() -> void: + var raw := {"body_id": "GJ380c", "rung": "District", "status": "Pending"} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound["kind"]).is_equal("step_canvas") + + +func test_decode_inbound_still_classifies_plain_atlas_response() -> void: + var raw := {"body_id": "GJ380c", "status": "Ready"} + var encoded = Messagepack.encode(raw) + var inbound: Dictionary = Protocol.decode_inbound(encoded.value) + assert_str(inbound["kind"]).is_equal("atlas") + + +# ============================================================================= +# EncodedStepCanvas — the PNG-per-field array-of-int wire shape +# (step_canvas.rs's png_bytes: Vec with NO serde_bytes anywhere in this +# codebase serializes via serialize_seq, a msgpack ARRAY of ints, never a +# `bin` blob — decode_png_field()/​_decode_encoded_canvas() must repack that +# Array into a PackedByteArray, not expect messagepack.gd's bin_8/16/32 path). +# ============================================================================= + + +func test_decode_png_field_repacks_array_of_ints_to_packed_byte_array() -> void: + # A 1x1 all-black L8 PNG's real byte stream, as it would arrive already + # decoded off the wire (a plain Array of ints, one per byte) — using real + # PNG magic bytes so a downstream Image.load_png_from_buffer() call + # would also succeed, not just this repack step in isolation. + var png_bytes := PackedByteArray([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + var as_array: Array = [] + for b in png_bytes: + as_array.append(b) + var field_raw := {"png_bytes": as_array} + var result: PackedByteArray = Protocol.step_canvas_response_from_raw( + { + "body_id": "GJ380c", + "rung": "Chunk", + "status": "Ready", + "canvas": + { + "width": 1, + "height": 1, + "morphology": field_raw, + "elev_q": {}, + "temp_dc": {"values": []}, + "moisture_q": {}, + "vegetation": {}, + "settlement_id": {"values": []}, + "glaciation": {}, + "flooded_q": {}, + "courses": [], + "cliffs": [], + }, + } + )["canvas"]["morphology"] + assert_that(result).is_equal(png_bytes) + + +func test_decode_encoded_canvas_passes_through_temp_dc_and_settlement_id_as_arrays() -> void: + var raw := { + "body_id": "GJ380c", + "rung": "Chunk", + "status": "Ready", + "canvas": + { + "width": 2, + "height": 1, + "morphology": {}, + "elev_q": {}, + "temp_dc": {"values": [120, -32768]}, + "moisture_q": {}, + "vegetation": {}, + "settlement_id": {"values": [0, 7]}, + "glaciation": {}, + "flooded_q": {}, + "courses": [], + "cliffs": [], + }, + } + var decoded = Protocol.step_canvas_response_from_raw(raw) + var canvas: Dictionary = decoded["canvas"] + assert_that(canvas["temp_dc"]).is_equal([120, -32768]) + assert_that(canvas["settlement_id"]).is_equal([0, 7]) + + +func test_decode_encoded_canvas_passes_through_courses_and_cliffs_unshaped() -> void: + var courses := [{"edge_id": 1, "class": 2, "points": [[0, 0], [100, 100]], "terminus": "Mouth"}] + var cliffs := [{"point": [5, 5], "channel_depth_dm": 10, "cliff_edge": true}] + var raw := { + "body_id": "GJ380c", + "rung": "Chunk", + "status": "Ready", + "canvas": + { + "width": 1, + "height": 1, + "morphology": {}, + "elev_q": {}, + "temp_dc": {"values": []}, + "moisture_q": {}, + "vegetation": {}, + "settlement_id": {"values": []}, + "glaciation": {}, + "flooded_q": {}, + "courses": courses, + "cliffs": cliffs, + }, + } + var decoded = Protocol.step_canvas_response_from_raw(raw) + var canvas: Dictionary = decoded["canvas"] + assert_that(canvas["courses"]).is_equal(courses) + assert_that(canvas["cliffs"]).is_equal(cliffs) + + +func test_decode_png_field_malformed_input_returns_empty_packed_byte_array() -> void: + assert_that(Protocol._scp().decode_png_field(null)).is_equal(PackedByteArray()) + assert_that(Protocol._scp().decode_png_field({"png_bytes": "not an array"})).is_equal( + PackedByteArray() + ) diff --git a/client/tests/test_step_canvas_request.gd b/client/tests/test_step_canvas_request.gd new file mode 100644 index 000000000..d5d4aa5d5 --- /dev/null +++ b/client/tests/test_step_canvas_request.gd @@ -0,0 +1,208 @@ +## T-1182 tests: step_canvas_request.gd — request lifecycle (cache hit/miss, +## staleness gate, the extent ECHO rule). Live mode is required for +## SimBridge.request_step_canvas() to actually send (test_mode short-circuits +## it), so these tests exercise on_response()/request_now() against a +## directly-constructed StepCanvasRequest node without a live bridge +## connection — request_now() on a cache MISS calls into SimBridge, which is +## a silent no-op in test_mode (SimBridge.test_mode defaults true outside +## SR_LIVE=1), so no live server is needed for these assertions. +class_name TestStepCanvasRequest +extends GdUnitTestSuite + +const StepCanvasRequestScript := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_request.gd") + + +func test_cache_hit_emits_canvas_ready_synchronously_with_no_pending_state() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + var canvas := {"width": 64, "height": 64} + req.get_cache().put("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64), canvas) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.request_now("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + + assert_int(received.size()).is_equal(1) + assert_that(received[0]).is_equal(canvas) + assert_bool(req.is_pending()).is_false() + + +func test_cache_miss_sets_pending_true() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "Chunk", Vector2i(0, 0), Vector2i(64, 64)) + assert_bool(req.is_pending()).is_true() + + +func test_on_response_ignores_a_response_for_a_different_body() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.on_response( + { + "body_id": "SomeOtherBody", + "rung": "District", + "center": Vector2i(0, 0), + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 1}, + } + ) + assert_int(received.size()).is_equal(0) + + +func test_on_response_ignores_a_response_for_a_different_rung() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.on_response( + { + "body_id": "GJ1c", + "rung": "Chunk", # a different rung answering — must be ignored + "center": Vector2i(0, 0), + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 1}, + } + ) + assert_int(received.size()).is_equal(0) + + +func test_on_response_ignores_a_stale_center_for_a_fixed_rung() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.on_response( + { + "body_id": "GJ1c", + "rung": "District", + "center": Vector2i(999, 999), # answers a since-panned-away-from center + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 1}, + } + ) + assert_int(received.size()).is_equal(0) + + +## Global ignores center/extent server-side — a Global response's staleness +## check must NOT compare center at all (an echoed (0,0) sentinel must not +## be rejected as "stale" against whatever was requested). +func test_on_response_global_rung_ignores_center_in_staleness_check() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.on_response( + { + "body_id": "GJ1c", + "rung": "Global", + "center": Vector2i.ZERO, + "extent": Vector2i.ZERO, + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 19_139, "height": 9_569}, + } + ) + assert_int(received.size()).is_equal(1) + + +## The extent ECHO rule (T-1181 wire addendum, mandatory): held extent comes +## from the RESPONSE's own echoed extent, never the requested one — a +## server-side clamp can shrink the actual canvas below what was asked for. +func test_on_response_holds_the_echoed_extent_not_the_requested_one() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "Chunk", Vector2i(0, 0), Vector2i(9_999, 9_999)) + + req.on_response( + { + "body_id": "GJ1c", + "rung": "Chunk", + "center": Vector2i(0, 0), + "extent": Vector2i(3_840, 2_160), # server-clamped echo, smaller than requested + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 3_840, "height": 2_160}, + } + ) + assert_that(req.get_held_extent()).is_equal(Vector2i(3_840, 2_160)) + + +## Global's held extent comes from the canvas's own width/height (the wire +## extent echo is a fixed (0,0) sentinel for that rung — nothing to read). +func test_on_response_global_held_extent_derives_from_canvas_dimensions() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "Global", Vector2i.ZERO, Vector2i.ZERO) + + req.on_response( + { + "body_id": "GJ1c", + "rung": "Global", + "center": Vector2i.ZERO, + "extent": Vector2i.ZERO, + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 19_139, "height": 9_569}, + } + ) + assert_that(req.get_held_extent()).is_equal(Vector2i(19_139, 9_569)) + + +func test_on_response_ready_with_null_canvas_retries_rather_than_adopting() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "District", Vector2i(0, 0), Vector2i(64, 64)) + + var received: Array = [] + req.canvas_ready.connect(func(c: Dictionary) -> void: received.append(c)) + req.on_response( + { + "body_id": "GJ1c", + "rung": "District", + "center": Vector2i(0, 0), + "min_wl_m": 0, + "status": "Ready", + "canvas": null, + } + ) + assert_int(received.size()).is_equal(0) + assert_bool(req.is_pending()).is_true() + + +func test_on_response_not_found_gives_up_immediately() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "Chunk", Vector2i(0, 0), Vector2i(64, 64)) + req.on_response({"body_id": "GJ1c", "rung": "Chunk", "status": "NotFound"}) + assert_bool(req.is_pending()).is_false() + + +func test_on_response_stores_a_fresh_ready_canvas_in_the_cache() -> void: + var req = auto_free(StepCanvasRequestScript.new()) + add_child(req) + req.request_now("GJ1c", "District", Vector2i(5, 5), Vector2i(64, 64)) + req.on_response( + { + "body_id": "GJ1c", + "rung": "District", + "center": Vector2i(5, 5), + "extent": Vector2i(64, 64), + "min_wl_m": 0, + "status": "Ready", + "canvas": {"width": 64, "height": 64}, + } + ) + assert_bool(req.get_cache().has("GJ1c", "District", Vector2i(5, 5), Vector2i(64, 64))).is_true() diff --git a/client/tests/test_step_canvas_transport.gd b/client/tests/test_step_canvas_transport.gd new file mode 100644 index 000000000..53858bf46 --- /dev/null +++ b/client/tests/test_step_canvas_transport.gd @@ -0,0 +1,190 @@ +## T-1182 tests: step_canvas_transport.gd — the D-255(a) six-rung stepped +## transport state machine (rung ladder, cursor-anchored step math, +## viewport-fit extent, world<->canvas-local projection). All pure +## functions, no scene tree needed. +class_name TestStepCanvasTransport +extends GdUnitTestSuite + +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + + +# ============================================================================= +# Rung ladder — index <-> name, scroll clamping +# ============================================================================= + + +func test_rung_at_index_zero_is_global() -> void: + assert_str(StepCanvasTransport.rung_at_index(0)).is_equal(StepCanvasTransport.RUNG_GLOBAL) + + +func test_rung_at_index_five_is_chunk_the_deepest() -> void: + assert_str(StepCanvasTransport.rung_at_index(5)).is_equal(StepCanvasTransport.RUNG_CHUNK) + + +func test_rung_at_index_clamps_out_of_range_indices() -> void: + assert_str(StepCanvasTransport.rung_at_index(-3)).is_equal(StepCanvasTransport.RUNG_GLOBAL) + assert_str(StepCanvasTransport.rung_at_index(99)).is_equal(StepCanvasTransport.RUNG_CHUNK) + + +func test_index_for_rung_round_trips_every_ladder_entry() -> void: + for i in range(StepCanvasTransport.RUNG_LADDER.size()): + var rung: String = StepCanvasTransport.rung_at_index(i) + assert_int(StepCanvasTransport.index_for_rung(rung)).is_equal(i) + + +func test_index_for_rung_unrecognized_returns_negative_one() -> void: + assert_int(StepCanvasTransport.index_for_rung("Sector")).is_equal(-1) + + +func test_scroll_step_descends_one_notch_at_a_time() -> void: + assert_int(StepCanvasTransport.scroll_step(0, 1)).is_equal(1) + assert_int(StepCanvasTransport.scroll_step(2, 1)).is_equal(3) + + +func test_scroll_step_ascends_one_notch_at_a_time() -> void: + assert_int(StepCanvasTransport.scroll_step(3, -1)).is_equal(2) + + +func test_scroll_step_clamps_at_the_deepest_rung() -> void: + assert_int(StepCanvasTransport.scroll_step(5, 1)).is_equal(5) + + +func test_scroll_step_clamps_at_the_global_opener() -> void: + assert_int(StepCanvasTransport.scroll_step(0, -1)).is_equal(0) + + +func test_scroll_step_zero_direction_is_a_no_op() -> void: + assert_int(StepCanvasTransport.scroll_step(2, 0)).is_equal(2) + + +# ============================================================================= +# D-243 gridunit spacing — pinned against the same metre values scale.rs uses +# ============================================================================= + + +func test_spacing_for_rung_matches_d243_metre_values() -> void: + assert_float(StepCanvasTransport.spacing_for_rung("Global")).is_equal_approx(204_800.0, 0.01) + assert_float(StepCanvasTransport.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.01) + assert_float(StepCanvasTransport.spacing_for_rung("District")).is_equal_approx(2_048.0, 0.01) + assert_float(StepCanvasTransport.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.01) + assert_float(StepCanvasTransport.spacing_for_rung("Block")).is_equal_approx(128.0, 0.01) + assert_float(StepCanvasTransport.spacing_for_rung("Chunk")).is_equal_approx(64.0, 0.01) + + +# ============================================================================= +# Display ratio — deep/mid 1x1, shallow ~5x5, PRESENTATION only (D-255(a)) +# ============================================================================= + + +func test_display_ratio_deep_rungs_are_one_to_one() -> void: + for rung in ["District", "Quarter", "Block", "Chunk"]: + assert_float(StepCanvasTransport.display_ratio_for_rung(rung)).is_equal_approx(1.0, 0.001) + + +func test_display_ratio_shallow_rungs_use_the_five_x_five_fallback() -> void: + for rung in ["Global", "Region"]: + assert_float(StepCanvasTransport.display_ratio_for_rung(rung)).is_equal_approx(5.0, 0.001) + + +func test_is_orbital_rung_true_only_for_global_and_region() -> void: + assert_bool(StepCanvasTransport.is_orbital_rung("Global")).is_true() + assert_bool(StepCanvasTransport.is_orbital_rung("Region")).is_true() + assert_bool(StepCanvasTransport.is_orbital_rung("District")).is_false() + assert_bool(StepCanvasTransport.is_orbital_rung("Chunk")).is_false() + + +# ============================================================================= +# Viewport-fit extent — the client half of "viewport-sized canvas" +# ============================================================================= + + +func test_viewport_fit_extent_at_deep_ratio_matches_viewport_pixels() -> void: + # 1x1 ratio -> extent in gridunits == viewport px, 1:1. + var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(800.0, 600.0), "Chunk") + assert_that(extent).is_equal(Vector2i(800, 600)) + + +func test_viewport_fit_extent_at_shallow_ratio_divides_by_the_display_ratio() -> void: + var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(1000.0, 500.0), "Region") + assert_that(extent).is_equal(Vector2i(200, 100)) + + +func test_viewport_fit_extent_clamps_to_the_fixed_canvas_max_axis() -> void: + var extent: Vector2i = StepCanvasTransport.viewport_fit_extent( + Vector2(20_000.0, 20_000.0), "Chunk" + ) + assert_int(extent.x).is_equal(StepCanvasTransport.FIXED_CANVAS_MAX_AXIS) + assert_int(extent.y).is_equal(StepCanvasTransport.FIXED_CANVAS_MAX_AXIS) + + +func test_viewport_fit_extent_never_produces_a_zero_axis() -> void: + var extent: Vector2i = StepCanvasTransport.viewport_fit_extent(Vector2(0.0, 0.0), "Chunk") + assert_int(extent.x).is_greater_equal(1) + assert_int(extent.y).is_greater_equal(1) + + +# ============================================================================= +# Gridunit snapping — cache-key stability for repeated "same spot" requests +# ============================================================================= + + +func test_snap_to_gridunit_snaps_to_the_rungs_own_spacing() -> void: + var snapped: Vector2i = StepCanvasTransport.snap_to_gridunit(Vector2(2100.0, -1000.0), "District") + # District spacing = 2048 m: 2100 rounds to 1*2048=2048, -1000 rounds to 0. + assert_int(snapped.x).is_equal(2048) + assert_int(snapped.y).is_equal(0) + + +func test_snap_to_gridunit_is_idempotent_once_already_on_grid() -> void: + var once: Vector2i = StepCanvasTransport.snap_to_gridunit(Vector2(4096.0, 6144.0), "District") + var world_again := Vector2(once.x, once.y) + var twice: Vector2i = StepCanvasTransport.snap_to_gridunit(world_again, "District") + assert_that(once).is_equal(twice) + + +# ============================================================================= +# World <-> canvas-local projection — the shared transform both the terrain +# and annotation layers agree on by construction +# ============================================================================= + + +func test_world_m_to_canvas_local_centers_the_world_center_on_the_canvas_center() -> void: + var extent := Vector2i(64, 64) + var rung := "District" + var world_center := Vector2(10_000.0, 20_000.0) + var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( + world_center, world_center, rung, extent + ) + var expected_center: Vector2 = StepCanvasTransport.canvas_footprint_px(rung, extent) * 0.5 + assert_that(local).is_equal_approx(expected_center, Vector2(0.01, 0.01)) + + +## world_m_to_canvas_local() and canvas_local_to_world_m() must be exact +## inverses of one another — a round trip through both must recover the +## original world point (within float tolerance). This is the invariant the +## cursor-anchored scroll step depends on: whatever point the cursor reads +## as "under it" before a scroll must be the SAME point after re-deriving +## from the new step's own frame. +func test_world_to_local_and_back_round_trips() -> void: + var extent := Vector2i(128, 96) + var rung := "Quarter" + var world_center := Vector2(50_000.0, -30_000.0) + var original_world := Vector2(51_200.0, -29_500.0) + + var local: Vector2 = StepCanvasTransport.world_m_to_canvas_local( + original_world, world_center, rung, extent + ) + var recovered_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( + local, world_center, rung, extent + ) + assert_that(recovered_world).is_equal_approx(original_world, Vector2(0.5, 0.5)) + + +func test_canvas_footprint_px_is_extent_times_display_ratio() -> void: + var footprint: Vector2 = StepCanvasTransport.canvas_footprint_px("Region", Vector2i(100, 50)) + assert_that(footprint).is_equal(Vector2(500.0, 250.0)) # 5x5 shallow ratio + + +func test_half_extent_m_is_half_the_cell_count_times_spacing() -> void: + var half: float = StepCanvasTransport.half_extent_m("District", 64) + assert_float(half).is_equal_approx(64.0 * 0.5 * 2048.0, 0.01) diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd new file mode 100644 index 000000000..56528c6f3 --- /dev/null +++ b/client/tests/test_step_canvas_viewer.gd @@ -0,0 +1,110 @@ +## 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. +class_name TestStepCanvasViewer +extends GdUnitTestSuite + +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + + +func test_enter_lands_on_the_global_opener() -> 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) + + +func test_get_body_id_reflects_the_entered_body() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + assert_str(v.get_body_id()).is_equal("") + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + assert_str(v.get_body_id()).is_equal("GJ380c") + + +## Scrolling one notch descends the ladder — cursor-anchored, so a cursor +## position must be supplied; the rung index advances by exactly one. +func test_scroll_rung_descends_one_notch() -> 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_REGION) + + +func test_scroll_rung_clamps_at_the_deepest_rung() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}) + for _i in range(10): + v._scroll_rung(1, Vector2(400.0, 300.0)) + assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_CHUNK) + + +func test_reset_to_global_returns_from_a_deep_rung() -> 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)) + v._scroll_rung(1, Vector2(400.0, 300.0)) + v._reset_to_global() + assert_str(v.get_held_rung()).is_equal(StepCanvasTransport.RUNG_GLOBAL) + + +func test_overlay_visibility_defaults_to_off_for_every_toggle() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + for def: Dictionary in v.get_overlay_defs(): + assert_bool(v.is_overlay_visible(def["id"])).is_false() + + +func test_set_overlay_visible_updates_state() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.set_overlay_visible("gen_dw_temp", true) + assert_bool(v.is_overlay_visible("gen_dw_temp")).is_true() + + +func test_set_overlay_visible_unknown_id_is_a_no_op() -> void: + var v: StepCanvasViewer = auto_free(StepCanvasViewer.new()) + add_child(v) + v.set_overlay_visible("not_a_real_overlay", true) + assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false() + + +# ============================================================================= +# RegionalScreen re-entry guard (BUG 2 lineage, carried forward from the +# retired AtlasWindowViewer-era regression) — now against StepCanvasViewer. +# ============================================================================= + + +func test_regional_screen_repeat_enter_for_the_same_body_is_a_no_op() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) + add_child(screen) + var body: Dictionary = {"body_id": "GJ380c", "body_radius_km": 6238.4} + screen.enter({"body": body, "system": {}}) + screen._viewer._scroll_rung(1, Vector2(400.0, 300.0)) + assert_str(screen._viewer.get_held_rung()).is_equal(StepCanvasTransport.RUNG_REGION) + + screen.enter({"body": body, "system": {}}) + + # A no-op re-entry must NOT reset the held rung back to Global — that + # would be the exact "repeat enter tears down in-flight state" class the + # retired viewer's own cold-start guard existed to prevent. + assert_str(screen._viewer.get_held_rung()).override_failure_message( + "a repeat enter() for the SAME body must not reset the held rung" + ).is_equal(StepCanvasTransport.RUNG_REGION) + + +func test_regional_screen_different_body_still_re_enters() -> void: + var screen: RegionalScreen = auto_free(RegionalScreen.new()) + add_child(screen) + screen.enter({"body": {"body_id": "GJ380c", "body_radius_km": 6238.4}, "system": {}}) + + screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}}) + + assert_str(screen._viewer.get_body_id()).is_equal("OtherBody") diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 0873d852e..72abab2a3 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -76,10 +76,10 @@ func _unhandled_key_input(event: InputEvent) -> void: return if not event.is_pressed() or event.is_echo(): return - # "regional" (the whole zoom ladder, AtlasWindowViewer, T-1153) handles its - # own Esc via _gui_input — a stray M/other unhandled key on that screen - # must not ALSO fire this app's own _handle_key underneath the viewer's - # own handling. + # "regional" (the whole stepped zoom ladder, StepCanvasViewer, T-1182) + # handles its own Esc via _gui_input — a stray M/other unhandled key on + # that screen must not ALSO fire this app's own _handle_key underneath + # the viewer's own handling. if current_screen_id() == "regional": return _handle_key(event as InputEventKey) diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd deleted file mode 100644 index 6f00d33b1..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ /dev/null @@ -1,727 +0,0 @@ -extends RefCounted - -## Pure geometry helpers for AtlasWindowViewer's fit/pan transform (T-1142 — -## Jeroen's second hands-on finding: enter() reset zoom to 1.0/offset to ZERO -## with no fit, so an n=32 composite (512px native) rendered as a postage -## stamp in a ~1900px viewport). Factored out of atlas_window_viewer.gd for -## the same reason atlas_descend_geometry.gd was factored out of -## atlas_viewer.gd (T-1138): the actual _canvas.position/.scale WRITES stay on -## the viewer (Node-tree side effects), but the pure "given a viewport and a -## window size, what zoom/offset centers it" math is unit-testable in -## isolation here — a caller does: -## const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") -## -## T-1153: also carries the rung-selection rule (design doc -## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed -## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both -## pure functions of (viewport, held state, body), same "geometry lives here, -## side effects live on the viewer" split as the rest of this file. Live -## round 3 also adds the orbital-rest-state TILE GRID computation -## (compute_tile_grid(), near the bottom) — reuses -## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for -## the SAME wrap/clamp discipline every other piece of this cluster already -## depends on, hence the preload below (no circular dependency: -## atlas_descend_geometry.gd never references this file). -## -## T-1170: the T-1156 wave-1 nature-overlay (river/basin/attractor) pixel -## mapping and per-rung visibility policy (RIVER_CLASS_*, layer1_pixel_to_*, -## *_visible_at_rung, zoom_compensated_size) moved OUT of this file to -## atlas_window_geometry_nature.gd (this file was at 954/1000 gdlint -## max-file-lines when the move happened) — see that file's own header doc. -## cell_index_for_local_offset() (T-1172, near the bottom of this file) stayed -## here since it is shared with AtlasWindowOverlay's terrain painter, a -## non-nature consumer. -const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") - -## D-243 rung spacings, metres/cell — the SAME constants -## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m -## source from, mirrored here rather than re-derived so the client's rung -## table can never silently drift from the wire contract it's choosing -## between. -const QUARTER_SPACING_M: float = 512.0 -const DISTRICT_SPACING_M: float = 2048.0 -const REGION_SPACING_M: float = 204_800.0 - -## Table form, coarsest-first — spacing_for_rung()'s inverse lookup walks -## this. select_rung() (below) does NOT walk this table directly — see that -## function's own doc for why the coarse (Region) and fine (District/ -## Quarter) ends are decided by two DIFFERENT tests, not a single ordered -## table scan. -const RUNG_TABLE: Array = [ - {"granularity_v2": "Region", "spacing_m": REGION_SPACING_M}, - {"granularity_v2": "District", "spacing_m": DISTRICT_SPACING_M}, - {"granularity_v2": "Quarter", "spacing_m": QUARTER_SPACING_M}, -] - -## Server per-axis cap on a District/Quarter-granularity window's `n` -## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see -## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side -## mirror of the same constant, kept in sync there). -const DISTRICT_WINDOW_MAX_N: int = 64 - -## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS / -## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap -## EVERY rung's single window is clamped against, per -## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas. -const WIRE_CAP_CELLS: int = 4_096 - -## Region's own per-axis ceiling (mirrors -## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION / -## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION). -const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400 - -## Per-tile district extent for the orbital-rest-state tile grid -## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a -## single Region request uses at its own per-axis ceiling. Each tile -## requests exactly this many districts on a side — the largest single -## window the wire budget allows, so tiling uses the FEWEST tiles that can -## cover a given body. -const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION - -## **Live round 3 finding (the actual root cause of "zoom-driven rung -## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM -## real-world coverage, derived from the SAME wire-size clamp -## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District -## and Quarter are NOT exempt from this the way the original (§5-literal) -## design assumed. A rung whose own single-window coverage is smaller than -## the CURRENTLY DISPLAYED world extent cannot legally be selected: the -## server would clamp `n` down to fit its own wire budget, producing a -## composite that covers only a FRACTION of the viewport — visually a tiny -## box in the middle of the screen, and (the bug this constant's discovery -## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n` -## the viewer was still carrying from the PREVIOUS rung, permanently failing -## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same -## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per -## rung inline — see MAX_COVERAGE_M below. -## -## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts -> -## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`. -## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts -> -## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original -## coverage-ceiling constant this replaces/generalizes). -## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts -> -## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's -## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to -## cover extents beyond this (see the viewer's tile-set model), so this -## constant alone does NOT bound what the ORBITAL REST STATE can show — -## only what one Region REQUEST's response covers. -const MAX_COVERAGE_M: Dictionary = { - "Quarter": 64.0 * QUARTER_SPACING_M, - "District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M, - "Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M, -} - -## Rungs ordered FINEST-first — select_rung() walks this to find the finest -## rung whose own single-window coverage ceiling still covers the current -## extent (never a rung that would silently under-cover the viewport). -const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"] - - -## Fit-and-center: given the viewport size and the window's side length in -## districts, compute the zoom/offset that COVERS the viewport (fills it edge -## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's -## own _fit_to_view() shape (fit, then center) but as a pure function -## returning {zoom, offset} instead of writing _view_zoom/_view_offset -## directly, so AtlasWindowViewer.enter()/(_on_window_ready)/NOTIFICATION_RESIZED -## can all call the SAME formula without three copies of the math drifting. -## -## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): the ORIGINAL fit -## was CONTAIN (zoom from the SMALLER viewport dimension, with a 0.9 margin -## factor) — in a wide viewport this left large side margins around a square -## composite (the window data is always n x n, a square, regardless of -## viewport aspect). Changed to COVER: zoom from the LARGER viewport -## dimension, with NO margin factor — a margin on the CONTAIN axis (the one -## the zoom is computed from) is a deliberate breathing-room choice; the -## exact same margin on the COVER axis would be a literal gap at the -## viewport's own edge, which is precisely the "no side margins" defect this -## fix removes. The composite therefore fills the screen edge to edge on its -## long axis (scaled side == max(viewport.x, viewport.y) exactly) and -## overhangs past both edges on its short axis (exactly the same "cover" -## concept CSS background-size/object-fit use — fill the frame, crop what -## doesn't fit, never letterbox). This is honest for a square dataset in a -## non-square frame: at rest, the player sees a full-bleed slice of the -## window, and panning (T-1145 item 2: WASD/edge-scroll) reveals the rest, -## including triggering the existing pan-edge refetch (§4) exactly as -## intended — cover does not change what "past the window edge" means, only -## how much of the window is visible before the player pans at all. -## -## zoom = clampf(max(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM) -## offset centers the (n * cell_px * zoom)-sized composite on the viewport, -## exactly as the old contain fit did. -static func fit_window_view( - viewport: Vector2, n: int, cell_px: float, min_zoom: float, max_zoom: float -) -> Dictionary: - if n <= 0 or cell_px <= 0.0 or viewport.x <= 0.0 or viewport.y <= 0.0: - return {"zoom": 1.0, "offset": Vector2.ZERO} - var composite_native: float = float(n) * cell_px - var zoom: float = clampf(maxf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom) - var composite_scaled: Vector2 = Vector2(composite_native, composite_native) * zoom - var offset: Vector2 = (viewport - composite_scaled) * 0.5 - return {"zoom": zoom, "offset": offset} - - -## T-1142 addendum (Jeroen — pole-wall ruling): the pan offset's Y component -## must never let the WINDOW EDGE (not merely the window center) cross the -## body's row extent — panning past a pole would ask the derive for rows -## beyond ±rows_half, which the server clamps (T-1142's own -## normalize_window_center) into a smeared repeated-clamped-latitude band, -## not real topology. The wall is therefore drawn at the edge of the ACTUAL -## valid row range, honestly reflecting "this is where the world ends", not -## an arbitrary UI limit. -## -## Inputs are all in the SAME units the caller's _view_offset/_view_zoom -## already use (canvas px = district cells * cell_px, screen px after zoom): -## `held_center`/`held_n` describe the currently-fetched window (its center -## district row and side length); `rows_half` is the body's half-meridian -## extent in districts (district_extent()'s "rows_half", i.e. equator-to-pole -## in whole districts); `cell_px`/`zoom` convert districts to screen pixels. -## Returns the Y-clamped offset — X is untouched (no wall on longitude, T-1142 -## item 6: circumnavigation is seamless, only the row axis is a hard boundary). -static func clamp_pan_offset_to_pole_wall( - offset: Vector2, - view_size: Vector2, - held_center: Vector2i, - held_n: int, - rows_half: int, - cell_px: float, - zoom: float -) -> Vector2: - if rows_half <= 0 or held_n <= 0 or cell_px <= 0.0 or zoom <= 0.0: - return offset - # The held window spans districts [held_center.y - held_n/2, held_center.y - # + held_n/2) — its top/bottom edges in ABSOLUTE district-row space. - var half_n: float = float(held_n) * 0.5 - var window_top_row: float = float(held_center.y) - half_n - var window_bottom_row: float = float(held_center.y) + half_n - # Canvas-space (pre-zoom) distance from the window's local origin (row 0 - # of the held composite, i.e. window_top_row) to the pole boundary rows. - # A pole boundary that falls OUTSIDE the held window's own row span is not - # reachable by panning within this fetch at all (clampf below is then a - # no-op in that direction) — the wall only bites once a pan would expose - # rows the held window doesn't cover AND those rows would cross the pole. - var north_wall_local_row: float = float(-rows_half) - window_top_row - var south_wall_local_row: float = float(rows_half) - window_top_row - # Screen-space Y bound: offset.y is the screen position of canvas-Y=0 - # (the composite's top edge). Moving the composite DOWN (offset.y - # increasing) reveals rows ABOVE window_top_row — i.e. moves the visible - # top edge toward the north wall. The composite's top edge, in canvas - # units, must never be dragged past the north wall's canvas position, and - # the bottom edge (view_size.y below the top, in screen space) must never - # be dragged past the south wall's. - var north_wall_screen_y: float = -north_wall_local_row * cell_px * zoom - var south_wall_screen_y: float = view_size.y - south_wall_local_row * cell_px * zoom - # offset.y is clamped so the top edge never exceeds the north wall - # (offset.y <= north_wall_screen_y keeps the top edge from being pulled - # DOWN past the wall — i.e. revealing north of it) and the bottom edge - # never exceeds the south wall on the other side. When the window's own - # span doesn't reach a wall, that wall's bound is on the permissive side - # of the other and clampf's min/max ordering still holds (min >= max only - # when BOTH walls are inside the span and the window is taller than the - # pole-to-pole distance — see the "tiny body" doc note on the caller). - var min_y: float = minf(north_wall_screen_y, south_wall_screen_y) - var max_y: float = maxf(north_wall_screen_y, south_wall_screen_y) - return Vector2(offset.x, clampf(offset.y, min_y, max_y)) - - -# ============================================================================= -# T-1153: rung selection (design doc §5) — the continuous zoom ladder's join -# point between "what granularity is legal to request" (a rung, a discrete -# set) and "what density the client actually wants" (world extent per canvas -# px, a continuous quantity that tracks the live zoom level). -# ============================================================================= - - -## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the -## original §5-literal `2x`-visual-tolerance-only reading): select the -## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M) -## still covers the current world extent. Walks RUNGS_FINEST_FIRST -## (Quarter, District, Region) and returns the first whose ceiling is `>= -## world_extent_m` — the coarser rungs are tried only once the finer ones -## genuinely cannot show the requested extent in a single window. -## -## **Why this replaces the original `2x`-visual-tolerance formula entirely** -## (not just patches its Region case, as an earlier version of this function -## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)") -## implicitly assumes every rung's single window CAN cover any extent the -## rule selects it for — true for an unbounded wire budget, false here. -## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the -## server itself enforces — caps every rung's single-window real-world -## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter -## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region -## window — bug B's progressive TILING composes several to cover more, a -## viewer-level concern this function doesn't need to know about). A rung -## selected for an extent BEYOND its own ceiling would have its `n` silently -## clamped server-side to something covering only a FRACTION of the -## viewport — visually a tiny box, AND (the actual live-round bug this -## redesign fixes) a clamped echo that no longer matches whatever `n` the -## viewer was still carrying from the rung it's leaving, permanently failing -## the staleness check in `_on_window_ready()`. -## -## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not -## contradicted by it** — verified numerically: at the exact zoom where -## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold -## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m -## spacing. This means by the time coverage RELEASES a rung, the visual -## tolerance would ALREADY prefer something finer than that rung offers — -## i.e. every rung this function selects is, by construction, at or past its -## own "as fine as it can usefully be" point. The visual-tolerance rule's -## fine-end guarantee (never show a coarser composite than the screen can -## resolve) is automatically satisfied by "select the finest rung whose -## coverage allows it" — there is no case where the coverage rule picks a -## rung the visual rule would have rejected as too coarse, because Quarter -## (the finest rung) is always the answer whenever ANY rung's visual -## tolerance alone would have mattered. -## -## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held -## window is always square, so either axis of the viewport/extent pair gives -## the same answer — the caller picks one, consistently). `canvas_px` is -## kept as a parameter (unused by the coverage rule itself) for signature -## stability with existing callers and because a future finer-than-Quarter -## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need -## it again. -## -## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region"). -static func select_rung(world_extent_m: float, _canvas_px: float) -> String: - for rung: String in RUNGS_FINEST_FIRST: - if world_extent_m <= float(MAX_COVERAGE_M[rung]): - return rung - return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job) - - -## The metre spacing a given granularity_v2 tag resolves to — the inverse -## lookup select_rung() itself doesn't need but callers computing "what world -## extent does holding N districts at this rung actually cover" do (the -## viewer's own extent-in-real-units header line, and the full-zoom-out -## predicate below). -static func spacing_for_rung(granularity_v2: String) -> float: - for rung: Dictionary in RUNG_TABLE: - if rung["granularity_v2"] == granularity_v2: - return float(rung["spacing_m"]) - return DISTRICT_SPACING_M # unknown tag -> district, matching the server's "unknown -> District" posture - - -# ============================================================================= -# T-1153: full-zoom-out reset (Jeroen's D-226 T-1143-rulings HARD condition — -# "a full zoom-out resets to the original canonical planetary frame and -# location", the ladder's top rest state, not a drifted pan state). -# ============================================================================= - - -## True once the requested world extent (at the CURRENT zoom, before any -## further zoom-out) covers the full body — i.e. the player has zoomed out as -## far as the ladder goes and is looking at (at least) the whole equatorial -## circumference. This is the crisp "fully zoomed out" definition the ticket -## asks for: `world_extent_m >= circumference_m` at the fit-zoom floor, rather -## than a fuzzy "close to MIN_ZOOM" heuristic (MIN_ZOOM is a UI clamp -## constant, not a planetary-coverage fact — a small body could hit MIN_ZOOM -## while still showing less than the whole circumference, and a huge body -## could show full coverage before MIN_ZOOM is reached, depending on -## CELL_PIXEL_SIZE/held_n; extent-vs-circumference is the honest test either -## way). -## -## **Distinct from select_rung()'s own coverage ceiling** (District's -## `DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M = 131,072 m`, a fixed, -## body-independent number) — this predicate's threshold is the actual -## body's full circumference, always far larger than 131,072 m for any real -## planet. The two compose in the expected order: extent crosses 131,072 m -## first (select_rung() already reports Region well before this predicate -## fires), and only once extent reaches the WHOLE circumference does the -## top-rest-state reset itself trigger. There is no conflict between the two -## thresholds, just two different questions ("which rung" vs. "are we at the -## very top"). -static func is_fully_zoomed_out(world_extent_m: float, body_radius_km: float) -> bool: - if body_radius_km <= 0.0: - return false # no-radius (tiny test body) has no circumference concept — never auto-resets - var circumference_m: float = TAU * body_radius_km * 1000.0 - return world_extent_m >= circumference_m - - -# ============================================================================= -# T-1153: pure screen<->world math extracted from AtlasWindowViewer for -# testability (the project's stated preference — static funcs over Control -# instance methods wherever the math doesn't need the scene tree). -# ============================================================================= - - -## The world extent (metres) currently displayed across the LARGER viewport -## dimension — the `E` half of the §5 rung-selection rule's `E/C`. -## `cell_pixel_size` is the caller's district-at-zoom-1.0 constant -## (AtlasWindowViewer.CELL_PIXEL_SIZE) — the composite's ON-SCREEN FOOTPRINT -## is ALWAYS `held_n * cell_pixel_size * view_zoom` px for `held_n * DISTRICT_M` -## metres of world, REGARDLESS of which rung is currently held (this is -## exactly the invariant AtlasWindowOverlay.cell_grid_side_for_window()'s doc -## establishes on the render side: `n` districts occupy a FIXED screen -## footprint; only the DERIVED CELL RESOLUTION packed into that footprint -## varies by rung). So the metres-per-screen-px sample density is a pure -## function of `view_zoom` — DISTRICT_SPACING_M / (cell_pixel_size * -## view_zoom) — with NO granularity_v2 parameter needed at all: the rung -## itself is the OUTPUT of this calculation (via select_rung()), not an -## input to it. -static func world_extent_m(cell_pixel_size: float, view_zoom: float, viewport: Vector2) -> float: - var canvas_px: float = cell_pixel_size * view_zoom - if canvas_px <= 0.0: - return 0.0 - var screen_px: float = maxf(viewport.x, viewport.y) - return DISTRICT_SPACING_M / canvas_px * screen_px - - -## The DistrictPos the viewport's screen center currently maps to, in RAW -## absolute district space (un-wrapped, un-clamped — the caller canonicalizes -## the final value it actually stores/sends, matching -## canonicalize_district_center()'s own "canonicalize once, at the boundary" -## discipline). Shared by AtlasWindowViewer's pan-edge refetch -## (_maybe_refloat_window()) and rung-reselect refetch -## (_maybe_reselect_rung()) so both read the SAME screen-to-district formula -## rather than two copies that could drift. -static func screen_center_to_district( - viewport_size: Vector2, - view_offset: Vector2, - view_zoom: float, - cell_pixel_size: float, - held_center: Vector2i, - held_n: int -) -> Vector2i: - var screen_center: Vector2 = viewport_size * 0.5 - var canvas_pt: Vector2 = (screen_center - view_offset) / view_zoom - var cell: Vector2 = canvas_pt / cell_pixel_size - var half: float = float(held_n) / 2.0 - var abs_col: float = float(held_center.x) - half + cell.x - var abs_row: float = float(held_center.y) - half + cell.y - return Vector2i(roundi(abs_col), roundi(abs_row)) - - -## Live round 4 fix: the exact INVERSE of screen_center_to_district()'s own -## district-space math, in CANVAS-LOCAL space (i.e. _canvas's own child -## coordinate system — BEFORE _view_offset/_view_zoom, which is what -## AtlasWindowOverlay._draw()/_draw_tile_mosaic() draw into, since the -## Node2D's position/scale already carries pan/zoom). Every held-window -## convention in this file agrees canvas-local `(0,0)` is absolute district -## `(held_center - held_n/2)` — single-window `_draw()`'s own -## `Rect2(0,0,extent,extent)` relies on this being true for `held_center` == -## the window's own center. `_draw_tile_mosaic()`'s per-tile placement must -## use this SAME formula (with the VIEWER's `held_center`/`held_n`, not a -## tile's own center/TILE_N) to land in the same coordinate frame the -## fit/pan/zoom machinery already assumes — drawing tiles relative to -## absolute district (0,0) directly (the live-round-4 bug) silently -## disagreed with fit_window_view()'s own `[0, held_n)`-from-origin -## assumption whenever `held_n` (the WHOLE-BODY extent in tile mode) wasn't -## itself anchored the same way, pushing the entire mosaic off-canvas. -static func district_to_canvas_local( - district: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float -) -> Vector2: - var half: float = float(held_n) / 2.0 - var local_col: float = (district.x - (float(held_center.x) - half)) * cell_pixel_size - var local_row: float = (district.y - (float(held_center.y) - half)) * cell_pixel_size - return Vector2(local_col, local_row) - - -## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully -## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into -## `[0, cols)` — the correct, single-valued key for REQUESTS and cache -## coalescing), but a canonical column has infinitely many EQUIVALENT -## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since -## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR -## function with no wrap concept — fed a canonical column directly, it -## places the tile at exactly ONE of those wrap-images, which is only ever -## the visually-correct one by coincidence. Lendel's own repro: the tile -## whose pre-canonicalization center was -6400 canonicalizes to 12739 -## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at -## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT), -## when the tile's actual visible position (immediately west of the -## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT -## third of the mosaic). -## -## The fix: before handing a tile's canonical column to -## `district_to_canvas_local()`, re-express it as whichever wrap-image -## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` — -## the representative that's actually near the current view, matching how a -## real, non-tiling single-window pan already resolves the "which -## circumnavigation" question implicitly (screen_center_to_district()'s own -## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile -## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is -## no periodicity to resolve. -static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int: - if cols <= 0: - return canonical_col - var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2 - return held_center_col + delta - - -## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond -## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a -## PURE screen<->canvas-local transform, entirely independent of -## `held_center`/`held_n` — cursor-anchored zoom (`_zoom_at()`) never -## references them. But the single-window `_draw()` path draws the held -## composite at canvas-local `Rect2(0,0,extent,extent)`, which is ONLY the -## right place on screen if canvas-local (0,0) still equals -## `held_center - held_n/2` for the NEW rung. `_maybe_reselect_rung()` -## updates `held_center`/`held_n` to the new rung's values (a DIFFERENT -## `held_n` — Region's ~thousands vs. District's 64 vs. Quarter's 16) but -## never touched `_view_offset` to compensate — so canvas-local (0,0) -## silently stopped meaning `held_center - held_n/2` the instant `held_n` -## changed, and the composite (still drawn at local (0,0)) landed wherever -## the STALE offset happened to put it — off-canvas by tens or hundreds of -## thousands of px for a Region-to-District/Quarter crossing (round 4's -## repro), same root shape as the tile-mosaic bug, just on the "one held -## window" side of the split instead of the "many tiles" side. -## -## This is the exact INVERSE construction: given the SAME screen point that -## used to map to `old_local` must now map to canvas-local -## `new_held_n/2 * cell_pixel_size` (i.e. new_held_center's own position -## under the NEW window's `[0, new_held_n)` span), solve for the -## `view_offset` that makes `screen_point == new_local * view_zoom + -## view_offset` true. Pan-edge refetch (`_maybe_refloat_window()`) never -## needed this because it never changes `held_n` — only rung crossings do. -static func recompute_offset_for_held_n_change( - screen_point: Vector2, view_zoom: float, new_held_n: int, cell_pixel_size: float -) -> Vector2: - var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * cell_pixel_size) - return screen_point - new_local * view_zoom - - -# ============================================================================= -# T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/ -# arrow-key held-pan direction + edge-scroll suppression/direction — pure -# functions of explicit inputs, no Control/scene-tree dependency. -# ============================================================================= - - -## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode -## (not an InputMap action): W/S/A/D on this project's global InputMap are -## already bound to move_north/move_south/move_east/move_west (gameplay -## movement, D-054 mouse-relative facing) — reusing those actions here would -## make holding W simultaneously pan this map AND queue a gameplay move -## command server-side the moment this implant screen closes back to -## gameplay (InputMapper polls Input.is_action_pressed() unconditionally, -## with no implant-occlusion guard — a genuine pre-existing gap outside this -## ticket's scope, not introduced here). Reading the raw physical keycode -## instead of the shared action name means this screen's WASD use is fully -## independent of whatever the gameplay action happens to be bound to — same -## key, two UNRELATED consumers, neither needs to know about the other. -## Arrow keys have no InputMap action bound at all, so they're conflict-free -## either way. Returns a raw (non-normalized) direction — the caller -## normalizes once after adding the edge-scroll contribution, so N+E doesn't -## move faster than N alone. Reads the global `Input` singleton directly (not -## injected) — this is the one function in this file that isn't a pure -## function of its arguments, kept here anyway to sit beside its two siblings -## below rather than splitting the WASD/edge-scroll trio across two files. -static func held_pan_direction() -> Vector2: - var direction := Vector2.ZERO - if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): - direction.y -= 1.0 - if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): - direction.y += 1.0 - if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): - direction.x -= 1.0 - if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): - direction.x += 1.0 - return direction - - -## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI -## (`is_over_ui` — the caller's own _is_over_ui() result, passed in rather -## than called from here since "what counts as UI" is viewer-specific) and -## (b) while the application window itself lacks OS focus (`app_has_focus` — -## otherwise a background window with the cursor left resting near its edge -## from a previous session would silently pan while the player is doing -## something else entirely; Godot's NOTIFICATION_APPLICATION_FOCUS_OUT/IN -## make this directly detectable, the caller's own _notification() wires it). -static func is_cursor_edge_scrolling( - app_has_focus: bool, - is_over_ui: bool, - viewport_size: Vector2, - mouse_pos: Vector2, - edge_margin_px: float -) -> bool: - if not app_has_focus: - return false - if is_over_ui: - return false - if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: - return false - return ( - mouse_pos.x >= 0.0 - and mouse_pos.y >= 0.0 - and mouse_pos.x <= viewport_size.x - and mouse_pos.y <= viewport_size.y - and ( - mouse_pos.x < edge_margin_px - or mouse_pos.y < edge_margin_px - or mouse_pos.x > viewport_size.x - edge_margin_px - or mouse_pos.y > viewport_size.y - edge_margin_px - ) - ) - - -## Direction toward whichever edge(s) the cursor is near — same shape as -## held_pan_direction() (a raw, un-normalized Vector2 the caller combines and -## normalizes once). -static func edge_scroll_direction( - viewport_size: Vector2, mouse_pos: Vector2, edge_margin_px: float -) -> Vector2: - var direction := Vector2.ZERO - if mouse_pos.x < edge_margin_px: - direction.x -= 1.0 - elif mouse_pos.x > viewport_size.x - edge_margin_px: - direction.x += 1.0 - if mouse_pos.y < edge_margin_px: - direction.y -= 1.0 - elif mouse_pos.y > viewport_size.y - edge_margin_px: - direction.y += 1.0 - return direction - - -# ============================================================================= -# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST -# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"] -# = 13,107,200 m) covers only a fraction of a real body's circumference -# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top -# rest state composes MULTIPLE Region windows ("progressive capped-density -# TILING", design doc §4) into a mosaic under ONE view transform. -# ============================================================================= - - -## Compute the tile-set grid for the orbital rest state: the minimal set of -## Region-granularity window CENTERS (each `TILE_N` districts wide) whose -## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s -## own east-west periodicity), rows clamp at the poles. Returns an Array of -## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row -## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny -## body where multiple nominal tile rows clamp to the identical pole-adjacent -## row, or multiple nominal tile columns wrap to the identical column, must -## not request/draw the same tile twice). -## -## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full -## circumference (evenly spaced, centered on column 0 — the canonical -## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to -## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is -## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric -## around the canonical origin, matching enter_orbital()'s own "canonical -## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's -## own center-of-mass lands exactly on the canonical frame, not offset from -## it. -## -## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a -## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback -## disposition (no circumference/tiling concept for a body with no radius). -static func compute_tile_grid(body_radius_km: float) -> Array: - if body_radius_km <= 0.0: - return [Vector2i.ZERO] - - var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km) - var cols: int = int(extent["cols"]) - var rows_half: int = int(extent["rows_half"]) - var rows_total: int = rows_half * 2 - - var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N))) - var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N))) - - var col_centers: Array = [] - for tx in range(cols_tiles): - var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N)) - col_centers.append(raw_col) - - var row_centers: Array = [] - for ty in range(rows_tiles): - var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N)) - row_centers.append(raw_row) - - # Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair — - # Godot Dictionary keys compare Vector2i by value, so this is a proper - # set. Insertion order is preserved (Godot Dictionaries are - # order-preserving), giving a deterministic tile ORDER too — the same - # grid always requests/draws in the same sequence, useful for progressive - # arrival to read as a stable left-to-right, top-to-bottom fill rather - # than an unpredictable one. - var seen: Dictionary = {} - var tiles: Array = [] - for raw_col: int in col_centers: - for raw_row: int in row_centers: - var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center( - Vector2i(raw_col, raw_row), body_radius_km - ) - if not seen.has(canonical): - seen[canonical] = true - tiles.append(canonical) - return tiles - - -# ============================================================================= -# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved -# here from atlas_window_viewer.gd for file-length (the viewer's own -# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since -# both are directly tested by name). -# ============================================================================= - - -## Body name + coordinate label — T-1142: shows the body's proper name -## (falling back to body_id) alongside the held district center, so the -## header never reads as bare "district (col, row)" with no indication of -## WHICH body the player is looking at. -static func location_label(body_display_name: String, held_center: Vector2i) -> String: - return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y] - - -## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header. -## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always -## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s -## doc), but the km/cell reading reflects the HELD rung's actual spacing -## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous -## metres-per-pixel/extent readout" design doc §6 calls for in place of a -## discrete "you are now in Quarter Mode" label (Jeroen's "no mode -## transition" ruling): the number itself communicates the rung. -static func screen_header_content( - body_display_name: String, - held_center: Vector2i, - held_n: int, - held_granularity_v2: String, - district_m: float -) -> Dictionary: - var label: String = location_label(body_display_name, held_center) - var extent_km: float = float(held_n) * district_m / 1000.0 - var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0 - var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km] - return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle} - - -## Cold-start dossier: the baseline position draw_string() needs to render -## `text` horizontally AND vertically centered inside `viewport_size` — pure -## geometry, split out of AtlasWindowViewer._draw_deriving_terrain_label() -## for file-length (gdlint max-file-lines), not a different concern. Callers -## pass HORIZONTAL_ALIGNMENT_CENTER to draw_string() themselves (that part -## isn't pure — it needs the real Font instance) — this only computes the Y -## baseline offset and the X center point draw_string()'s own centering -## then works from. -static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2: - var center: Vector2 = viewport_size * 0.5 - return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5) - - -## T-1172 round 2 (coordinator's "reconsider the split" ask): the SHARED -## cell-index formula both AtlasWindowOverlay's terrain painter (which builds -## the drawn `grid_side x grid_side` per-cell texture — `i = row * grid_side -## + col`, `img.set_pixel(col, row, ...)`) and AtlasWindowWaterClip's clip -## predicate (which must read the EXACT SAME cell for a given position, or -## the clip silently disagrees with what's actually painted) both need. The -## live trace that closed T-1172 round 2's investigation PROVED this formula -## itself was never the bug (painter and clip independently computed the -## identical col/row/idx for the same query throughout) — the actual bug was -## in the WRAP resolution one layer up (resolve_morphology_zone()'s own doc) -## — but factoring the index math into ONE shared function here, rather than -## two independently-maintained copies (atlas_window_overlay.gd's inline -## `row * grid_side + col` vs. the old duplicate in -## atlas_window_water_clip.gd), removes the STRUCTURAL risk of a future -## divergence in exactly the way the coordinator flagged as the general -## danger class ("stub-and-code agreeing on the wrong convention" — here, -## PAINTER-and-clip could drift the same way without a shared source). -## `local_x`/`local_y` are DISTRICT-SPACE offsets from the window's own -## top-left corner (`center - n/2`), in `[0, n)` — the SAME quantity both -## call sites already compute before this function is reached. -static func cell_index_for_local_offset( - local_x: float, local_y: float, n: int, grid_side: int -) -> Vector2i: - if n <= 0 or grid_side <= 0: - return Vector2i(-1, -1) - var col: int = clampi(int(floor(local_x / float(n) * float(grid_side))), 0, grid_side - 1) - var row: int = clampi(int(floor(local_y / float(n) * float(grid_side))), 0, grid_side - 1) - return Vector2i(col, row) diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd deleted file mode 100644 index 9e8f5f8ff..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd +++ /dev/null @@ -1,685 +0,0 @@ -extends RefCounted - -## Nature-overlay (river/basin/attractor) pure geometry + per-rung policy — -## split out of atlas_window_geometry.gd (T-1170, that file was at 954/1000 -## gdlint max-file-lines when this batch started) exactly the same way -## test_atlas_window_geometry_nature.gd was already split from -## test_atlas_window_geometry.gd — one file, one concern, room to grow. Every -## symbol below moved VERBATIM from atlas_window_geometry.gd; no behavior -## change in this split itself. atlas_window_nature_overlay.gd is the only -## runtime consumer (verified: grep across client/ before the move) and now -## preloads THIS file instead. -## -## Contains: -## - T-1156 wave 1 whole-body Layer-1 pixel-space -> canvas-local mapping -## (layer1_pixel_to_world_m/world_m_to_district/layer1_pixel_to_canvas_local) -## - T-1156 wave 1 per-rung skeleton visibility/styling policy (RIVER_CLASS_*, -## CONFLUENCES/MOUTHS/BASINS/ATTRACTORS_VISIBLE_BY_RUNG, dot/ring/attractor -## size consts) — RENAMED this batch (T-1170 Ruling 5c, see below) from -## RIVER_CLASS_VISIBLE_BY_RUNG to SKELETON_CLASS_VISIBLE_BY_RUNG. -## - zoom_compensated_size() — the screen-space marker-size zoom-compensation -## fix (coordinator live-eyeball finding, 2026-07-23). -## -## atlas_window_geometry.gd retains cell_index_for_local_offset() (T-1172 -## round 2) rather than moving it here — that function is shared with -## AtlasWindowOverlay's terrain painter (a non-nature consumer), so it stays -## on the base file both files already depend on, avoiding a nature-file -> -## base-file dependency for a symbol the base file's own painter needs too. -const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") - -## atlas_window_geometry.gd never depends on this file (verified: no preload -## of atlas_window_geometry_nature.gd anywhere in that file) — so preloading -## it back here is safe, no circular dependency, matching the pattern -## AtlasWindowOverlay/AtlasWindowWaterClip already use for -## AtlasWindowGeometryRef. -const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -## D-243 district spacing, metres/district — this file's own copy of -## AtlasWindowGeometry.DISTRICT_SPACING_M (duplicated, not preloaded-and-read, -## matching this cluster's existing "each file owns its own reading of a -## small pure constant rather than force a dependency" precedent — -## atlas_overlay_colors.gd's header doc states this explicitly; the same -## rationale that kept atlas_window_water_clip.gd's cell_grid_side_for_window() -## a deliberate duplicate rather than a shared call applies here). MUST stay -## numerically identical to the base file's constant — both ultimately trace -## to D-243's 2,048 m district spacing, which is locked project vocabulary, -## not a value expected to drift. -const DISTRICT_SPACING_M: float = 2048.0 - - -# ============================================================================= -# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility/styling policy -# (Araminta's presentation ruling, 2026-07-23 — supersedes Tyre's provisional -# add-detail-as-you-descend mapping the ticket brief originally carried). -# -# T-1170 Ruling 5c (Tyre, 2026-07-23) — THE REVISIT, split on the carrier -# axis: the single RIVER_CLASS_VISIBLE_BY_RUNG table is replaced by TWO -# tables, one per presentation surface — -# - SKELETON_CLASS_VISIBLE_BY_RUNG: the Region+ whole-body skeleton-chord -# path (Ruling 5a) — unchanged posture from wave 1, Region shows every -# class. -# - COURSE_CLASS_VISIBLE_BY_RUNG: the District/Quarter windowed course- -# polyline path (Ruling 5b) — THIS is where "Quarter rivers return" -# (the pre-announced wave-1 fade-down revisit executes): District shows -# trunk+tributary, Quarter shows all three classes. -# Companion per-class width/opacity tables (COURSE_CLASS_WIDTH_PX/ -# COURSE_CLASS_OPACITY) carry FUNCTIONAL DEFAULTS per the ruling brief -# (trunk widest ~2.2px, tributary ~1.4px, stream ~0.9px, screen-space via the -# existing zoom-compensation discipline) — Araminta's forthcoming presentation -# ruling edits THESE TABLES AND ONLY THESE TABLES, same single-revisit-point -# discipline wave 1 established for RIVER_CLASS_VISIBLE_BY_RUNG itself. -# ============================================================================= - -## River class ids — mirrors server/src/atlas/body_world_state.rs -## RiverNetwork.river_class's own doc exactly (0=stream, 1=tributary, -## 2=trunk). A `river_class` array shorter than `river_cells` (pre-T-1156 -## payload, or the graceful-fallback empty-array case) has no per-cell class -## to read — RIVER_CLASS_FALLBACK is what a missing entry resolves to: TRUNK, -## so an old/absent river_class array still shows something at every rung -## rather than silently vanishing (Dudley's `#[serde(default)]` empty-array -## contract makes "index out of range" the normal case for a pre-T-1156 -## response, not an edge case to special-case away). -const RIVER_CLASS_STREAM: int = 0 -const RIVER_CLASS_TRIBUTARY: int = 1 -const RIVER_CLASS_TRUNK: int = 2 -const RIVER_CLASS_FALLBACK: int = RIVER_CLASS_TRUNK - - -# ============================================================================= -# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode — the wire -# convention `RiverNetwork.river_downstream` (Vec, index-aligned with -# river_cells) encodes per river cell: a DIRECTION 0-7 into an adjacent D8 -# neighbor, or a SENTINEL >= RIVER_DOWNSTREAM_SENTINEL_BASE marking a chain -# end (MOUTH/EDGE_DRAIN/reserved-TERMINAL). CONFIRMED against Dudley's A1 -# (server/src/atlas/drainage.rs:35-44, landed 0fea69feb, relayed by the -# coordinator) — these are the REAL shipped values, final until/unless the -# server's own encoding changes, in which case this is the one place to -# repoint. -# ============================================================================= - -## Sentinel base — any river_downstream value >= this is a chain-end -## sentinel, not a direction. Direction values are 0-7 (8 real D8 neighbors); -## sentinels start immediately above at 8. -const RIVER_DOWNSTREAM_SENTINEL_BASE: int = 8 -const RIVER_DOWNSTREAM_MOUTH: int = 8 -const RIVER_DOWNSTREAM_EDGE_DRAIN: int = 9 -## TERMINAL is reserved/unused in round 1 (Ruling 2c/7b — future endorheic -## basin support) — this client never expects to see it on real data yet, but -## decodes it identically to EDGE_DRAIN (chain end, no ring) rather than -## treating an unrecognized-but-in-sentinel-range value as an error, so a -## future server enabling TERMINAL needs no client change to degrade -## gracefully (it would just draw as an unmarked chain end until a future -## ticket gives it its own ring treatment, exactly EDGE_DRAIN's own current -## disposition). -const RIVER_DOWNSTREAM_TERMINAL: int = 10 - -## D8 direction index (0-7) -> (row_delta, col_delta), CONFIRMED against -## drainage.rs:35-44's own fdir table order (not assumed/guessed — the -## coordinator relayed this explicitly from Dudley's A1 source): row -## increases SOUTH (matching layer1_pixel_to_world_m()'s own "row 0 = north -## pole" convention, confirmed the same convention on both sides of this -## mapping), col increases EAST and WRAPS at the antimeridian (handled by the -## caller's existing nearest-wrap-image discipline, same as every other -## column value flowing through this file — this table itself has no wrap -## concept, it is pure grid-adjacency). -## 0 = N (-1, 0) 4 = NE (-1, 1) -## 1 = S ( 1, 0) 5 = NW (-1, -1) -## 2 = E ( 0, 1) 6 = SE ( 1, 1) -## 3 = W ( 0, -1) 7 = SW ( 1, -1) -const D8_DIRECTION_DELTAS: Array = [ - Vector2i(-1, 0), # 0 N - Vector2i(1, 0), # 1 S - Vector2i(0, 1), # 2 E - Vector2i(0, -1), # 3 W - Vector2i(-1, 1), # 4 NE - Vector2i(-1, -1), # 5 NW - Vector2i(1, 1), # 6 SE - Vector2i(1, -1), # 7 SW -] - -# ============================================================================= -# T-1170 Ruling 3h/5b: RiverCourse.terminus wire vocabulary — the -# CourseTerminus enum's variant NAMES as they arrive over msgpack (bare -# strings, the SAME "unit variant -> string tag" convention granularity_v2 -# already uses on this same wire — see AtlasMapProtocol's own doc). `None` on -# the Rust side (a mid-window course that neither reaches a real mouth nor -# the window edge — the ordinary "ends because the chord's amplitude taper -# reached zero at a confluence/headwater anchor cell inside this window" -## case) decodes to the bare string "None" per rmp_serde's unit-variant -## convention — NOT GDScript `null`. Callers must compare against the STRING -## constant below, never `== null`. -# ============================================================================= - -const COURSE_TERMINUS_NONE: String = "None" -const COURSE_TERMINUS_MOUTH: String = "Mouth" -const COURSE_TERMINUS_EDGE_DRAIN: String = "EdgeDrain" -const COURSE_TERMINUS_CONTINUES_BEYOND_WINDOW: String = "ContinuesBeyondWindow" - -## Region+ SKELETON path (Ruling 5a) — the whole-body chord-chain draw, built -## from river_downstream. Region shows every class (the full skeleton) — this -## table's posture is UNCHANGED from wave 1's original -## RIVER_CLASS_VISIBLE_BY_RUNG (renamed, not re-tuned). District/Quarter keys -## are retained (both empty) purely so a caller that queries this table by an -## unexpected rung tag gets the same documented "nothing visible" answer wave -## 1 shipped, rather than a KeyError — the SKELETON path itself is only ever -## drawn at Region in practice (District/Quarter draw courses, the OTHER -## table, per Ruling 5b). -const SKELETON_CLASS_VISIBLE_BY_RUNG: Dictionary = { - "Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], - "District": [], - "Quarter": [], -} - -## District/Quarter COURSE path (Ruling 5b/5c) — the windowed polyline draw, -## built from DistrictWindowLayer.courses. District: trunk+tributary (streams -## stay off at District — the ruling's own example enumeration). Quarter: -## ALL THREE classes — "Quarter rivers return", the pre-announced wave-1 -## fade-down revisit executing here. Region is not a key here at all (Region -## never draws courses — it draws the skeleton chord chain, the OTHER table) -## — a caller must not query this table at Region; river_class_visible_at_rung() -## style readers for this table live on this file too and fall back safely -## for an unrecognized tag (see course_class_visible_at_rung()'s own doc). -const COURSE_CLASS_VISIBLE_BY_RUNG: Dictionary = { - "District": [RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], - "Quarter": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], -} - -## Per-rung feature-group toggles beyond river-cell class filtering — whether -## confluences/mouths/basins/attractors draw at all at a given rung (each -## still additionally gated by its own overlay-bar toggle, RVR/BAS/ATR, where -## applicable — this table is the RUNG gate, the overlay bar is the PLAYER -## gate, both must pass). Mouths get the one rung-based exception in the whole -## table: District keeps them at full Region styling/opacity (a mouth is -## always a landmark, per the ruling) while every other District river feature -## is suppressed or de-emphasized. -const CONFLUENCES_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} -const MOUTHS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": true, "Quarter": false} -const BASINS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} -const ATTRACTORS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} - -## Per-rung river dot styling (screen-space px, at zoom=1.0 — the same -## "canvas-local px" domain every other drawn feature in this cluster already -## uses, scaled by the caller's own view zoom like everything else in -## `_canvas`). District trunk dots are smaller AND drawn at reduced opacity -## (80% — raised from the ruling's initial 60% in Araminta's PR #195 capture -## review: at 1.6px/60% the dot was "essentially invisible without knowing -## where to look", underselling the 'a major river crosses near here' intent; -## 2.0px/80% keeps the fade-down ladder vs Region's 2.2px/100% without -## reading as accidentally-erased) — the "fade down" the ruling describes; -## Region dots are full-strength opacity (alpha baked into the reused -## COLOR_GEN_RIVER/COLOR_GEN_MOUTH constants themselves, alpha 1.0). -## -## T-1170: these RIVER_DOT_* consts now describe the Region SKELETON path -## ONLY (Ruling 5a's chord-chain draw reuses the same per-class radii the old -## dot-scatter used — chords are drawn at these widths, not a new table). -## RIVER_DOT_RADIUS_DISTRICT_TRUNK/RIVER_DOT_OPACITY_DISTRICT_TRUNK are DEAD -## at District now that District draws courses (Ruling 5b/3g retires the -## District dot-scatter entirely) — left in place, unread by any T-1170 draw -## path, rather than deleted mid-batch: B3 (course polyline drawing) is the -## change that stops calling them; deleting here would be a premature edit to -## a still-referenced-by-wave-1-code constant ahead of that landing. -const RIVER_DOT_RADIUS_BY_CLASS_REGION: Dictionary = { - RIVER_CLASS_STREAM: 0.9, - RIVER_CLASS_TRIBUTARY: 1.4, - RIVER_CLASS_TRUNK: 2.2, -} -const RIVER_CONFLUENCE_RADIUS_REGION: float = 3.5 -const RIVER_DOT_RADIUS_DISTRICT_TRUNK: float = 2.0 -const RIVER_DOT_OPACITY_DISTRICT_TRUNK: float = 0.8 - -## T-1170 Ruling 5c: course polyline per-class width/opacity, District/Quarter -## COURSE path companion tables to COURSE_CLASS_VISIBLE_BY_RUNG above. -## FUNCTIONAL DEFAULTS ONLY (the ruling's own numbers) — Araminta's -## forthcoming presentation ruling edits these two tables and only these two -## tables, same discipline as every other single-revisit-point table in this -## file. Widths are screen-space px at zoom=1.0, routed through -## zoom_compensated_size()/the caller's `_zs()` wrapper before reaching -## draw_polyline() exactly like every other marker size in this cluster (PR -## #195's stroke-width miss is the standing regression class this discipline -## exists to prevent — see zoom_compensated_size()'s own doc). Opacities are -## plain [0,1] alpha multipliers on COLOR_GEN_RIVER, no zoom involvement. -## Trunk widest / stream thinnest, matching the Region skeleton's own -## per-class radius ordering (RIVER_DOT_RADIUS_BY_CLASS_REGION) so the visual -## "trunk is the biggest river" read is consistent whether the player is -## looking at the Region chord chain or a District/Quarter course polyline. -const COURSE_CLASS_WIDTH_PX: Dictionary = { - RIVER_CLASS_STREAM: 0.9, - RIVER_CLASS_TRIBUTARY: 1.4, - RIVER_CLASS_TRUNK: 2.2, -} -const COURSE_CLASS_OPACITY: Dictionary = { - RIVER_CLASS_STREAM: 0.8, - RIVER_CLASS_TRIBUTARY: 0.9, - RIVER_CLASS_TRUNK: 1.0, -} - -## Mouth double-ring geometry (Region AND District — mouths never de-emphasize, -## per the ruling) — verbatim from the retired atlas_marker_overlay.gd -## _draw_gen_rivers() (:537-539), reused exactly, not re-tuned. T-1170: also -## the mouth-ring geometry for REAL course termini (Ruling 5b/3e) — one -## geometry, both presentation surfaces (skeleton chord ends at Region, -## course polyline ends at District/Quarter). -const MOUTH_RING_RADIUS: float = 5.0 -const MOUTH_HALO_RADIUS: float = 8.0 -const MOUTH_HALO_ALPHA: float = 0.30 - -## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): -## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow -## rather than degenerate into a solid blob — see -## zoom_compensated_ring_radius()'s own doc for the full A/B bracket -## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with -## margin over the observed transition). -const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0 - -## Attractor minimum-strength gate — verbatim from the retired -## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per -## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering -## at any other rung to gate. -const ATTRACTOR_MIN_STRENGTH: float = 0.15 - - -# ============================================================================= -# T-1156 wave 1: whole-body Layer-1 (river/basin/attractor) pixel-space -> -# canvas-local mapping for the zoom ladder — the nature-overlay counterpart to -# the district/canvas machinery in atlas_window_geometry.gd. Layer-1's -# `river_network`/`drainage_basins`/`attractors` positions are (row, col) -# heightmap-pixel coordinates in a `grid_w`(cols) x `grid_h`(rows) working -# grid (Rust `Layer1Output.grid_w/grid_h` = `BodyHeightmap.width/height` = the -# SAME `TerrainAnalysis.w/h` river/attractor extraction ran against — -# server/src/atlas/layer1.rs, features.rs `TerrainAnalysis::analyze`). This is -# NOT the atlas_marker_overlay.gd `_gen_pos()` texture-fraction mapping (that -# maps onto a DISPLAYED heightmap texture on the retired planetary screen) — -# the ladder has no resident heightmap texture at all, so pixel positions must -# go all the way to WORLD METRES -> DISTRICT space -> canvas-local, the same -# frame AtlasWindowGeometry.district_to_canvas_local() already establishes for -# every other drawn feature on this screen. -# ============================================================================= - - -## Heightmap pixel (row, col) -> absolute world metres (wx east, wy south), -## mirroring server/src/atlas/district_profile.rs's `pixel_to_world_m()` -## EXACTLY (verified against that function's source, not assumed): longitude -## WRAPS and is addressed by the plain column fraction (`col / grid_w`) against -## the full circumference — column 0 sits at world/longitude 0, no -0.5 -## centering unlike latitude. Latitude CLAMPS at the poles and is addressed by -## `row / (grid_h - 1) - 0.5`, i.e. row 0 is exactly the pole (lat_frac -0.5 = -## north pole = wy negative-most) and row (grid_h - 1) is exactly the opposite -## pole (lat_frac +0.5 = south pole = wy positive-most) — the SAME "row -## increases southward" convention AtlasDescendGeometry.district_pos_at() -## already assumes for its own (inverse-direction) pixel<->district mapping, -## confirmed here to be the same convention layer1's grid uses, not a -## different one that happens to share variable names. -## -## No-radius bodies (body_radius_km <= 0, tiny test bodies): 1 heightmap pixel -## = 1 district-spacing metre, matching pixel_to_world_m()'s own no-radius -## fallback (`px * scale::DISTRICT_M`) and district_pos_at()'s no-radius -## branch on the other side of this mapping. Uses this file's own -## DISTRICT_SPACING_M (the same 2,048 m/district constant — see that const's -## own doc for why it's a deliberate duplicate, not a preload-and-read). -static func layer1_pixel_to_world_m( - row: float, col: float, grid_w: float, grid_h: float, body_radius_km: float -) -> Vector2: - if grid_w <= 0.0 or grid_h <= 0.0: - return Vector2.ZERO - if body_radius_km <= 0.0: - return Vector2(col * DISTRICT_SPACING_M, row * DISTRICT_SPACING_M) - var circumference_m: float = TAU * body_radius_km * 1000.0 - var meridian_m: float = PI * body_radius_km * 1000.0 - var wx: float = (col / grid_w) * circumference_m - var lat_frac: float = (row / (grid_h - 1.0) - 0.5) if grid_h > 1.0 else 0.0 - var wy: float = lat_frac * meridian_m - return Vector2(wx, wy) - - -## World metres -> fractional DistrictPos (NOT rounded to an integer district -## — a river dot's true position is sub-district-precise even though the -## window grid itself is district-granular; rounding here would visibly snap -## every river pixel onto a district lattice). DISTRICT_SPACING_M is this -## file's own existing constant (2,048 m/district, D-243) — one division, no -## re-derivation. -static func world_m_to_district(world_m: Vector2) -> Vector2: - return world_m / DISTRICT_SPACING_M - - -## The full pixel(row,col) -> canvas-local composition a nature-overlay draw -## call needs in one step: heightmap pixel -> world metres -> fractional -## district -> canvas-local (via AtlasWindowGeometry.district_to_canvas_local(), -## reused verbatim so a river dot lands in exactly the same coordinate frame -## every other drawn feature on this screen already agrees on — pan/zoom/rung -## crossings all move the SAME transform under everything drawn into -## `_canvas`). Wrap resolution (AtlasWindowGeometry.nearest_wrap_image()) is -## the CALLER's job, same split the tile mosaic draw path already uses — this -## function's `district` output is the RAW (un-wrapped) fractional position; a -## caller iterating river cells against a specific held window picks the -## nearest wrap-image of the COLUMN only (rows never wrap, matching every -## other wrap-aware caller in this cluster). -static func layer1_pixel_to_canvas_local( - row: float, - col: float, - grid_w: float, - grid_h: float, - body_radius_km: float, - held_center: Vector2i, - held_n: int, - cell_pixel_size: float -) -> Vector2: - var world_m: Vector2 = layer1_pixel_to_world_m(row, col, grid_w, grid_h, body_radius_km) - var district: Vector2 = world_m_to_district(world_m) - return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) - - -## T-1170 Ruling 5b/3h: RiverCourse.points are already WORLD METRES on the -## wire (unlike the skeleton path's heightmap-pixel `river_cells` — see -## Ruling 3h's wire shape doc) — one fewer conversion step than -## layer1_pixel_to_canvas_local() above: world metres -> fractional district -## (world_m_to_district(), reused verbatim) -> canvas-local -## (AtlasWindowGeometry.district_to_canvas_local(), same shared transform -## every other drawn feature on this screen uses). No pixel-grid/body-radius -## step at all — courses have no heightmap-pixel domain to convert out of. -static func world_m_to_canvas_local( - world_m: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float -) -> Vector2: - var district: Vector2 = world_m_to_district(world_m) - return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) - - -# ============================================================================= -# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode. -# ============================================================================= - - -## Decode one river cell's `river_downstream` wire value into its downstream -## neighbor's (row, col) heightmap-pixel position, or `null` if the value is -## a chain-end sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or an out-of-range/ -## malformed direction. `row`/`col` are the UPSTREAM cell's own pixel -## position (float, matching this file's own row/col domain everywhere -## else); the return value (when non-null) is a Vector2 in that SAME -## (row, col) pixel domain — NOT yet converted to world metres/district/ -## canvas-local, that conversion is the caller's job via the usual -## layer1_pixel_to_world_m()/world_m_to_district() pipeline, exactly as if -## the target were itself an entry read out of `river_cells`. -## -## Deliberately returns the RAW grid-adjacent position rather than looking it -## up in a `river_cells` array — a D8 downstream pointer always names a real -## adjacent grid cell by construction (that is what D8 flow direction means), -## whether or not that specific cell independently appears in whatever -## (possibly filtered) `river_cells` list the caller is iterating. -static func d8_downstream_target(row: float, col: float, downstream_raw: int) -> Variant: - if downstream_raw < 0 or downstream_raw >= RIVER_DOWNSTREAM_SENTINEL_BASE: - return null # sentinel or malformed — no real direction to decode - var delta: Vector2i = D8_DIRECTION_DELTAS[downstream_raw] - return Vector2(row + float(delta.x), col + float(delta.y)) - - -## T-1170 Ruling 5a — pure chord-chain CONSTRUCTION (no draw calls, no water -## clip, no canvas-local conversion): given `river_cells`/`river_class`/ -## `river_downstream` (the raw decoded river_network sub-dict arrays) and a -## `granularity_v2` rung tag, returns an Array of -## `{"from": Vector2, "to": Vector2, "cls": int}` dicts — one per river cell -## whose class is visible at this rung AND whose river_downstream pointer -## resolves to a real direction (not a sentinel, not out of range, not -## missing). `from`/`to` are in the SAME (row, col) heightmap-pixel domain -## `river_cells` entries themselves use — the caller converts to world -## metres/district/canvas-local and applies the water clip, exactly as if it -## had built this list inline (this function exists so that CONSTRUCTION is -## unit-testable without a live render pass — draw_line() itself requires -## one, per this cluster's own "pure function tests are the gate" draw-smoke -## caveat, so the chain-walking logic that actually decides WHICH segments -## exist must not be entangled with the draw call that paints them). -## -## Split out of AtlasWindowNatureOverlay._draw_skeleton_chords() specifically -## so a test can assert "this exact set of segments was constructed from -## this exact fixture" (including the sentinel-chain-end and malformed-input -## cases) without a SubViewport/render context — matching this file's -## existing "geometry/construction here, draw calls in the overlay node" -## split for every other piece of this cluster. -static func build_skeleton_chords( - river_cells: Array, river_class: Array, river_downstream: Array, granularity_v2: String -) -> Array: - var chords: Array = [] - for idx in range(river_cells.size()): - var c: Variant = river_cells[idx] - if not (c is Array and c.size() >= 2): - continue - var cls: int = int(river_class[idx]) if idx < river_class.size() else RIVER_CLASS_FALLBACK - if not skeleton_class_visible_at_rung(cls, granularity_v2): - continue - if idx >= river_downstream.size(): - continue # no downstream pointer for this cell yet — no segment - var downstream_raw: int = int(river_downstream[idx]) - var row: float = float(c[0]) - var col: float = float(c[1]) - var target: Variant = d8_downstream_target(row, col, downstream_raw) - if target == null: - continue # sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or malformed direction — chain end - chords.append({"from": Vector2(row, col), "to": target, "cls": cls}) - return chords - - -## T-1170 Ruling 5b/3h — pure course-polyline CONSTRUCTION (no draw calls): -## given one raw `RiverCourse` dict (as decoded off the wire — `class`, -## `points` (world-metres `[x,y]` pairs), `terminus` (a bare string tag)) and -## the window's own `granularity_v2`/`held_center`/`held_n`/`cell_pixel_size`, -## returns `null` if the course should not draw at all at this rung (class -## not visible, missing/degenerate points), or -## `{"canvas_pts": PackedVector2Array, "cls": int, "terminus": String}` -## ready for the caller to draw_polyline() + terminus-marker dispatch. -## -## Class defaults to RIVER_CLASS_FALLBACK (TRUNK) when missing, the same -## graceful-decode posture as the skeleton path's river_class fallback. -## `terminus` defaults to COURSE_TERMINUS_NONE when missing — an ordinary -## interior/no-marker ending, never crashing on an old/malformed payload. -## Malformed individual points are skipped (not fatal to the whole polyline, -## matching build_skeleton_chords()'s own "skip the bad entry, keep going" -## posture) — if fewer than 2 valid points remain after skipping, returns -## `null` (nothing to draw a line between). -static func build_course_render_plan( - course: Dictionary, - granularity_v2: String, - held_center: Vector2i, - held_n: int, - cell_pixel_size: float -) -> Variant: - var cls: int = int(course.get("class", RIVER_CLASS_FALLBACK)) - if not course_class_visible_at_rung(cls, granularity_v2): - return null - var points_raw: Variant = course.get("points") - if not points_raw is Array or (points_raw as Array).size() < 2: - return null - - var canvas_pts: PackedVector2Array = PackedVector2Array() - for pt: Variant in points_raw: - if not (pt is Array and pt.size() >= 2): - continue # malformed point — skip it, don't fail the whole polyline - var world_m := Vector2(float(pt[0]), float(pt[1])) - canvas_pts.append(world_m_to_canvas_local(world_m, held_center, held_n, cell_pixel_size)) - if canvas_pts.size() < 2: - return null # too many malformed points left too few to draw a line - - var terminus: String = str(course.get("terminus", COURSE_TERMINUS_NONE)) - return {"canvas_pts": canvas_pts, "cls": cls, "terminus": terminus} - - -# ============================================================================= -# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility policy READERS. -# The policy TABLES themselves live up in the top-of-file const block per -# class-definitions-order. -# ============================================================================= - - -## Whether a river cell of `river_class` should draw on the Region+ SKELETON -## path (Ruling 5a) at `granularity_v2`. An unrecognized rung tag falls back -## to Region's (fullest) visibility set — matching this cluster's existing -## "unrecognized -> most permissive/safest already-shipped behavior" posture -## (see AtlasWindowOverlay._filter_for_granularity_v2()'s own doc for the same -## fallback shape, there choosing the safer LINEAR filter for an unknown tag). -static func skeleton_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: - var visible: Array = SKELETON_CLASS_VISIBLE_BY_RUNG.get( - granularity_v2, SKELETON_CLASS_VISIBLE_BY_RUNG["Region"] - ) - return visible.has(river_class) - - -## Whether a river class should draw on the District/Quarter COURSE path -## (Ruling 5b) at `granularity_v2`. No Region key exists in -## COURSE_CLASS_VISIBLE_BY_RUNG (Region never draws courses) — an unrecognized -## OR Region tag both fall back to an EMPTY array (nothing visible), the -## inverse fallback posture from skeleton_class_visible_at_rung() above, -## deliberately: falling back to "show everything" for a course-path query at -## an unexpected rung would risk drawing course polylines at Region, which no -## window response ever carries (courses are windowed-only content, Ruling 1) -## — failing to EMPTY is the safe direction on this specific table. -static func course_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: - var visible: Array = COURSE_CLASS_VISIBLE_BY_RUNG.get(granularity_v2, []) - return visible.has(river_class) - - -static func confluences_visible_at_rung(granularity_v2: String) -> bool: - return bool(CONFLUENCES_VISIBLE_BY_RUNG.get(granularity_v2, true)) - - -static func mouths_visible_at_rung(granularity_v2: String) -> bool: - return bool(MOUTHS_VISIBLE_BY_RUNG.get(granularity_v2, true)) - - -static func basins_visible_at_rung(granularity_v2: String) -> bool: - return bool(BASINS_VISIBLE_BY_RUNG.get(granularity_v2, true)) - - -static func attractors_visible_at_rung(granularity_v2: String) -> bool: - return bool(ATTRACTORS_VISIBLE_BY_RUNG.get(granularity_v2, true)) - - -## Per-class course polyline width (screen-space px, zoom=1.0 domain — see the -## const's own doc). Falls back to the stream (thinnest) width for an -## unrecognized class id, matching RIVER_DOT_RADIUS_BY_CLASS_REGION's own -## `.get(cls, 2.2)` call-site fallback shape on the skeleton side (there the -## fallback is trunk/widest — the caller passes a literal default; here the -## table itself owns a documented fallback since this is a NAMED reader, not -## an inline `.get()`). -static func course_class_width_px(river_class: int) -> float: - return float(COURSE_CLASS_WIDTH_PX.get(river_class, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM])) - - -## Per-class course polyline opacity multiplier on COLOR_GEN_RIVER. Same -## unrecognized-class fallback posture as course_class_width_px() above. -static func course_class_opacity(river_class: int) -> float: - return float(COURSE_CLASS_OPACITY.get(river_class, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM])) - - -## Coordinator live-eyeball finding (2026-07-23): Araminta's ruling specifies -## nature-overlay marker sizes as SCREEN-SPACE px, constant regardless of -## zoom — but every draw call in this cluster (river dots, mouth rings, basin -## line widths, T-1170 course polylines/chords) executes inside `_canvas`, a -## Node2D whose `.scale` IS `_view_zoom` (AtlasWindowViewer._apply_transform()). -## A raw radius/width constant handed to draw_circle()/draw_arc()/ -## draw_polyline() therefore gets multiplied by `_view_zoom` at render time — -## invisible at the Region orbital tile mosaic's fit zoom (~0.0063 for Lendel: -## a 2.2px trunk-river dot rasterizes at ~0.014 screen px, sub-pixel), even -## though the SAME drawing code produces a correctly-sized (visible) mouth -## ring at District's much larger fit zoom (~3.75, live capture confirmed -## this). The fix: every marker's draw-time radius/width must be pre-divided -## by `view_zoom` so the canvas transform's multiply cancels back out to the -## ruling's literal screen-space value. `view_zoom` is clamped to a small -## positive floor (MIN_ZOOM's own order of magnitude) to avoid a -## divide-by-zero/near-zero blowup on a degenerate zero-zoom caller — this -## floor is far below any legal `_view_zoom` (AtlasWindowViewer.MIN_ZOOM = -## 0.0005), so it is inert for every real caller and only guards a malformed -## test input. -static func zoom_compensated_size(screen_space_size: float, view_zoom: float) -> float: - return screen_space_size / maxf(view_zoom, 0.0001) - - -## T-1170 live-round finding (2026-07-23, coordinator/Araminta's pixel-scan -## of the course captures — D-district-courses.png/Q-quarter-courses.png -## showed a UNIFORM 1px hairline for the entire course, no width/opacity -## variation at all): zoom_compensated_size() is correct arithmetic (verified: -## 2.2 / 3.75 = 0.5867, and 0.5867 * 3.75 round-trips to 2.2 exactly — the -## compensation MATH has never been the bug), but it has NO FLOOR against -## Godot's own STROKE-WIDTH rasterizer minimum — confirmed empirically via a -## live A/B bracket (temporary instrumentation, since reverted): draw_line()/ -## draw_polyline() called with a width in [0.6, 1.0) canvas-local units -## renders as a flat 1px hairline REGARDLESS of the input value, identically -## on both APIs (ruling out a draw_polyline()-specific quirk) — Godot's line -## rasterizer treats any width below ~1.0 the same as its historical -## width=-1.0 "hairline" sentinel, rather than continuing to shrink the -## antialiased stroke sub-pixel the way draw_circle()'s radius parameter -## does (mouth rings at the SAME District/Quarter zoom levels render -## correctly-sized — confirmed, radii have no equivalent floor). -## -## District/Quarter fit zooms (3.75/7.5+, and the player can zoom further -## within a rung) divide COURSE_CLASS_WIDTH_PX's 0.9-2.2px table values down -## to 0.12-0.59 canvas-local units — BELOW the 1.0 floor — so every course -## class collapses to the identical hairline the moment view_zoom exceeds -## roughly `screen_space_size` itself. This is the STROKE-WIDTH-SPECIFIC -## sibling of zoom_compensated_size() (which remains correct and unchanged -## for radii/point sizes, its own existing floor is a divide-by-zero guard -## only, not a rasterizer-minimum guard) — a SEPARATE function because the -## two draw families have genuinely different Godot-side minimums, not a -## single shared bug. -## -## The fix clamps the OUTPUT to a 1.0 canvas-local-unit floor — the closest -## representable value to "as thin as Godot's rasterizer can actually draw a -## non-hairline stroke" — rather than letting the divide produce a -## sub-floor value that Godot silently reinterprets as hairline anyway. This -## is an honest floor, not a workaround: below it, EVERY value (0.373, 0.6, -## 0.9999...) already rendered identically as hairline before this fix, so -## clamping to exactly 1.0 changes nothing about what could already be drawn -## at that zoom — it only stops different classes/rungs from silently -## collapsing to the SAME wrong result and starts drawing the class/opacity -## variation the ruling specifies. At extreme zoom-in (small view_zoom -## relative to the literal px value) the floor never engages — the same -## divide-then-scale math takes over exactly as design intends, matching -## zoom_compensated_size()'s own behavior at Region's tiny fit zoom. -static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float: - return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0) - - -## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the -## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to -## zoom_compensated_size()/zoom_compensated_stroke_width(), needed -## specifically for draw_arc() RING markers (mouth rings — the only -## draw_arc() caller in this file whose radius and stroke width are BOTH -## small, zoom-compensated values that can cross each other). -## -## Root-cause evidence (live A/B bracket, temporary instrumentation, since -## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver): -## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE, -## already-correct-code issue — the terminus point legitimately sits near -## the requesting window's own edge, and the fit-and-center COVER strategy -## crops that edge off the visible viewport (screen_p verified computed as -## (1755, -191) against a 1920x1080 frame — above the top edge, not a -## drawing bug). Panning the view to re-center the SAME point (verified via -## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a -## SOLID BLOB, not a hollow ring, at the production radius/stroke pair -## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5): -## draw_arc()'s stroke, centered ON the radius circle, extends inward past -## the circle's own center once stroke exceeds ~1x the radius, filling the -## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied): -## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) -> -## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring -## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob -## persists past the naive geometric threshold (radius > stroke/2, where an -## infinitely-thin/perfectly-antialiased ring would already have a hole) -## because draw_arc()'s low tessellation (18 points, this file's own call) -## plus antialiasing blur eat into the theoretical hole at these tiny -## absolute magnitudes — an empirical floor, not a derived one, chosen with -## margin over the observed 1.0x-blob/1.5x-hollow transition rather than -## shaving the boundary exactly. -## -## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER` -## (2.0, the top-of-file const — verified clean in the bracket above) -## whenever the naive zoom-compensated radius would fall below it — the -## same "floor the OUTPUT, never let a sub-threshold value reach Godot's -## renderer" pattern zoom_compensated_stroke_width() already established, -## applied to the paired radius/stroke relationship a lone-value floor -## can't express (unlike the stroke-width floor, this one's threshold is -## RELATIVE to another draw-time value, not an absolute constant). At every -## zoom where the naive radius already clears the floor on its own -## (Region's dot radii, or any District/Quarter case wide enough), this is -## an exact no-op — identical to calling zoom_compensated_size() directly. -static func zoom_compensated_ring_radius( - screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float -) -> float: - var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom) - return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER) diff --git a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd deleted file mode 100644 index 82998c9f4..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd +++ /dev/null @@ -1,750 +0,0 @@ -extends Node2D - -## Draws the whole-body Layer-1 nature overlays (rivers/basins/attractors, -## T-1156 wave 1) on the AtlasWindowViewer zoom ladder. Child of -## AtlasWindowViewer._canvas, ABOVE the terrain composite (AtlasWindowOverlay) -## and below UI chrome — same parent, same pan/zoom transform, drawn after so -## river dots/basin fills sit on top of the terrain colorizer. -## -## T-1170 (Ruling 5a): the Region+ river dot-scatter upgraded to CONNECTED -## STRAIGHT CHORDS via river_downstream (_draw_skeleton_chords()) — per -## Ruling 3b this chord chain IS the rung-truncated course at Region -## truncation, not an approximation of it. District/Quarter no longer draw -## the (now-retired) dot-scatter at all; they draw windowed course polylines -## instead (Ruling 5b, _draw_courses()). -## -## T-1170 Ruling 5b (B3): course polylines ride `DistrictWindowLayer.courses` -## — a SEPARATE data source from `_layer1` above (courses arrive on the -## WINDOWED response, `viewer.get_district_window()`, not the whole-body -## Layer-1 response this node requests via request_layer1()). _draw() is -## therefore two INDEPENDENT gates, not one: the skeleton/basin/attractor -## path gates on `_layer1 != null` (unchanged); the course path gates on -## `viewer.get_district_window()` being a Dictionary with a `courses` key, -## entirely independent of whether Layer-1 has arrived yet — a player who -## descends straight to District without the whole-body fetch completing -## still sees courses the moment the window arrives. NO T-1172 water clip on -## this path (Ruling 3g) — courses carry real rung-consistent termini -## server-side (the whole POINT of windowing course invention, Ruling 1d). -## -## This is a PORT, not a reactivation, of the retired planetary-screen draw -## code (atlas_marker_overlay.gd:523-572, _draw_gen_rivers/_draw_gen_basins/ -## _draw_gen_attractors) — atlas_marker_overlay.gd stays retired/unreachable. -## The drawing IDEAS survive (polygon basins, glyph-free double-ring mouths, -## draw order basins-under-rivers-under-attractors; the dot-scatter idea -## itself is superseded at Region by T-1170's chord chain, see above); the -## COORDINATE MAPPING does not — the retired code projected onto a resident -## displayed heightmap TEXTURE (_gen_pos(), texture-fraction space) that this -## ladder screen has no equivalent of. Positions here go all the way through -## world metres -> district -> canvas-local -## (AtlasWindowGeometryNature.layer1_pixel_to_canvas_local()), the same frame every -## other drawn feature on this screen already shares, wrap-resolved exactly -## like the tile mosaic resolves terrain tiles. -## -## Data source: the WHOLE-BODY Layer-1 response (`layer1` key on the shared -## AtlasLayerResponse envelope, SimBridge.atlas_layers_received) — a SEPARATE -## fetch from the windowed DistrictWindowLayer composite AtlasWindowOverlay -## draws (both responses ride the SAME signal, discriminated by which -## envelope key is populated — SimBridge/atlas_map_protocol.gd's decode -## always includes both `layer1` and `district_window` keys, only one -## non-null per response, per that decoder's own doc). This node connects to -## SimBridge.atlas_layers_received DIRECTLY (mirroring -## atlas_window_tile_set.gd's own "one shared inbound signal, N independent -## consumers filtering by their own criteria" shape) rather than being routed -## through AtlasWindowViewer's own _on_atlas_layers_received() — the viewer -## stays at the gdlint max-file-lines cap with this node needing zero new -## lines in that function. Requested once per body entry via request_layer1() -## (call sites: the viewer's _enter_at_rung() and _enter_tile_mode() — every -## fresh descent funnels through one of those two; enter() itself is a thin -## wrapper over _enter_at_rung and has no call of its own), which owns -## clearing stale data on a body change itself -## (see that function's own doc, no separate reset() call needed) — cached -## thereafter, rivers are static per body, no re-request on pan/zoom/rung -## crossing. -## -## No `class_name` on purpose, matching every other viewer-owned helper in -## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd/ -## atlas_window_tile_set.gd, review #8 precedent): the owner -## (AtlasWindowViewer) passes itself to `_init()`. -## -## Redraw wiring: no _process() poll — AtlasWindowViewer/AtlasWindowOverlay -## already call `_overlay_node.queue_redraw()` on every pan/zoom/rung- -## crossing/window-arrival event; adding this node as a SIBLING of -## AtlasWindowOverlay under `_canvas` means the viewer's existing redraw call -## sites need exactly one more line each (`_nature_overlay.queue_redraw()` -## alongside the existing `_overlay_node.queue_redraw()`) — trivial wiring on -## the viewer, no new redraw PATH. This node's OWN _on_atlas_layers_received() -## also queue_redraw()s directly (the direct-signal-connection path bypasses -## the viewer's own arrival redraw calls, so it must trigger its own). Once -## layer1 arrives for a body it never goes stale until the next -## enter()/enter_orbital(), so unlike the tile mosaic's pending-tile poll, -## this node never needs a _process() self-heal. - -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") -# T-1170: the nature-overlay pixel-mapping + per-rung visibility policy split -# out of atlas_window_geometry.gd — see that file's own doc. -const AtlasWindowGeometryNature := preload("res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd") -const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") -const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") -# T-1172: two-waterline clip — see that file's own header doc. -const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd") - -## Reused verbatim from the retired atlas_marker_overlay.gd (Araminta's -## ruling: "reuse the retired palette exactly") — same values, same source of -## truth, just no longer read from the retired file. -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) - -var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) - -## Whole-body Layer1Output dict (river_network/drainage_basins/attractors/ -## grid_w/grid_h), or null before the first response for the current body. -## Set by _on_atlas_layers_received() (this node's own direct signal -## connection); request_layer1()/reset() manage the request lifecycle. -var _layer1: Variant = null -var _requested_body_id: String = "" - - -func _init(viewer_ref = null) -> void: - viewer = viewer_ref - - -func _ready() -> void: - SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) - - -func _exit_tree() -> void: - if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): - SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) - - -## Adopt a Layer-1 response — ignores non-Layer1 responses (the windowed -## DistrictWindowLayer envelope leaves `layer1` null, per -## atlas_response_from_raw()'s own doc) and responses for a body this node -## didn't ask for (the player navigated away while the request was in -## flight, or this node never issued a request at all — an empty -## `_requested_body_id` must never match an empty response `body_id`, -## matching request_layer1()'s own "empty body_id is never a valid request" -## guard), matching AtlasGenerationProxy.on_response()'s own staleness guard. -func _on_atlas_layers_received(response: Dictionary) -> void: - if _requested_body_id.is_empty(): - return - var layer1: Variant = response.get("layer1") - if layer1 == null: - return - if str(response.get("body_id", "")) != _requested_body_id: - return - _layer1 = layer1 - queue_redraw() - - -## Request a body's Layer-1 data — the ONE entry point every fresh-descent -## viewer path (enter()/_enter_at_rung()/_enter_tile_mode()) calls, so it owns -## its own reset-on-body-change instead of requiring callers to remember to -## call reset() first (a single call site per entry path stays a one-line -## addition to atlas_window_viewer.gd, keeping that file at its gdlint cap). -## Idempotent per body — a second call for the SAME body_id (e.g. a -## re-entrant enter_orbital()/rung-crossing re-descent on the body already -## showing) is a no-op, keeping the held data drawn rather than flashing it -## away and re-fetching. A DIFFERENT body_id clears the stale data FIRST (so -## the previous body's rivers never draw over the new body's terrain during -## the gap) then re-requests. No-op on an empty body_id (matches -## AtlasGenerationProxy.request()'s own guard). -func request_layer1(body_id: String) -> void: - if body_id.is_empty(): - return - if body_id == _requested_body_id and _layer1 != null: - return - if body_id != _requested_body_id: - _layer1 = null - _requested_body_id = body_id - SimBridge.request_atlas_layers(body_id) - - -func get_layer1() -> Variant: - return _layer1 - - -## T-1170 (B3): TWO INDEPENDENT draw gates, not one — see the class doc's own -## "two independent gates" paragraph. The skeleton/basin/attractor path -## (Layer-1, whole-body) is unchanged from wave 1; the course path (windowed, -## Ruling 5b) is a SEPARATE early-return chain reaching _draw_courses(), -## checked regardless of whether `_layer1` has arrived — a player descending -## straight into District/Quarter must see courses without waiting on the -## whole-body Layer-1 fetch this node happens to also own. -func _draw() -> void: - if viewer == null: - return - _draw_skeleton_path() - _draw_course_path() - - -## The pre-T-1170 draw gate, unchanged in shape: whole-body Layer-1 -## (rivers/basins/attractors), gated on `_layer1` having arrived. -func _draw_skeleton_path() -> void: - if _layer1 == null: - return - var rn: Variant = _layer1.get("river_network") - if not rn is Dictionary: - return - var grid_w: float = float(_layer1.get("grid_w", 0)) - var grid_h: float = float(_layer1.get("grid_h", 0)) - if grid_w <= 0.0 or grid_h <= 0.0: - return - - var granularity_v2: String = viewer.get_held_granularity_v2() - var radius_km: float = viewer.get_body_radius_km() - var held_center: Vector2i = viewer.get_held_center() - var held_n: int = viewer.get_held_n() - var cell_px: float = viewer.get_cell_pixel_size() - var cols: int = _cols_for_wrap(radius_km) - - var ctx := { - "grid_w": grid_w, - "grid_h": grid_h, - "radius_km": radius_km, - "held_center": held_center, - "held_n": held_n, - "cell_px": cell_px, - "cols": cols, - "granularity_v2": granularity_v2, - # Coordinator live-eyeball finding (2026-07-23): every marker size - # below is drawn as a SCREEN-SPACE constant (Araminta's ruling), but - # draw calls execute inside _canvas, whose .scale IS view_zoom — a - # raw constant gets multiplied by that transform at render time, - # invisible at the Region orbital tile mosaic's tiny fit zoom - # (~0.006). zs() below pre-divides by view_zoom so the transform's - # multiply cancels back to the literal screen-space value. See - # AtlasWindowGeometryNature.zoom_compensated_size()'s own doc. - "view_zoom": viewer.get_view_zoom(), - } - - if AtlasWindowGeometryNature.basins_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( - "gen_basins" - ): - _draw_basins(ctx) - if viewer.is_overlay_visible("gen_rivers"): - _draw_rivers(rn, ctx) - if AtlasWindowGeometryNature.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( - "gen_attractors" - ): - _draw_attractors(ctx) - - -## T-1170 Ruling 5b (B3): the District/Quarter windowed COURSE path — an -## entirely separate data source (`viewer.get_district_window()`) and draw -## gate from _draw_skeleton_path() above. Gated on `gen_rivers` (the SAME -## overlay-bar toggle the skeleton path uses — one player-facing "rivers" -## toggle covers both presentation surfaces, matching Araminta's ruling that -## the two paths are one continuous feature from the player's perspective, -## not two separate layers to independently show/hide). -## -## Region NEVER reaches this function's draw calls (course_class_visible_at_ -## rung() has no Region key, always false there — Region draws the skeleton -## chord chain, never windowed course content, per Ruling 1). Tile mode -## (the Region orbital mosaic) also never carries `district_window` data at -## all (get_district_window() is single-window-mode-only, per that -## accessor's own doc — courses simply never reach this path in tile mode by -## construction, no separate is_tile_mode() guard needed here). -func _draw_course_path() -> void: - if not viewer.is_overlay_visible("gen_rivers"): - return - var window: Variant = viewer.get_district_window() - if not window is Dictionary: - return - var w: Dictionary = window - var courses: Variant = w.get("courses") - if not courses is Array: - return # missing `courses` field (old/pre-A2 payload) — draw nothing, see class doc - var granularity_v2: String = str(w.get("granularity_v2", "District")) - var ctx := { - "held_center": viewer.get_held_center(), - "held_n": viewer.get_held_n(), - "cell_px": viewer.get_cell_pixel_size(), - "granularity_v2": granularity_v2, - "view_zoom": viewer.get_view_zoom(), - } - - for course: Variant in courses: - if not course is Dictionary: - continue - _draw_one_course(course, ctx) - - -## T-1170 Ruling 5b: one RiverCourse's polyline draw — class-filtered per -## COURSE_CLASS_VISIBLE_BY_RUNG, width/opacity from the COURSE_CLASS_WIDTH_PX/ -## COURSE_CLASS_OPACITY companion tables (zoom-compensated via _zs(), the SAME -## discipline every other marker in this cluster follows — PR #195's -## stroke-width miss is the standing regression class this exists to -## prevent). Points arrive as `Vec<(i32,i32)>` WORLD METRES (Ruling 3h, NOT -## heightmap pixels — see world_m_to_canvas_local()'s own doc for why this is -## one conversion step shorter than the skeleton path). NO T-1172 water -## clip on this path (Ruling 3g) — courses carry real rung-consistent -## termini server-side; that is the entire point of windowing course -## invention (Ruling 1d). -## -## Terminus handling (Ruling 3h's CourseTerminus vocabulary): -## - Mouth: double-ring at the LAST point (the real coast anchor — mouths -## return as real geometry here, per Ruling 3g/3e). -## - EdgeDrain: no ring (Ruling 3f — pole-edge drains are grid artifacts, -## not river-meets-sea events; same disposition as the skeleton path's -## EDGE_DRAIN sentinel). -## - ContinuesBeyondWindow: draw to the last point, no marker (the course -## keeps going outside this window's crop — nothing to mark AT this -## window's edge, the polyline simply stops because the data stops). -## - None: an ordinary interior terminus (a headwater/confluence anchor -## inside this window) — no marker, same as ContinuesBeyondWindow's "just -## stop drawing" treatment; the two differ in MEANING (why the points ran -## out) but not in PRESENTATION (neither gets a ring). -## The actual gating/construction (class visibility, point decode, terminus -## lookup) is delegated to AtlasWindowGeometryNature.build_course_render_plan() -## — a pure function with no draw calls, unit-tested directly in -## test_atlas_window_geometry_nature.gd, the SAME split B2's -## build_skeleton_chords() already established. This function's own job is -## just the draw calls the plan feeds. -func _draw_one_course(course: Dictionary, ctx: Dictionary) -> void: - var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( - course, ctx["granularity_v2"], ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - if plan == null: - return - var canvas_pts: PackedVector2Array = plan["canvas_pts"] - var cls: int = plan["cls"] - var terminus: String = plan["terminus"] - - var width: float = _zs_stroke(AtlasWindowGeometryNature.course_class_width_px(cls), ctx) - var opacity: float = AtlasWindowGeometryNature.course_class_opacity(cls) - var color := Color(COLOR_GEN_RIVER.r, COLOR_GEN_RIVER.g, COLOR_GEN_RIVER.b, COLOR_GEN_RIVER.a * opacity) - draw_polyline(canvas_pts, color, width) - - if terminus == AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH: - _draw_mouth(canvas_pts[canvas_pts.size() - 1], ctx) - # EdgeDrain/ContinuesBeyondWindow/None: no marker — draw to the last - # point and stop, per the doc above. - - -## Circumference in districts, for nearest_wrap_image()'s wrap resolution — -## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation -## exactly (same source, same reason: only meaningful in tile/orbital mode on -## a real body; a no-radius body has no periodicity, `cols=0` is -## nearest_wrap_image()'s own documented no-op passthrough). -func _cols_for_wrap(radius_km: float) -> int: - if radius_km <= 0.0: - return 0 - return int(AtlasDescendGeometryRef.district_extent(radius_km).get("cols", 0)) - - -## Zoom-compensated screen-space size — thin per-ctx wrapper over -## AtlasWindowGeometryNature.zoom_compensated_size() (see that function's own -## doc for the "why divide" rationale). Every draw_circle()/draw_arc() RADIUS -## in this file routes through this so Araminta's "constant on-screen size" -## ruling holds at every rung/zoom. NOT for stroke widths — see _zs_stroke() -## below, added T-1170 live round (2026-07-23) after the course-polyline -## hairline finding: draw_line()/draw_polyline() STROKE WIDTH arguments have -## a Godot-side rasterizer floor radii don't share (confirmed empirically — -## zoom_compensated_stroke_width()'s own doc has the full A/B evidence). -func _zs(screen_space_size: float, ctx: Dictionary) -> float: - return AtlasWindowGeometryNature.zoom_compensated_size(screen_space_size, ctx["view_zoom"]) - - -## T-1170 live round (2026-07-23): the STROKE-WIDTH-specific sibling of -## _zs() — every draw_line()/draw_polyline()/draw_arc() STROKE WIDTH -## argument (never a radius/point-size) in this file routes through this -## instead of _zs(), so the width never crosses Godot's ~1.0-canvas-local- -## unit line-rasterizer floor and silently collapses to an -## indistinguishable hairline. See -## AtlasWindowGeometryNature.zoom_compensated_stroke_width()'s own doc for -## the full live-repro evidence (the course-path pixel scan that found this: -## a uniform 1px hairline with zero class/width variation in both District -## and Quarter captures). -func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float: - return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"]) - - -## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the -## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring -## marker whose radius and stroke are BOTH small, zoom-compensated values -## (currently: the two _draw_mouth() rings) routes its RADIUS through this -## instead of plain _zs(), so the radius never falls at-or-below its own -## paired stroke width and degenerates from a hollow ring into a solid blob. -## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for -## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow, -## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY -## zoom-compensated stroke value (this function's own caller passes -## _zs_stroke()'s result, not a raw screen-space width) — the floor compares -## against the SAME canvas-local units the naive radius divide produces. -func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float: - return AtlasWindowGeometryNature.zoom_compensated_ring_radius( - screen_space_radius, stroke_canvas_local, ctx["view_zoom"] - ) - - -## Pixel (row, col) -> fractional district position, wrap-resolved against -## the currently-HELD view's own center (ctx["held_center"]) — the -## representative wrap-image _pos()'s canvas conversion needs. T-1172: this -## is also the value fed to the water-clip lookup (_is_drawn_water()) — NOT -## a separately-computed position — so the clip test and the actual drawn -## position can never disagree about which longitude wrap-image is meant. -## Tile-mode's own per-tile wrap re-resolution (AtlasWindowWaterClip. -## resolve_morphology_zone()) re-derives whichever wrap-image a SPECIFIC -## tile needs internally; feeding it this held-center-wrapped value is a -## safe, consistent starting representative either way (longitude is -## periodic — any wrap-image of the same district resolves to the same -## real-world position). -func _district(row: float, col: float, ctx: Dictionary) -> Vector2: - var world_m: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( - row, col, ctx["grid_w"], ctx["grid_h"], ctx["radius_km"] - ) - var district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) - var cols: int = ctx["cols"] - if cols > 0: - var held_center: Vector2i = ctx["held_center"] - var wrapped_col: float = float( - AtlasWindowGeometry.nearest_wrap_image(roundi(district.x), held_center.x, cols) - ) - # Preserve the SUB-district fractional offset nearest_wrap_image()'s - # integer rounding would otherwise discard — river dots are not - # district-lattice-snapped (see world_m_to_district()'s own doc). - district.x = wrapped_col + (district.x - roundi(district.x)) - return district - - -## Pixel (row, col) -> canvas-local, wrap-resolved to whichever longitude -## image is nearest the currently-held view — the SAME two-step -## (map-then-nearest-wrap) the tile mosaic draw path uses, just for a single -## point instead of a tile's four corners. Thin wrapper over _district() + -## AtlasWindowGeometry.district_to_canvas_local() (T-1172 split: callers that -## also need the water-clip test call _district() directly instead, so the -## SAME resolved district feeds both the draw position and the clip check). -func _pos(row: float, col: float, ctx: Dictionary) -> Vector2: - return AtlasWindowGeometry.district_to_canvas_local( - _district(row, col, ctx), ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - - -## T-1172: whether the composite cell covering `district` is drawn as water -## (OpenOcean/Lake) — FAILS OPEN (returns false, "not water", i.e. draw the -## dot) when no arrived composite data covers the position, per Tyre's rule -## 5 ("the clip is a presentation refinement, never a data gate"). Resolves -## through AtlasWindowWaterClip.resolve_morphology_zone(), which handles -## BOTH the single-window rung path and Region tile mode internally — this -## function never branches on viewer.is_tile_mode() itself, matching that -## function's own "single dispatch point" doc. `ctx["held_center"].x` is -## threaded through (live round 2, coordinator's trace) — tile-mode -## resolution must wrap each tile's CENTER toward held_center EXACTLY like -## AtlasWindowOverlay._draw_tile_mosaic()'s own `draw_col` computation, or -## the clip silently tests the wrong wrap-image of a seam tile (see -## resolve_morphology_zone()'s own doc for the live repro). -func _is_drawn_water(district: Vector2, ctx: Dictionary) -> bool: - var is_tile_mode: bool = viewer.is_tile_mode() - var single_window: Variant = null if is_tile_mode else viewer.get_district_window() - var tiles: Array = [] - if is_tile_mode: - var tile_set = viewer.get_tile_set() - if tile_set != null: - tiles = tile_set.get_tiles() - var held_center: Vector2i = ctx["held_center"] - var zone: int = AtlasWindowWaterClip.resolve_morphology_zone( - district, is_tile_mode, single_window, tiles, ctx["cols"], held_center.x - ) - if zone == AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA: - return false - return AtlasOverlayColors.is_morphology_water(zone) - - -## T-1172 clip — RETAINED for this Region-skeleton path only (Ruling 3g: the -## clip retires for the District/Quarter COURSE-drawing rungs — see -## _draw_courses() below — because courses carry real rung-consistent -## termini and the clip's job is done there; the Region skeleton path keeps -## drawing against a rung-dependent drawn coast and needs the presentation- -## frame reconciliation until Region itself goes windowed, T-1143 ruling 2). -## River cells, confluences, and mouths are each dropped (strict, no snap) -## when their resolved composite cell reads as drawn water — see -## AtlasWindowWaterClip's own header doc for the two-waterline rationale. -## Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched. -## -## T-1170 Ruling 5a: at Region+, river cells draw as CONNECTED STRAIGHT -## CHORDS (each river cell to its river_downstream neighbor) instead of a -## dot-scatter — see _draw_skeleton_chords() below, called from here. -## District/Quarter no longer reach this function's river-cell/confluence -## loop at all (SKELETON_CLASS_VISIBLE_BY_RUNG has empty District/Quarter -## entries) — they draw via _draw_courses() instead (Ruling 5b), wired from -## _draw(). -func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: - var granularity_v2: String = ctx["granularity_v2"] - - _draw_skeleton_chords(rn, ctx) - - if AtlasWindowGeometryNature.confluences_visible_at_rung(granularity_v2): - for cf: Variant in rn.get("confluences", []): - if cf is Array and cf.size() >= 2: - var district: Vector2 = _district(float(cf[0]), float(cf[1]), ctx) - if _is_drawn_water(district, ctx): - continue - var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - var radius: float = _zs(AtlasWindowGeometryNature.RIVER_CONFLUENCE_RADIUS_REGION, ctx) - draw_circle(p, radius, COLOR_GEN_RIVER) - - if AtlasWindowGeometryNature.mouths_visible_at_rung(granularity_v2): - for m: Variant in rn.get("mouths", []): - if m is Array and m.size() >= 2: - var district: Vector2 = _district(float(m[0]), float(m[1]), ctx) - # T-1172 rule 3: mouths are SUPPRESSED (not snapped, not - # dimmed) when their cell reads as drawn water — a mouth is - # the worst-case disagreement by construction (the last LAND - # cell on the RAW coast; wherever the drawn coast is - # displaced inland, the mouth renders offshore). - if _is_drawn_water(district, ctx): - continue - var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - _draw_mouth(p, ctx) - - -## T-1170 Ruling 5a — the Region+ skeleton-chord draw: each river cell whose -## class is visible at this rung draws a STRAIGHT LINE SEGMENT to its -## `river_downstream` neighbor. `river_network.river_downstream` is a u8 PER -## RIVER CELL (index-aligned with river_cells, the SAME alignment convention -## river_class already uses) encoding a **D8 DIRECTION** (0-7, see -## AtlasWindowGeometryNature.D8_DIRECTION_DELTAS — NOT a river_cells index; -## the target cell's grid position is `c + delta`, decoded via -## AtlasWindowGeometryNature.d8_downstream_target()), with SENTINEL values -## `>= RIVER_DOWNSTREAM_SENTINEL_BASE` for MOUTH/EDGE_DRAIN/reserved-TERMINAL -## (Ruling 2c). Direction-index convention and sentinel values CONFIRMED -## against Dudley's A1 (server/src/atlas/drainage.rs:35-44, landed -## 0fea69feb; relayed by the coordinator, not guessed) — MOUTH=8, -## EDGE_DRAIN=9, TERMINAL=10 (reserved/unused), directions 0-7 = N/S/E/W/NE/ -## NW/SE/SW. Every place this convention is encoded is a SINGLE named -## constant group on AtlasWindowGeometryNature (D8_DIRECTION_DELTAS / -## RIVER_DOWNSTREAM_SENTINEL_BASE / RIVER_DOWNSTREAM_MOUTH / -## RIVER_DOWNSTREAM_EDGE_DRAIN / RIVER_DOWNSTREAM_TERMINAL) — see that file's -## own doc. -## -## Per Ruling 3b, these chords ARE the rung-truncated course at Region (no -## octave warp survives at Region spacing — the invented course degenerates -## exactly to this chord), NOT an approximation of it — one function (the -## server's course inventor, eventually), every rung, this is simply what it -## looks like with zero surviving octaves. -## -## Sentinel dispositions: MOUTH and EDGE_DRAIN both END the chain — no -## downstream segment is drawn for a sentinel-terminated cell (there is no -## real neighbor cell to connect to). EDGE_DRAIN gets NO mouth ring (Ruling -## 3f — pole-edge drains are grid artifacts, not river-meets-sea events; the -## existing mouths array/_draw_mouth() call in _draw_rivers() is already -## scoped to real MOUTH sentinels via rn["mouths"], server-side, per Ruling -## 3f's "extract_river_network stops classifying grid-edge exits into -## mouths" — this function draws NO ring at all, sentinel or otherwise, that -## is _draw_rivers()'s mouths-array job). -## -## `river_downstream` missing or shorter than `river_cells` (pre-T-1170 -## payload — Dudley's `#[serde(default)]` empty-Vec contract, the exact same -## graceful-decode shape river_class already established) means NO chord -## segment can be drawn for that index at all (there is no real downstream -## direction to connect to, unlike the class-fallback case where TRUNK is a -## safe visual default) — those cells draw NOTHING at Region until the field -## arrives, a graceful (not crashing) degradation, documented here rather -## than silently falling back to the old dot-scatter (which would require -## carrying that whole second code path forward past this ticket). The -## decoded target cell is ALSO not required to appear in `river_cells` itself -## (the chord draws to the raw grid position `c + delta`, not to a looked-up -## river-cell entry) — a downstream D8 pointer always names a real adjacent -## grid cell by construction, whether or not that cell independently made it -## into the (possibly rung/threshold-filtered) `river_cells` list. -## -## The actual chain-CONSTRUCTION (which segments exist at all, given the -## fixture and rung) is delegated to -## AtlasWindowGeometryNature.build_skeleton_chords() — a pure function with -## no draw calls, unit-tested directly in -## test_atlas_window_geometry_nature.gd (the sentinel/malformed/visibility -## cases). This function's own job is the remaining per-segment work that DOES -## need the overlay's own state: the water clip (_segment_touches_drawn_water(), -## needs the composite/tile data only the overlay holds) and the actual -## draw_line() call (needs a live render pass). -func _draw_skeleton_chords(rn: Dictionary, ctx: Dictionary) -> void: - var granularity_v2: String = ctx["granularity_v2"] - var river_cells: Array = rn.get("river_cells", []) - var river_class: Array = rn.get("river_class", []) - var river_downstream: Array = rn.get("river_downstream", []) - - var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( - river_cells, river_class, river_downstream, granularity_v2 - ) - for chord: Dictionary in chords: - var from_rc: Vector2 = chord["from"] - var to_rc: Vector2 = chord["to"] - var cls: int = chord["cls"] - - var from_district: Vector2 = _district(from_rc.x, from_rc.y, ctx) - var to_district: Vector2 = _district(to_rc.x, to_rc.y, ctx) - # T-1172 clip (Region-only, retained per Ruling 3g): a segment is - # clipped when EITHER endpoint OR its midpoint resolves to drawn - # water — see _segment_touches_drawn_water()'s own doc for why this - # three-point rule was chosen over an endpoints-only test. - if _segment_touches_drawn_water(from_district, to_district, ctx): - continue - var from_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - from_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - var to_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - to_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - var width: float = AtlasWindowGeometryNature.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2) - draw_line(from_p, to_p, COLOR_GEN_RIVER, _zs_stroke(width, ctx)) - - -## T-1172 clip rule for a CHORD SEGMENT (as opposed to a single point, which -## is what the pre-T-1170 dot-scatter clipped): tested at the segment's TWO -## ENDPOINTS AND its MIDPOINT, clipping the whole segment if ANY of those -## three samples resolves to drawn water. **Decision, documented per the -## ruling's ask ("pick the visually cleaner rule, document it, test it"):** -## endpoints-only was rejected because a chord that DIPS through a coastal -## composite cell without either endpoint landing in it (a river cell just -## inland connecting to a river cell just inland on the OTHER side of a -## narrow drawn-water inlet/bay) would draw a visible line segment crossing -## open water with neither end clipped — worse than the old dot-scatter's -## per-point clip, which never had this failure mode since a dot has no -## extent to cross anything. Midpoint-only was rejected symmetrically: a -## long chord whose midpoint happens to land on drawn land while both real -## endpoints sit in drawn water would draw an uncllipped segment starting and -## ending in the ocean. Three-point (both ends + midpoint) catches the -## common cases of both failure modes at negligible extra cost (one more -## _is_drawn_water() lookup per segment) without requiring a full -## segment-rasterization walk — chords at Region spacing (~76 km apart) are -## short enough relative to Region's own 204.8 km composite cell that a -## single midpoint sample is a reasonable proxy for "does this segment pass -## through this cell", matching the coarseness the Region rung already draws -## at everywhere else in this file (204.8 km cells, not sub-cell precision). -func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2, ctx: Dictionary) -> bool: - if _is_drawn_water(from_district, ctx): - return true - if _is_drawn_water(to_district, ctx): - return true - var mid_district: Vector2 = (from_district + to_district) * 0.5 - return _is_drawn_water(mid_district, ctx) - - -## Double-ring sea-terminus marker — verbatim geometry from the retired -## atlas_marker_overlay.gd _draw_gen_rivers() (:536-539). Mouths never fade -## (Araminta's ruling: "a mouth is always a landmark") — same styling at -## every rung it's visible at (Region, District; never Quarter). T-1170: -## also the marker for REAL course termini (Ruling 5b/3e) — one geometry -## function, both presentation surfaces. -func _draw_mouth(p: Vector2, ctx: Dictionary) -> void: - var ring_stroke: float = _zs_stroke(1.5, ctx) - draw_arc( - p, - _zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx), - 0.0, - TAU, - 18, - COLOR_GEN_MOUTH, - ring_stroke - ) - var halo := Color( - COLOR_GEN_MOUTH.r, - COLOR_GEN_MOUTH.g, - COLOR_GEN_MOUTH.b, - AtlasWindowGeometryNature.MOUTH_HALO_ALPHA - ) - var halo_stroke: float = _zs_stroke(1.0, ctx) - draw_arc( - p, - _zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx), - 0.0, - TAU, - 22, - halo, - halo_stroke - ) - - -## Basins — Region only, binary (no fade), per the ruling. Polygon fill + -## boundary polyline, verbatim geometry from the retired -## atlas_marker_overlay.gd _draw_gen_basins() (:542-557), coordinate mapping -## replaced with _pos() (this file's wrap-aware canvas-local mapping) in place -## of the retired _gen_pos() texture-fraction mapping. The FILL polygon's -## points are positions (never zoom-compensated — the fill must track the -## real district-space shape); only the boundary LINE's width is a -## screen-space marker size and goes through _zs(). -func _draw_basins(ctx: 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(_pos(float(pt[0]), float(pt[1]), ctx)) - 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, _zs_stroke(0.8, ctx), true) - - -## Attractors — Region only, wave 1 (per the ruling; District/Quarter never -## reach this function since _draw() gates the whole call on -## attractors_visible_at_rung()). Ported from the retired -## atlas_marker_overlay.gd _draw_gen_attractors()/_draw_attractor_shape() -## (:560-...) — the shape vocabulary (7 attractor-type glyphs) is Araminta's -## existing design, unchanged; only the coordinate mapping moves to _pos() -## and the size is zoom-compensated before reaching the shape drawer (that -## function stays a pure "draw this size at this position", zoom-agnostic). -func _draw_attractors(ctx: 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 < AtlasWindowGeometryNature.ATTRACTOR_MIN_STRENGTH: - continue - var pos_rc: Variant = a.get("position") - if not pos_rc is Array or pos_rc.size() < 2: - continue - var p: Vector2 = _pos(float(pos_rc[0]), float(pos_rc[1]), ctx) - var size: float = _zs(5.0 + strength * 4.0, ctx) - var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", ""))) - _draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs_stroke(1.0, ctx)) - - -## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd -## _draw_attractor_shape(). `size` AND `px_w` (the 1-screen-px stroke unit) -## both arrive ALREADY zoom-compensated from _draw_attractors() — this -## function stays a pure "draw at these literal dimensions" primitive with no -## ctx/zoom knowledge of its own. px_w exists because Godot multiplies stroke -## WIDTH args by the canvas scale exactly like radii (PR #195 review, Tyre -## I1: the retired code's raw 1.0/2.0 widths rasterized at ~0.01px at the -## Region orbital fit zoom — the same sub-pixel failure the dot/ring -## compensation fixed, missed on glyph outlines). -func _draw_attractor_shape( - atype: String, pos: Vector2, size: float, color: Color, px_w: float -) -> void: - match atype: - "RiverMouth": - draw_circle(pos, size, color) - "Confluence": - draw_circle(pos, size * 0.8, color) - draw_arc(pos, size * 1.3, 0.0, TAU, 12, color, px_w) - "Alpine", "PassEntrance": - var pts := PackedVector2Array( - [ - pos + Vector2(0, -size), - pos + Vector2(-size * 0.8, size * 0.6), - pos + Vector2(size * 0.8, size * 0.6), - ] - ) - draw_colored_polygon(pts, color) - "Coastal", "NaturalHarbor": - draw_arc(pos, size, PI * 0.15, PI * 0.85, 10, color, 2.0 * px_w) - "Oasis": - draw_circle(pos, size * 0.5, color) - for i in range(6): - var ang: float = TAU * float(i) / 6.0 - draw_line(pos, pos + Vector2(cos(ang), sin(ang)) * size, color, px_w) - _: - draw_circle(pos, size * 0.6, color) diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd deleted file mode 100644 index bf5dc2f9c..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ /dev/null @@ -1,600 +0,0 @@ -class_name AtlasWindowOverlay -extends Node2D - -## Draws the DistrictWindowLayer composite for AtlasWindowViewer (T-1138, -## D-226 T-1124 amendment §5). Child of AtlasWindowViewer._canvas so it -## inherits the pan transform (zoom is client-side texture zoom on the -## already-held composite, §5 — never a re-fetch). -## -## Draw order (bottom to top), matching the amendment's compositing model: -## 1. Base layer — morphology hue, lightness-modulated by elev_q. Always on, -## no toggle id (§5: "it IS this screen's terrain layer"). -## 2. Toggle overlays (mutually independent, at most one drawn per cell — -## each REPLACES the base read for that cell rather than blending, so -## switching between temp/moisture/veg never fights the base hue): -## gen_dw_temp / gen_dw_moisture / gen_dw_veg. -## 3. Glaciation ice-tint MODIFIER — composited over whichever layer is -## showing (base or a toggle), always-on, not a toggle id of its own. -## -## Reads window data via viewer.get_district_window() (a Dictionary or null) — this -## overlay draws nothing until the viewer has a window (border-fade during -## the wait is the VIEWER's job, drawn separately underneath this node, not -## here — this node is purely "draw the composite when there is one"). -## -## T-1145 item 3 (interim presentation, pending the T-1143 design pass): -## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one -## pixel per district, EXACT same per-cell color pipeline this file always -## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an -## ImageTexture and drawn scaled with texture-filtered sampling, instead of -## n*n flat draw_rect() calls. The crisp per-cell rect path SURVIVES behind -## the const (COMPOSITE_SMOOTH := false) so T-1143's design pass can compare -## both renderings directly — this is explicitly an INTERIM presentation, not -## the final answer on district-tier legibility (T-1143 owns that design). -## -## T-1161 (Araminta's per-rung filter ruling, PR #192 review follow-up): the -## smoothed path's PIPELINE (texture-vs-flat-rects) and its SAMPLING FILTER -## (how the GPU reads that texture) are now two INDEPENDENT axes, not one -## bundled choice: -## - Axis 1 — PIPELINE: COMPOSITE_SMOOTH (compile-time const, unchanged by -## this ticket). true = draw a texture; false = per-cell draw_rect(). The -## composite is a TEXTURE at every rung when COMPOSITE_SMOOTH is true — -## this axis does not vary per rung. -## - Axis 2 — FILTER: _filter_for_granularity_v2() (runtime, keyed on rung -## IDENTITY via granularity_v2, T-1161). Region (incl. the orbital tile -## mosaic) samples TEXTURE_FILTER_NEAREST — GPU bilinear stretch at -## 204.8 km/cell reads as a near-featureless soft gradient, technically -## honest LoD but visually indistinguishable from the coarse-composite -## smoothing-over-absence the mandate was written to kill (T-1161's own -## description). District and Quarter sample TEXTURE_FILTER_LINEAR — cell -## density there reads as texture, not smoothing-over-absence, so the -## bilinear blend is earned. No hysteresis, no px-per-cell threshold — -## the filter is a pure function of which rung's data is being drawn. -## The crisp draw_rect() path has no sampling-filter concept at all (no -## texture involved) — its comparison/debug role per the paragraph above is -## unaffected by this axis. -## -## The texture is REBUILT only when its inputs change (the window object -## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked -## by REFERENCE via is_same(), not a per-field deep compare — or the active -## toggle overlay id), not per frame/per redraw. Panning and zooming redraw -## this node constantly (every _apply_transform() call) but never touch -## window/overlay state, so the common case (panning within an already-held -## window) is zero rebuild cost — draw_texture_rect() on an already-built -## ImageTexture, same as any other texture draw. -## -## T-1152/T-1153: `n` (the response's echoed district extent) and the DERIVED -## cell-grid side length are now DIFFERENT quantities at every rung except -## District — cell_grid_side_for_window() computes the latter from `n` and -## the response's own `granularity_v2` echo (mirroring the server's -## `WindowGranularity::cell_grid_side` exactly), so a Region-rung response (a -## FAR SPARSER cell grid than its district extent — see that Rust doc's -## "inversion" note) renders through the exact same colorizer pipeline as -## District/Quarter, satisfying the design doc §6 "one colorizer family, no -## per-rung palettes" encoding-continuity requirement — no branch in -## _cell_color()/_temp_cell_color()/etc. below needed any change at all. - -const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") -const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path. -const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") -# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix. -const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") - -## T-1145 item 3: interim presentation toggle — true renders the smoothed -## Image/ImageTexture composite; false keeps the original crisp per-cell -## draw_rect() path (both call the SAME _cell_color()/_apply_glaciation() -## pipeline, so switching this never changes WHAT color a cell reads, only -## HOW it's rendered). Left as a compile-time const, not a runtime toggle — -## T-1143's design pass is expected to pick a winner, not ship a player- -## facing switch between them. -const COMPOSITE_SMOOTH: bool = true - -## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) — -## not the categorical lookup itself (that's keyed by sub-biome NAME, not a -## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that -## table: Desert (arid/dry) and TropicalWet (wet/coastal). -const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0) # sand — matches SUB_BIOME_COLORS.Desert -const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — matches SUB_BIOME_COLORS.TropicalWet - -var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) - -## T-1145 item 3: texture rebuild cache — see the class doc's "REBUILT only -## when its inputs change" paragraph. _cache_window_ref is compared by -## REFERENCE (is_same()), not value — a fresh DistrictWindowLayer response is -## always a NEW Dictionary object (built by atlas_map_protocol.gd's decode), -## so reference identity is both correct AND far cheaper than a deep compare -## of a potentially-4096-cell dictionary on every _draw() call. -var _cached_texture: ImageTexture = null -var _cache_window_ref: Variant = null -var _cache_active_toggle: String = "" - -## Live round 4 fix: per-TILE texture cache, keyed by tile index — mirrors -## the single-window cache above, but one slot per mosaic tile (a Dictionary -## of `{window_ref, active_toggle, texture}`, since the mosaic doesn't have a -## single fixed set of tiles the way the single-window path has a single -## fixed field). Building a brand-new, UNSTORED `ImageTexture` every -## `_draw()` call (the round-3 version) left it referenced only by a local -## variable — nothing keeps the RID alive past the function returning, which -## raced against the RenderingServer's deferred draw-command flush and -## rendered as a blank/white tile (the round-4 "pitch black"/white-mosaic -## repro's second half, beyond the coordinate fix above): the CPU-side pixel -## data was provably correct (sampled directly), but the GPU-side texture -## backing it could be gone by composite time. Caching each tile's texture -## as a class-owned Dictionary entry (same reference-identity rebuild-only- -## on-change discipline as `_cached_texture`) keeps it alive exactly as long -## as the single-window composite's own texture already is. -var _tile_texture_cache: Dictionary = {} - - -func _draw() -> void: - if viewer == null: - return - if viewer.is_tile_mode(): - _draw_tile_mosaic() - return - var window: Variant = viewer.get_district_window() - if not window is Dictionary: - return - var w: Dictionary = window - var n: int = int(w.get("n", 0)) - if n <= 0: - return - - var morphology: Variant = w.get("morphology") - if not (morphology is PackedByteArray or morphology is Array): - return - - # T-1152/T-1153: `n` (the response's echoed field) is ALWAYS window extent - # in DISTRICTS at every rung (Dudley's wire contract, DistrictWindowLayer.n's - # own doc) — the per-cell arrays (morphology/elev_q/etc.) are sized by the - # DERIVED cell-grid side, `cell_grid_side_for_window()` below, which equals - # `n` only at District granularity. Quarter packs MORE cells into the same - # n-district extent (`n*4`); Region packs FEWER, since one region cell - # spans 100 districts (`round(n/100).max(1)`). The on-screen EXTENT stays - # `n * cell_px` regardless of rung (CELL_PIXEL_SIZE is defined as "one - # DISTRICT at zoom=1.0" — see the viewer's own doc on that constant) so a - # rung swap at a fixed pan/zoom never jumps the composite's screen footprint - # (§6 "no layout jump") — only the TEXTURE RESOLUTION packed into that - # footprint changes, exactly the "same colorizer family, different LoD" - # picture the design doc describes. - var grid_side: int = cell_grid_side_for_window(w) - if grid_side <= 0: - return - - var cell_px: float = viewer.get_cell_pixel_size() - var active_toggle: String = _active_toggle_overlay() - - if COMPOSITE_SMOOTH: - _draw_smoothed_composite(w, n, grid_side, cell_px, active_toggle) - else: - _draw_crisp_composite(w, grid_side, n, cell_px, active_toggle) - - -## T-1153, live round 3/4 (Jeroen's ruling, design doc §4): the orbital -## rest-state MOSAIC draw path — one call to the EXISTING single-tile -## composite-building logic (`_rebuild_texture_if_needed()`/ -## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE, -## each positioned at its own LOCAL offset in the SAME canvas-local -## coordinate frame the single-window path (and fit_window_view()/ -## screen_center_to_district()) already use. -## -## **Live round 4 fix:** the round-3 version placed tiles relative to -## absolute district (0,0) directly (`(tile.center - TILE_N/2) * cell_px`), -## which does NOT match `_fit_and_center()`'s own convention — canvas-local -## (0,0) is `held_center - held_n/2` (AtlasWindowGeometry. -## district_to_canvas_local()'s own doc), and in tile mode `held_n` is the -## WHOLE BODY's extent, not TILE_N. That mismatch pushed the entire mosaic -## off-canvas (round 4's "pitch black" repro) — silent, since nothing -## errors, it just draws somewhere the viewport never shows. Fixed by -## routing every tile's placement through `district_to_canvas_local()` with -## the VIEWER's own `held_center`/`held_n`, the exact reference frame every -## other canvas-local consumer (fit/pan/reselect) already agrees on. Tiles -## that haven't arrived yet (`tile["window"] == null`) are simply SKIPPED — -## no per-tile placeholder draw, letting COLOR_BG show through as the honest -## "nothing here yet" read (the viewer's own `_draw()` already documents why -## no separate whole-viewport fade is needed on top of this). -## -## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into -## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys, -## since longitude is periodic and a canonical column is the single-valued -## key both sides of the wire agree on). But `district_to_canvas_local()` -## is a pure LINEAR function with no wrap concept — handed a canonical -## column directly, it places the tile at exactly ONE of its infinitely -## many equivalent on-screen positions (`col + k*cols`), which is only the -## visually-correct one by coincidence. Lendel's own repro: the tile whose -## true position is immediately WEST of the canonical origin canonicalizes -## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands -## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by -## re-expressing each tile's column via `nearest_wrap_image()` — whichever -## wrap-image is closest to `held_center`, i.e. the one actually near the -## current view — BEFORE handing it to `district_to_canvas_local()`. -func _draw_tile_mosaic() -> void: - var tile_set = viewer.get_tile_set() - if tile_set == null: - return - var cell_px: float = viewer.get_cell_pixel_size() - var active_toggle: String = _active_toggle_overlay() - var held_center: Vector2i = viewer.get_held_center() - var held_n: int = viewer.get_held_n() - var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5 - var tiles: Array = tile_set.get_tiles() - var cols: int = int( - AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0) - ) - - for i in range(tiles.size()): - var tile: Dictionary = tiles[i] - var window: Variant = tile["window"] - - var center: Vector2i = tile["center"] - var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols) - var tile_top_left: Vector2 = Vector2( - float(draw_col) - half_tile, float(center.y) - half_tile - ) - var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local( - tile_top_left, held_center, held_n, cell_px - ) - var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px - - # Coordinator ask (cold-start dossier): a bare COLOR_BG gap for an - # unarrived tile reads as broken, not "still working" — a cold - # server's first AnalyzeBody can take seconds, during which every - # tile in the mosaic is exactly this state at once. Same treatment - # the single-window path already gives its OWN no-composite-yet wait - # (AtlasWindowViewer._draw_border_fade()) — read off the live - # `viewer` instance rather than a preload of its script (that field - # is deliberately untyped to avoid a cyclic ref, see its own doc; a - # const is reachable through an instance either way). - if not window is Dictionary: - draw_rect(Rect2(local_origin, Vector2(extent, extent)), viewer.COLOR_BORDER_FADE) - continue - var w: Dictionary = window - var morphology: Variant = w.get("morphology") - if not (morphology is PackedByteArray or morphology is Array): - continue - var grid_side: int = cell_grid_side_for_window(w) - if grid_side <= 0: - continue - - _draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle) - - -## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the -## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED), -## just drawn at `local_origin` instead of always at (0,0). Each tile gets -## its OWN texture-rebuild cache slot in `_tile_texture_cache`, keyed by -## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the -## single-window field) would thrash on every draw call as different tiles' -## windows compete for it. -## -## T-1161: every mosaic tile is a Region-rung request (atlas_window_tile_set.gd -## requests tiles at AtlasWindowRequest.GRANULARITY_V2_REGION), so the mosaic -## as a whole is in scope for the Region -> NEAREST ruling. As with the -## single-window path, the filter is read from THIS tile's own echoed `w` -## rather than assumed, via the shared `_filter_for_granularity_v2()` helper -## — one policy, two call sites, no duplicated match statement. -func _draw_one_tile( - tile_index: int, - w: Dictionary, - grid_side: int, - local_origin: Vector2, - extent: float, - active_toggle: String -) -> void: - if not COMPOSITE_SMOOTH: - _draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle) - return - var tile_texture: ImageTexture = _rebuild_tile_texture_if_needed( - tile_index, w, grid_side, active_toggle - ) - if tile_texture == null: - return - var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) - texture_filter = _filter_for_granularity_v2(granularity_v2) - draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false) - - -## Live round 4 fix: rebuilds (and, critically, KEEPS — see -## `_tile_texture_cache`'s own doc for why an unstored local `ImageTexture` -## silently rendered blank/white) `_tile_texture_cache[tile_index]`'s texture -## ONLY when that tile's window object or the active toggle overlay has -## changed since the last build — the SAME reference-identity discipline -## `_rebuild_texture_if_needed()` uses for the single-window composite, one -## cache entry per tile index instead of one shared field. -func _rebuild_tile_texture_if_needed( - tile_index: int, w: Dictionary, grid_side: int, active_toggle: String -) -> ImageTexture: - var entry: Dictionary = _tile_texture_cache.get(tile_index, {}) - if ( - is_same(entry.get("window_ref"), w) - and entry.get("active_toggle") == active_toggle - and entry.get("texture") != null - ): - return entry["texture"] - var texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle) - _tile_texture_cache[tile_index] = { - "window_ref": w, "active_toggle": active_toggle, "texture": texture - } - return texture - - -## Builds a tile's own Image/ImageTexture from its per-cell colors — -## identical pipeline to `_rebuild_texture_if_needed()`, just returning the -## texture directly instead of writing to the single-window cache fields -## (the CALLER, `_rebuild_tile_texture_if_needed()`, owns persisting it). -func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture: - var elev_q: Variant = w.get("elev_q") - var glaciation: Variant = w.get("glaciation") - var morphology: Variant = w.get("morphology") - var n_cells: int = morphology.size() - - var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8) - for row in range(grid_side): - for col in range(grid_side): - var i: int = row * grid_side + col - if i >= n_cells: - img.set_pixel(col, row, Color.TRANSPARENT) - continue - var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) - cell_color = _apply_glaciation(cell_color, glaciation, i) - img.set_pixel(col, row, cell_color) - - return ImageTexture.create_from_image(img) - - -## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()` -## exactly, just positioned at `local_origin` instead of always at (0,0). -func _draw_crisp_tile( - w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String -) -> void: - var elev_q: Variant = w.get("elev_q") - var glaciation: Variant = w.get("glaciation") - var morphology: Variant = w.get("morphology") - var n_cells: int = morphology.size() - var screen_cell_px: float = extent / float(grid_side) - - for row in range(grid_side): - for col in range(grid_side): - var i: int = row * grid_side + col - if i >= n_cells: - continue - var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) - if cell_color.a <= 0.0: - continue - cell_color = _apply_glaciation(cell_color, glaciation, i) - var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px) - draw_rect( - Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color - ) - - -## The derived cell-grid side length (in CELLS) for a window dict `w` — -## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side` -## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than -## trusting a caller's separately-tracked rung (the response is the source of -## truth for what it actually contains). Falls back to `n` unchanged -## (District's own identity mapping) for an old-shape response with no -## `granularity_v2` key — matches the server's own "unknown -> District" -## posture and AtlasWindowRequest.on_response()'s own default-to-District -## disposition for the same field. -static func cell_grid_side_for_window(w: Dictionary) -> int: - var n: int = int(w.get("n", 0)) - var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) - match granularity_v2: - AtlasWindowRequest.GRANULARITY_V2_QUARTER: - return n * 4 # WINDOW_GRANULARITY_QUARTER multiplier — D-243 QUARTER_M - AtlasWindowRequest.GRANULARITY_V2_REGION: - return maxi(roundi(float(n) / 100.0), 1) # D-243 DISTRICTS_PER_REGION - _: - return n # District — 1:1 - - -## T-1161 (Araminta's per-rung filter ruling): the sampling filter to use for -## the smoothed composite's texture, keyed on RUNG IDENTITY alone via -## `granularity_v2` — no hysteresis, no px-per-cell/zoom threshold. Region -## (204.8 km/cell — the same rung the orbital tile mosaic draws at, since -## every mosaic tile is itself a Region-rung window per `_draw_tile_mosaic()`) -## reads NEAREST: at that density, GPU bilinear blending between real derived -## samples is technically honest LoD but visually indistinguishable from the -## coarse-composite-stretched smoothing-over-absence the mandate was written -## to kill — the spirit is violated even though the letter ("never magnified -## interpolation") is not. District and Quarter read LINEAR: cell density at -## those rungs is high enough that the blend reads as texture, not as papering -## over sparse data. Mirrors `cell_grid_side_for_window()`'s own posture on an -## unknown/missing tag — an unrecognized wire value must never be trusted into -## the crisp NEAREST treatment, so it falls back to District's LINEAR instead -## of Region's NEAREST (fail toward the safer/already-shipped look). -static func _filter_for_granularity_v2(granularity_v2: String) -> CanvasItem.TextureFilter: - match granularity_v2: - AtlasWindowRequest.GRANULARITY_V2_REGION: - return CanvasItem.TEXTURE_FILTER_NEAREST - _: - return CanvasItem.TEXTURE_FILTER_LINEAR # District, Quarter, and unknown/missing fallback - - -## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side` -## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see -## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n -## being the window's DISTRICT extent. texture_filter is set on `self` (a -## CanvasItem property) once per draw — cheap (a property write, not a -## texture rebuild). T-1161: the filter itself is now PER-RUNG, read from -## `w`'s own echoed `granularity_v2` (the same "response is the source of -## truth" posture cell_grid_side_for_window() already uses) via -## `_filter_for_granularity_v2()`, rather than an unconditional LINEAR. -func _draw_smoothed_composite( - w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String -) -> void: - var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)) - texture_filter = _filter_for_granularity_v2(granularity_v2) - _rebuild_texture_if_needed(w, grid_side, active_toggle) - if _cached_texture == null: - return - var extent: float = float(n) * cell_px - draw_texture_rect(_cached_texture, Rect2(0.0, 0.0, extent, extent), false) - - -## Rebuilds _cached_texture from `w`'s per-cell colors ONLY when the window -## object or the active toggle overlay has changed since the last build — -## see the class doc's rebuild-cost paragraph. `elev_q` and `glaciation` are -## read directly from `w` here (rather than threaded through as params, the -## way the crisp path's _cell_color()/_apply_glaciation() calls already -## receive them) since this function owns the whole per-cell loop, not just -## one cell. `grid_side` (T-1152) is the DERIVED cell-grid side (see -## cell_grid_side_for_window()), not the window's district extent `n`. -func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: String) -> void: - if ( - is_same(_cache_window_ref, w) - and _cache_active_toggle == active_toggle - and _cached_texture != null - ): - return # inputs unchanged since the last build — reuse the existing texture - - var elev_q: Variant = w.get("elev_q") - var glaciation: Variant = w.get("glaciation") - var morphology: Variant = w.get("morphology") - var n_cells: int = morphology.size() - - var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8) - for row in range(grid_side): - for col in range(grid_side): - var i: int = row * grid_side + col - if i >= n_cells: - img.set_pixel(col, row, Color.TRANSPARENT) - continue - var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) - cell_color = _apply_glaciation(cell_color, glaciation, i) - img.set_pixel(col, row, cell_color) - - _cached_texture = ImageTexture.create_from_image(img) - _cache_window_ref = w - _cache_active_toggle = active_toggle - - -## The ORIGINAL crisp per-cell path — kept byte-for-byte behind -## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both -## renderings directly (see the class doc). `grid_side` (T-1152) is the -## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the -## window's district extent) sizes the on-screen cell pitch so the total -## drawn footprint stays `n * cell_px` regardless of rung. -## -## Note (T-1161): this path never touches the node-level `texture_filter` -## property — draw_rect() has no texture/sampling-filter concept, so there is -## nothing to set. That is inert today only because nothing else reads -## `texture_filter` while this path is active; it is not a bug to fix here, -## just worth stating since the smoothed path now sets that property -## per-rung and a reader might otherwise wonder why this path doesn't. -func _draw_crisp_composite( - w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String -) -> void: - var elev_q: Variant = w.get("elev_q") - var glaciation: Variant = w.get("glaciation") - var morphology: Variant = w.get("morphology") - var n_cells: int = morphology.size() - var screen_cell_px: float = float(n) * cell_px / float(grid_side) - - for row in range(grid_side): - for col in range(grid_side): - var i: int = row * grid_side + col - if i >= n_cells: - continue - var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) - if cell_color.a <= 0.0: - continue # Marine-transparent or otherwise "don't draw" (cheaper than a 0-alpha rect) - cell_color = _apply_glaciation(cell_color, glaciation, i) - # +0.5 overdraw avoids hairline seams between adjacent cells — - # same idiom as _draw_gen_district/_draw_gen_region_grid. - draw_rect( - Rect2(col * screen_cell_px, row * screen_cell_px, screen_cell_px + 0.5, screen_cell_px + 0.5), - cell_color - ) - - -## Which of the three mutually-exclusive toggle overlays (if any) is active. -## At most one draws — §5 does not describe blending two toggles together, -## and doing so would fight the "one colorizer, one read" legibility goal the -## whole layer design optimizes for. First-match-wins on ties (should never -## happen — the overlay bar toggles independently, but this keeps the draw -## deterministic instead of implicitly depending on dictionary iteration -## order if more than one somehow ends up true). -func _active_toggle_overlay() -> String: - if viewer.is_overlay_visible("gen_dw_temp"): - return "gen_dw_temp" - if viewer.is_overlay_visible("gen_dw_moisture"): - return "gen_dw_moisture" - if viewer.is_overlay_visible("gen_dw_veg"): - return "gen_dw_veg" - return "" - - -func _cell_color( - w: Dictionary, i: int, morphology_zone: int, elev_q: Variant, active_toggle: String -) -> Color: - match active_toggle: - "gen_dw_temp": - return _temp_cell_color(w.get("temp_dc"), i) - "gen_dw_moisture": - return _moisture_cell_color(w.get("moisture_q"), i) - "gen_dw_veg": - return _veg_cell_color(w.get("vegetation"), i) - _: - return _base_cell_color(morphology_zone, elev_q, i) - - -## Base layer: morphology hue, lightness-modulated by elev_q (§5's one -## `0.7 + 0.3*(elev_q/100)` multiply per cell). -func _base_cell_color(morphology_zone: int, elev_q: Variant, i: int) -> Color: - var base: Color = AtlasOverlayColors.district_window_morphology_color(morphology_zone) - var eq: int = _dense_int(elev_q, i, 50) - return AtlasOverlayColors.district_window_elevation_lightness(base, eq) - - -## gen_dw_temp: reuses T-1118's region_temp_color() EXACTLY — same i16 -## deci-°C domain, same REGION_TEMP_NONE_DC sentinel disposition (skip the -## cell entirely, matching _draw_gen_region_grid's airless treatment) — one -## colorizer across both zoom levels, per the amendment's consistency ruling. -func _temp_cell_color(temp_dc: Variant, i: int) -> Color: - if temp_dc == null: - return Color.TRANSPARENT - var t: int = _dense_int(temp_dc, i, REGION_TEMP_NONE_DC) - if t == REGION_TEMP_NONE_DC: - return Color.TRANSPARENT # airless — no reading, skip the cell (matches region-grid precedent) - return AtlasOverlayColors.region_temp_color(t) - - -## gen_dw_moisture: dry-sand -> wet-teal ramp over the existing SUB_BIOME_COLORS -## endpoints (§5). -func _moisture_cell_color(moisture_q: Variant, i: int) -> Color: - if moisture_q == null: - return Color.TRANSPARENT - var m: int = clampi(_dense_int(moisture_q, i, 50), 0, 100) - return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0) - - -## gen_dw_veg: green-family ramp, Marine transparent (§3/§5 — non-negotiable -## per the amendment; see atlas_overlay_colors.gd's vegetation_color() doc). -func _veg_cell_color(vegetation: Variant, i: int) -> Color: - if vegetation == null: - return Color.TRANSPARENT - return AtlasOverlayColors.vegetation_color(_dense_int(vegetation, i, 0)) - - -## Glaciation: an always-on MODIFIER (never a toggle id), composited over -## whichever layer is currently showing — the base or one of the three -## toggles (§5). -func _apply_glaciation(cell_color: Color, glaciation: Variant, i: int) -> Color: - if glaciation == null: - return cell_color - var grade: int = _dense_int(glaciation, i, 0) - return AtlasOverlayColors.glaciation_tint(cell_color, grade) - - -## Reads element `i` from a dense numeric array field regardless of whether -## the messagepack decode produced a PackedByteArray (u8 fields) or a plain -## Array (i16 temp_dc — rmp_serde without serde_bytes, matching the existing -## region_grid _dense_int precedent in test_atlas_overlays.gd, now needed at -## RUNTIME here too, not just in a test helper). -static func _dense_int(arr: Variant, i: int, fallback: int) -> int: - if (arr is Array or arr is PackedByteArray) and i < arr.size(): - return int(arr[i]) - return fallback diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd deleted file mode 100644 index 8b53b8243..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ /dev/null @@ -1,464 +0,0 @@ -extends Node - -## District-window request orchestration for AtlasWindowViewer (T-1138, D-226 -## T-1124 amendment §1/§4). Owns the cache, the pan-triggered re-request -## policy, the post-drag-release debounce, and the retry loop for the -## queue-based background derive (PR #185 finding: a window response arrives -## on a LATER TICK, not synchronously — the exact same "None until derived, -## re-poll" contract atlas_generation_proxy.gd's Layer1 path already handles, -## reused here rather than re-invented). -## -## This script has no `class_name` on purpose, matching every other -## viewer-owned helper in this cluster (atlas_overlay_bar.gd/ -## atlas_legend_panel.gd/atlas_generation_proxy.gd, review #8 precedent): the -## owner (AtlasWindowViewer) passes itself to _init(), and a `class_name` + -## required-arg _init() combo is a Godot editor footgun. `extends Node` (not -## RefCounted) because it needs get_tree() for the debounce/retry timers — -## added as a child via -## load("res://ui/implant/apps/atlas/atlas_window_request.gd").new(self). -## -## §4 policy fixed here (the constants + the debounce, NOT the pan-edge -## detection — that's the viewer's job, since it owns the screen-to-district -## geometry): -## - DISTRICT_WINDOW_DEFAULT_N = 32 (client's interactive default, half the -## server's DISTRICT_WINDOW_MAX_N = 64 hard cap — §4 pins both numbers; -## the cap itself is a server-side clamp this client never needs to -## duplicate, only stay under so a request is never silently clamped in -## a way the client didn't expect). -## - 150ms post-drag-release debounce — long enough to collapse a -## flick-and-resettle into one request, short enough that a deliberate -## single pan-and-stop never feels delayed (§4/§5 wording, identical). -## - Cache-hit is instant (no request at all) — §4's "D-227 makes exact- -## repeat the common case for Esc-then-re-enter and pan-back" is what -## makes this the common path, not the minority one. - -signal window_ready(window: Dictionary) # emitted on a cache hit OR a fresh Ready response - -const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd") - -const DISTRICT_WINDOW_DEFAULT_N: int = 32 -const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5 - -## Cold-start dossier (PR #192 round 3): the retry-on-PENDING loop used to be -## a flat RETRY_DELAY=0.5s / MAX_RETRIES=20 (~10s ceiling), copied verbatim -## from atlas_generation_proxy.gd's Layer1 poll — a DIFFERENT, typically -## faster derive. The real starvation bug turned out to be the status-gate -## fix in on_response() (see that function's own doc) — this backoff/stagger -## work is HARDENING landed alongside it, not the fix itself: once the -## status-gate fix makes 6 independent tiles all correctly retry on a -## whole-response Pending, they do so in perfect lockstep (all six went -## pending at entry within the same frame, so all six retry timers fire -## within the same frame too) — six re-requests every RETRY_DELAY, in sync, -## is exactly the "storm" shape worth damping even though it isn't what -## caused the starvation. Exponential backoff (INITIAL_RETRY_DELAY doubling -## to MAX_RETRY_DELAY) plus a DETERMINISTIC per-tile stagger -## (STAGGER_STEP * stagger_index, set once by the owning AtlasWindowTileSet -## at construction — see `_stagger_index`) spread that pulse into a trickle: -## tile 0 retries at 0.5s, tile 1 at 0.6s, tile 2 at 0.7s, etc. — deterministic -## and directly assertable in a test, not a randomized jitter a test would -## have to tolerance-check. Backoff ALSO buys a much longer wall-clock window -## from a modest MAX_RETRIES increase (~110s at 30 retries, see -## _retry_delay_for()'s own doc) without ever polling aggressively for that -## whole span. A fast (already-warm) response still resolves on retry #1, -## unaffected — backoff/stagger only matter once a request is genuinely -## still pending past the first cycle. -const INITIAL_RETRY_DELAY: float = 0.5 # first retry, matches the old flat RETRY_DELAY -const MAX_RETRY_DELAY: float = 4.0 # backoff ceiling — never polls slower than this -const STAGGER_STEP: float = 0.1 # per-tile-index offset — tile i retries STAGGER_STEP*i later -const MAX_RETRIES: int = 30 # ~110s wall-clock at the backoff schedule above - -## T-1150 struct/key plumbing: legacy int granularity — district is the -## default for every caller that doesn't request quarter/Region explicitly. -const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY -const DEFAULT_MIN_WL_M: int = 0 - -## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" | -## "District" | "Region". This is the axis request_now()/request_debounced()'s -## `granularity_v2` parameter actually varies; the legacy int -## (DEFAULT_GRANULARITY) stays pinned at district for every call this object -## makes, since v2 always wins server-side once present -## (resolve_window_granularity_v2()'s documented precedence) and the legacy -## int cannot express Region at all. -const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER -const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT -const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION -const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2 - -## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N / -## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()` -## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror -## pattern `AtlasDescendGeometry.canonicalize_district_center()` already uses -## for the server's `normalize_window_center()`. Keep both numbers in sync -## with the server constants of the same name if either ever changes. -const SERVER_DISTRICT_WINDOW_MAX_N: int = 64 -const SERVER_WIRE_CAP_CELLS: int = 4_096 - -## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's -## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n` -## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's -## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in -## sync with the server constant of the same name. -const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400 -## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region -## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs -## to reproduce `WindowGranularity::cell_grid_side`'s Region branch. -const SERVER_DISTRICTS_PER_REGION: int = 100 - -var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) -var _cache = null # AtlasWindowCache -var _body_id: String = "" -var _center: Vector2i = Vector2i.ZERO -var _n: int = DISTRICT_WINDOW_DEFAULT_N -var _granularity: int = DEFAULT_GRANULARITY -var _granularity_v2: String = DEFAULT_GRANULARITY_V2 -var _min_wl_m: int = DEFAULT_MIN_WL_M -var _pending: bool = false -var _retries: int = 0 -var _debounce_timer: Timer = null -## Cold-start dossier round 3: deterministic per-request stagger index for -## the retry backoff (see STAGGER_STEP's own doc) — 0 for the single-window -## viewer's own request (no fan-out, nothing to desync from), the tile's own -## index (0..5) for a tile-set-owned request (AtlasWindowTileSet.enter() -## sets this once at construction, right after AtlasWindowRequest.new()). -var _stagger_index: int = 0 - - -func _init(owner_ref = null) -> void: - _owner = owner_ref - _cache = AtlasWindowCache.new() - - -func _ready() -> void: - _debounce_timer = Timer.new() - _debounce_timer.name = "DebounceTimer" - _debounce_timer.one_shot = true - _debounce_timer.wait_time = DEBOUNCE_DELAY - _debounce_timer.timeout.connect(_on_debounce_timeout) - add_child(_debounce_timer) - - -## Reset for a fresh entry into the regional window mode (new body/center) — -## clears in-flight retry bookkeeping but NOT the cache (D-227: a cached -## window is valid forever regardless of which body/center the viewer is -## currently showing; clearing on every entry would throw away exactly the -## Esc-then-re-enter hit §4 promises). -func reset() -> void: - _pending = false - _retries = 0 - if _debounce_timer: - _debounce_timer.stop() - - -## Mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n(raw_n, -## granularity)` EXACTLY (PR #191 review, Tyre C1 — "the sharpest" finding): -## `serve_district_window` echoes the CLAMPED `n` back in -## `DistrictWindowLayer.n`, but `on_response()`'s staleness guard compares the -## echo against `_n`. Without this mirror, `_n` would hold the RAW requested -## value while the server echoes the CLAMPED one — the moment a caller -## requests quarter (granularity=4) at n=32, the server clamps to n=16 and -## echoes THAT, `on_response()` sees `echoed_n=16 != _n=32`, decides the -## response is stale, and the window silently never loads (no error, no log -## on this side — just an eternally-pending request). -## -## Clamping HERE, before `_n` is ever stored or sent, means `_n` already -## equals what the server will echo — no drift between the two sides, the -## SAME load-bearing-mirror pattern `AtlasDescendGeometry. -## canonicalize_district_center()` uses for the server's -## `normalize_window_center()` (see that function's docstring for the general -## rationale: canonicalizing before the request is sent means the client's -## held state already equals what the server will echo back). -## -## Formula, bit-for-bit: `n = raw_n.clamp(1, SERVER_DISTRICT_WINDOW_MAX_N)`, -## then `n = min(n, floor(sqrt(SERVER_WIRE_CAP_CELLS) / max(granularity, 1)))` -## — applied in that order (per-axis cap first, then the granularity-aware -## wire-size ceiling), matching `clamp_window_n`'s own comment ("Applied AFTER -## the per-axis clamp so a request that already satisfies -## DISTRICT_WINDOW_MAX_N still shrinks further at granularity 4"). -static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int: - var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N) - var g: int = maxi(granularity, 1) - var cap_n: int = int(floor(sqrt(float(SERVER_WIRE_CAP_CELLS)) / float(g))) - return mini(n, maxi(cap_n, 1)) - - -## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/ -## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2` -## EXACTLY, including its Region branch, per the ticket's explicit -## instruction ("replicate the loop exactly, there is NO closed form"). For -## District/Quarter this delegates straight to `_clamp_window_n_mirror()` -## (byte-identical clamped `n`, matching the server's own -## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs` -## guarantee). For Region: per-axis clamp to -## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded -## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and -## `n > 1` — there is no closed-form inverse of the rounding division -## `cell_grid_side` uses at Region granularity, so this loop is the correct -## (and only) mirror, not an approximation of one. -static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int: - if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION: - var legacy_granularity: int = ( - DEFAULT_GRANULARITY - if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT - else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER - ) - return _clamp_window_n_mirror(raw_n, legacy_granularity) - - var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION) - while ( - _cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS - and n > 1 - ): - n = int(n / 2.0) - return maxi(n, 1) - - -## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY: -## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid -## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's -## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches -## that for non-negative inputs (the only domain `n` — always >= 1 here — -## can produce), so this is a faithful mirror, not an approximation. -static func _cell_grid_side_region_mirror(n: int) -> int: - var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION)) - return maxi(side, 1) - - -## Entry point + pan re-request: request the window centered on `center` -## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2` -## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the -## default for every caller that doesn't ask for a different rung explicitly, -## matching the legacy behavior byte-for-byte when omitted). Cache hit -> -## immediate synchronous window_ready emit, no network traffic at all. Cache -## miss -> fire the request now (the caller — either the initial entry or a -## debounce-fired pan/zoom — has already decided this call SHOULD fire; the -## 150ms debounce itself lives in request_debounced() below, not here, so -## this function is also the one entry-mechanic click-through uses directly -## with no debounce at all, matching §5's "first window" contract). -func request_now( - body_id: String, - center: Vector2i, - n: int = DISTRICT_WINDOW_DEFAULT_N, - granularity_v2: String = DEFAULT_GRANULARITY_V2 -) -> void: - _body_id = body_id - _center = center - _granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side - _granularity_v2 = granularity_v2 - _min_wl_m = DEFAULT_MIN_WL_M - _n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing - _debounce_timer.stop() # a direct request supersedes any pending debounced one - - var cached: Variant = _cache.get_window( - body_id, center, _n, _granularity, _min_wl_m, _granularity_v2 - ) - if cached != null: - _pending = false - _retries = 0 - window_ready.emit(cached) - return - - _pending = true - _retries = 0 - SimBridge.request_atlas_layers( - body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2 - ) - - -## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not -## per-drag-frame"). The viewer calls this on every pan-edge-crossing -## candidate; only the LAST call within the debounce window actually fires -## (Timer.start() on an already-running one-shot timer restarts it — Godot's -## documented behavior — so a flick-and-resettle collapses to one request). -func request_debounced( - body_id: String, - center: Vector2i, - n: int = DISTRICT_WINDOW_DEFAULT_N, - granularity_v2: String = DEFAULT_GRANULARITY_V2 -) -> void: - _body_id = body_id - _center = center - _granularity = DEFAULT_GRANULARITY - _granularity_v2 = granularity_v2 - _min_wl_m = DEFAULT_MIN_WL_M - _n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing - _debounce_timer.start() - - -func _on_debounce_timeout() -> void: - request_now(_body_id, _center, _n, _granularity_v2) - - -## Handle an AtlasLayerResponse (routed by the owning viewer from its own -## SimBridge.atlas_layers_received subscription — this object has no signal -## connection of its own, matching atlas_generation_proxy.gd's on_response() -## shape). Ignores responses for a stale body/center/n/min_wl_m/granularity -## (legacy OR v2, see below) — the player panned, zoomed across a rung -## boundary, or navigated away while a request was in flight, or a different -## rung's derive answers a request for a different rung, T-1150/T-1152 — the -## echoed fields ARE the staleness guard (§2, extended T-1150/T-1152), -## compared here against what THIS object most recently asked for. -## -## **Live-round finding (the second C1-shaped bug): v2 is AUTHORITATIVE over -## the legacy field whenever v2 is present — the legacy comparison is -## SKIPPED entirely, not run alongside it.** A T-1152-aware server (this -## codebase's) ALWAYS populates `granularity_v2` on the wire (Dudley's -## contract, `DistrictWindowLayer.granularity_v2`'s own doc: "Always -## populated (never `None`)"), and for `Region` responses specifically the -## LEGACY `granularity` slot carries `WINDOW_GRANULARITY_REGION_KEY` -## (`u32::MAX` = 4294967295) — a reserved KEY-SPACE TAG, not a real -## multiplier, that can never equal this object's own stored `_granularity` -## (which stays pinned at `DEFAULT_GRANULARITY`=1 for every rung this object -## requests, per that field's own doc — the legacy slot has no concept of -## Region at all). Comparing the legacy field UNCONDITIONALLY alongside v2 -## therefore drops EVERY Region response as stale forever, even though the -## v2 comparison alone would have correctly accepted it — exactly the live -## bug (`_held_n` fixed; this is the same "old comparison still active -## alongside the new one" class of bug, one layer up in the staleness -## checks). Fix: branch on whether `granularity_v2` is actually PRESENT in -## the response dict (`w.has(...)`, not `w.get(..., default)` — the -## presence/absence distinction is the whole point here) — present (every -## real server, always) -> v2 is the ONLY granularity comparison; absent (a -## hypothetically old, pre-T-1152 server) -> fall back to the legacy -## comparison alone, matching this object's own pre-T-1152 behavior exactly. -## PR #192 cold-start round 3: the coordinator's live cold-server capture -## (retries=0, pending=true, forever) exposed that the OLD version of this -## function returned unconditionally whenever the WHOLE response's status -## wasn't "Ready" — treating a cold body's `status: "Pending"` (the FIRST -## request against a whole-body cache miss, before ANY layer including the -## window has even been queued — `serve_district_window`/`get_or_generate()` -## in server/src/atlas/layer_proxy.rs) identically to `NotFound`/`Error`: a -## silent no-op, never reaching the retry-scheduling code at all. Confirmed -## server-side: `status: Ready` is set ONLY on the whole-body cache-HIT -## branch, entirely independent of whether the WINDOW itself has resolved — -## so a cold body's first-ever window request gets `Pending` at the OUTER -## layer, while a body someone has already warmed (a later connection, or -## this SAME connection's own re-request once its own AnalyzeBody has -## landed) gets `Ready` with `district_window: null` inside it, correctly -## reaching the retry branch below. Same "still generating" signal, two -## different wire shapes depending on which cache warmed first — the fix is -## to treat BOTH as the identical retry-worthy state, matching -## atlas_generation_proxy.gd's own on_response() `match` shape exactly -## (Ready -> handle, Pending -> retry, NotFound/Error -> give up now, not -## after MAX_RETRIES: a real error is never going to resolve by waiting). -func on_response(response: Dictionary) -> void: - if str(response.get("body_id", "")) != _body_id: - return - var status := str(response.get("status", "")) - if status == "Pending": - _retry_if_pending() - return - if status != "Ready": - _pending = false # NotFound / Error — a real failure, not a queue wait; give up now - return - var window: Variant = response.get("district_window") - if window == null: - # §1: an as-yet-underived window rides as `district_window: None` - # inside an OUTER-Ready response — the whole-body cache already - # warmed, but this specific window hasn't derived yet. Same - # "still generating" signal the outer-Pending branch above handles, - # just the OTHER wire shape it can arrive in. - _retry_if_pending() - return - - var w: Dictionary = window - var echoed_center := _vec_from_center(w.get("center", [0, 0])) - var echoed_n := int(w.get("n", 0)) - var echoed_min_wl_m := int(w.get("min_wl_m", 0)) - var granularity_matches: bool = _echoed_granularity_matches(w) - if ( - echoed_center != _center - or echoed_n != _n - or echoed_min_wl_m != _min_wl_m - or not granularity_matches - ): - return # stale — answers a window we've since panned/zoomed away from, or a different rung - - _pending = false - _retries = 0 - _cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2) - window_ready.emit(w) - - -## Shared "still generating, re-poll" logic for BOTH wire shapes on_response() -## can see it in (outer status=="Pending", or inner district_window==null -## inside an outer Ready) — re-request until the derive lands or the retry -## ceiling is hit (queue-based serving, PR #185 — the response lands on a -## LATER tick, never this same round-trip). -func _retry_if_pending() -> void: - if not _pending: - return - if _retries < MAX_RETRIES: - _retries += 1 - _schedule_retry() - else: - _pending = false # gave up — caller's border-fade / empty state persists - - -## The granularity half of on_response()'s staleness check, split out for the -## v2-authoritative-when-present precedence rule (see on_response()'s own -## doc for the full live-round rationale). Presence, not value, is the -## branch: `w.has("granularity_v2")` — a real server ALWAYS sets this key -## (even if its value happened to coincidentally equal a default), so -## checking presence rather than "is it the default value" is the only -## correct way to distinguish "an old server that never heard of this field" -## from "a new server whose value happens to match." -func _echoed_granularity_matches(w: Dictionary) -> bool: - if w.has("granularity_v2"): - return str(w.get("granularity_v2")) == _granularity_v2 - var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY)) - return echoed_granularity == _granularity - - -## Pure: the exponential-backoff delay for retry attempt number `retry_count` -## (1-indexed — the FIRST retry, right after the initial request's own -## PENDING answer, uses `retry_count=1`), staggered by `stagger_index` -## (STAGGER_STEP*stagger_index added on top — deterministic, not randomized, -## so a test can assert the exact delay sequence for tile N directly). Split -## out from _schedule_retry() as a pure function for the same reason every -## other formula in this file is: directly unit-testable without a live -## Timer/SceneTree. -static func _retry_delay_for(retry_count: int, stagger_index: int) -> float: - var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0))) - var capped: float = minf(base, MAX_RETRY_DELAY) - return capped + STAGGER_STEP * float(stagger_index) - - -func _schedule_retry() -> void: - var delay: float = _retry_delay_for(_retries, _stagger_index) - var timer := get_tree().create_timer(delay) - timer.timeout.connect( - func() -> void: - if _pending: - SimBridge.request_atlas_layers( - _body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2 - ) - ) - - -func is_pending() -> bool: - return _pending - - -## The v2 granularity ("Quarter" | "District" | "Region") this object most -## recently asked for — the viewer reads this to know which rung the HELD -## window (once it arrives) actually is, without threading a second copy of -## the state through window_ready's payload. -func get_granularity_v2() -> String: - return _granularity_v2 - - -## Current window extent in districts, as CLAMPED — the viewer's rung- -## selection math needs this to compute the held composite's real-world -## extent regardless of which rung last resolved it. -func get_n() -> int: - return _n - - -func get_cache() -> Variant: - return _cache - - -static func _vec_from_center(center: Variant) -> Vector2i: - if center is Array and center.size() >= 2: - return Vector2i(int(center[0]), int(center[1])) - return Vector2i.ZERO diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd deleted file mode 100644 index 89bd3f301..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd +++ /dev/null @@ -1,203 +0,0 @@ -extends Node - -## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's -## ruling, design doc §4: "the top rest state is the WHOLE body, served as -## progressive capped-density TILING"). A single wire-capped Region window -## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a -## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single -## window is ~a third of the body, the exact live-round finding: shot 01's -## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference). -## -## Owns N independent `AtlasWindowRequest` child instances — one per tile — -## reusing 100% of the EXISTING, already-tested single-window request/cache/ -## debounce/retry machinery (atlas_window_request.gd) rather than -## reinventing multi-window orchestration from scratch. Each tile is just a -## Region-granularity window request at its own canonicalized center -## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already -## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline), -## so nothing about the request/cache LAYER needed to change for tiling to -## work — only the ORCHESTRATION (issue N requests instead of one) and the -## DRAWING (a mosaic instead of one composite) are new. -## -## No `class_name` on purpose, matching every other viewer-owned helper in -## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/ -## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer) -## passes itself to `_init()`. -## -## Progressive arrival (design doc §4's own "with visible refinement as -## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects -## independently — a tile's own `_tiles[i]["window"]` updates the moment -## THAT tile's response lands, with no dependency on any other tile's -## arrival. The viewer/overlay reads `get_tiles()` every draw and renders -## whichever tiles have arrived so far — an empty/border-fade gap for the -## rest, exactly the same "hold what's there, sharpen in place" contract -## single-window progressive refinement already has (§6 "no mode flip"), -## just per-tile instead of per-composite. - -signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws - -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) -var _body_id: String = "" -var _tile_n: int = AtlasWindowGeometry.TILE_N - -## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest, -## "window": Variant (null until arrived)} — one entry per tile, in the SAME -## deterministic order compute_tile_grid() produces (stable fill order, see -## that function's own doc). -var _tiles: Array = [] - - -func _init(owner_ref = null) -> void: - _owner = owner_ref - - -## Unlike an individual AtlasWindowRequest (which has no signal connection of -## its own — the OWNING viewer forwards responses to it, per that class' -## own doc), the tile set DOES connect directly to -## SimBridge.atlas_layers_received itself and fans a single response out to -## EVERY tile's own `on_response()` — each tile's OWN staleness guard -## (center/n/granularity_v2) decides whether that particular response is -## the one IT was waiting for; only the matching tile ever adopts it. This -## is the same "one shared inbound signal, N independent consumers filtering -## by their own criteria" shape the design already uses elsewhere (every -## AtlasWindowRequest instance filters on its own state from a common -## broadcast — tiling just means N instances share the broadcast instead of -## one). -func _ready() -> void: - SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) - - -func _exit_tree() -> void: - if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): - SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) - - -func _on_atlas_layers_received(response: Dictionary) -> void: - for tile: Dictionary in _tiles: - var request = tile["request"] - if is_instance_valid(request): - request.on_response(response) - - -## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid, -## tears down any PREVIOUS tile set's child request nodes (a fresh -## enter_orbital() on a DIFFERENT body must not leave stale tile requests -## from the old body wired up), and issues one request per tile immediately -## (no debounce — matching AtlasWindowRequest.request_now()'s own "first -## window" contract, §5: entry is never debounced, only pan/rung-reselect -## refetches are). -func enter(body_id: String, body_radius_km: float) -> void: - _teardown() - _body_id = body_id - var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km) - for i in range(centers.size()): - var center: Vector2i = centers[i] - var request = AtlasWindowRequest.new(self) - request.name = "Tile%d" % i - # Cold-start dossier round 3 hardening: deterministic per-tile retry - # stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the - # same frame and retry in perfect lockstep, a request pulse every - # RETRY_DELAY instead of a spread trickle. Set BEFORE request_now() - # so it's already in place for the very first retry, if one fires. - request._stagger_index = i - add_child(request) - var tile_index := i # capture by value for the lambda below - request.window_ready.connect( - func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window) - ) - _tiles.append({"center": center, "request": request, "window": null}) - request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION) - - -func _on_tile_window_ready(index: int, window: Dictionary) -> void: - if index < 0 or index >= _tiles.size(): - return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown) - _tiles[index]["window"] = window - tile_ready.emit(index) - - -## Tear down every tile's request node — disconnects nothing explicitly -## (queue_free() on a Node disconnects all its own signal connections -## automatically, Godot's documented behavior) but DOES clear `_tiles` so a -## stale index from an in-flight-but-now-orphaned request's eventual -## response can never reach `_on_tile_window_ready()` with a now-meaningless -## index (guarded there too, belt-and-suspenders). -func _teardown() -> void: - for tile: Dictionary in _tiles: - var request = tile["request"] - if is_instance_valid(request): - request.queue_free() - _tiles.clear() - - -## The current tile set, for the viewer/overlay to draw — an Array of -## {"center": Vector2i, "window": Variant} (the "request" key is internal, -## not exposed here; callers only need center + arrived-or-null window). -func get_tiles() -> Array: - var result: Array = [] - for tile: Dictionary in _tiles: - result.append({"center": tile["center"], "window": tile["window"]}) - return result - - -## True while at least one tile's `window` hasn't arrived yet — the viewer's -## cold-start self-healing redraw (see AtlasWindowViewer._process()'s own -## doc) polls this every frame so a mosaic's paint can never silently wedge -## behind a lost/late queue_redraw() no matter which signal edge it was -## supposed to ride in on. Also the source of truth for whether the §4 -## pending treatment should show. -func has_pending_tiles() -> bool: - for tile: Dictionary in _tiles: - if tile["window"] == null: - return true - return false - - -## True once at least ONE tile has a real window — distinct from -## has_pending_tiles()'s "at least one MISSING" (both can be true at once, -## mid-arrival). PR #192 cold-start round 2: a cold server's first -## AnalyzeBody can take >10s with ZERO tiles landed the whole time — the -## per-tile border-fade wash alone (subtle, same color as every OTHER -## no-data-yet state) read as broken darkness in a live cold capture, not -## loading. The viewer uses this to gate an unmistakable "DERIVING -## TERRAIN…" label: shown while this is false (nothing has arrived at all — -## the reassurance is needed most), dropped the moment even one tile lands -## (per-tile washes alone read fine once real content is visibly filling in -## around the gaps). -func has_any_tile_arrived() -> bool: - for tile: Dictionary in _tiles: - if tile["window"] != null: - return true - return false - - -## True once tiling is active for the current body — a body whose whole -## circumference fits in ONE Region window's own coverage ceiling produces -## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so -## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling -## machinery, technically) from "genuinely multiple tiles" — the viewer uses -## this to decide whether the tile-set draw path or the ORIGINAL -## single-window draw path is simpler/preferred for a small body (both are -## correct; single-window avoids the extra Node/signal overhead when there's -## only ever going to be one tile). -func is_multi_tile() -> bool: - return _tiles.size() > 1 - - -func get_tile_count() -> int: - return _tiles.size() - - -## True if every tile currently has an arrived window — the viewer/legend -## chrome can use this to know when the mosaic is "complete" vs. still -## progressively filling in. -func is_fully_arrived() -> bool: - if _tiles.is_empty(): - return false - for tile: Dictionary in _tiles: - if tile["window"] == null: - return false - return true diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd deleted file mode 100644 index 54838b898..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ /dev/null @@ -1,1017 +0,0 @@ -class_name AtlasWindowViewer -extends Control - -## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's -## click-through-only entry per the D-226 T-1143-rulings amendment — see -## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152 -## client half): the whole ladder from the canonical orbital frame (Region -## rung) down to District/Quarter lives in ONE screen/Control, not a separate -## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer -## composite (or, at the orbital rest state on a large body, a MOSAIC of -## several — see `_tile_mode`/AtlasWindowTileSet) at whichever rung is -## currently held: morphology base lightness-modulated by elev_q, three -## switchable climate/vegetation overlays, an always-on glaciation ice-tint -## modifier (drawing is AtlasWindowOverlay's job — this Control owns input, -## request orchestration, chrome, pan/zoom). One colorizer family renders -## every rung unchanged (design doc §6). -## -## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153): -## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom -## = _canvas.scale. Zoom is client-side on the ALREADY-HELD composite -## frame-to-frame, but CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013): -## crossing a rung's coverage ceiling fires a background request for the -## new granularity while the OLD composite keeps drawing — progressive -## refinement, no blank frame (§6). A pan past the held window's edge -## re-requests the SAME rung at a new center. -## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD -## condition) — on a body needing tiling, re-enters `_tile_mode` (§4). -## - _window_request owns the single-window cache/debounce/retry; _tile_set -## owns N of those for the tiled rest state — this Control decides WHICH. -## -## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click -## semantics with future map objects, so it's removed entirely): -## WASD / arrow keys continuous pan, held (frame-rate independent, _process) -## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX pans toward it (suppressed over UI / unfocused) -## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153) -## Esc back (nav.pop()) - -signal back_pressed - -const PANEL_MARGIN: float = 16.0 -const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 - -## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display -## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED -## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling re-requests a -## DIFFERENT granularity at the SAME apparent screen extent, never clamping -## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same -## range independently. MIN_ZOOM must stay low enough that enter_orbital()'s -## largest legal `n` COVER-fits without clamping — 0.0005 covers a -## ~120,000 km-radius body at a 3840px 4K viewport. -const MIN_ZOOM: float = 0.0005 -const MAX_ZOOM: float = 64.0 -const ZOOM_STEP: float = 1.15 - -## T-1145 item 2: WASD/arrow-key pan speed, CANVAS px/s at zoom=1.0 — actual -## screen-space rate scales by CURRENT _view_zoom, so panning covers the same -## TERRAIN/s regardless of zoom. ~6 districts/s at zoom=1.0 (96/16) — brisk. -const PAN_SPEED_CANVAS_PX_S: float = 96.0 - -## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll -## (Jeroen's "~24px"). Same speed as WASD, no separate constant. -const EDGE_SCROLL_MARGIN_PX: float = 24.0 - -## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale -## (no source texture dictates a native size; this constant IS it). 16px/cell -## at n=64 gives a ~1024px composite before zoom, fits a 1280x720+ viewport. -const CELL_PIXEL_SIZE: float = 16.0 - -const COLOR_BG: Color = Color("#0d1117") -## Border-fade target (§5 "what renders during the wait"): the underlying -## whole-body heightmap's own background tint, so the newly-exposed edge -## reads as "real data seen through", not a placeholder block. Also read by -## AtlasWindowOverlay for a per-tile pending wash in the mosaic — same -## "still working" cue, one color. -const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) - -## T-1153/R6: pending-refinement wash — border-fade's referent repointed to -## "the previous derived composite at this position" for a rung-crossing zoom -## (real data stays on screen, unlike the no-composite-yet case above). Same -## hue, lighter alpha — hints something sharper is arriving, not that the view is wrong. -const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12) - -## Cold-start dossier: the "DERIVING TERRAIN…" label's own text color — -## default_implant.tres's text_dim (#667788), matching the implant chrome's -## existing dim/secondary-text role rather than inventing a new hue. -const COLOR_DERIVING_LABEL: Color = Color("#667788") - -## D-243: 2,048 m per district side. -const DISTRICT_M: float = 2048.0 - -const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") -# T-1142: fit-and-center + pole-wall pan-clamp math (canonicalization lives on -# atlas_descend_geometry.gd instead — it already owns district_extent()). -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") -const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") -# T-1153: orbital rest-state mosaic orchestration. -const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") -# T-1156 wave 1: whole-body nature overlay (rivers/basins/attractors). -const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") - -# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/ -# atlas_legend_panel.gd's existing duck-typed viewer interface: both call -# only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(), so -# this Control is a drop-in "viewer" for either component without -# subclassing AtlasViewer). Glaciation is deliberately NOT here — it is an -# always-on modifier per §5, not a toggle id. -const OVERLAY_DEFS: Array = [ - { - "id": "gen_dw_temp", - "label": "TMP", - "group": "toggle", - "tooltip": "Temperature — region-ramp colorizer, reused from the planetary climate overlay." - }, - { - "id": "gen_dw_moisture", - "label": "MST", - "group": "toggle", - "tooltip": "Moisture — dry-to-wet ramp." - }, - { - "id": "gen_dw_veg", - "label": "VEG", - "group": "toggle", - "tooltip": "Vegetation — green-family ramp. Marine reads transparent (open water)." - }, - # T-1156 wave 1: per-rung visibility lives in AtlasWindowGeometry's tables. - {"id": "gen_rivers", "label": "RVR", "group": "toggle", "tooltip": "Rivers — per-rung class filter."}, - {"id": "gen_basins", "label": "BAS", "group": "toggle", "tooltip": "Drainage basins — Region only."}, - {"id": "gen_attractors", "label": "ATR", "group": "toggle", "tooltip": "Attractors — Region only."}, -] - -# ── Context (set by enter()) ────────────────────────────────────────────── -var _body: Dictionary = {} -var _system: Dictionary = {} -var _implant_theme = null - -# ── Held window + geometry ──────────────────────────────────────────────── -var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting -var _held_center: Vector2i = Vector2i.ZERO -var _held_n: int = 32 -## T-1153: the granularity_v2 tag this viewer is currently HOLDING (the -## last-adopted _window's own rung) — distinct from -## _window_request.get_granularity_v2() (most recently REQUESTED, may -## already be in flight to a different rung). Defaults to District. -var _held_granularity_v2: String = "District" - -# ── Pan/zoom state ───────────────────────────────────────────────────────── -var _view_offset: Vector2 = Vector2.ZERO -var _view_zoom: float = 1.0 -# T-1145 item 2: last known LOCAL mouse position, tracked from _gui_input's -# motion events for the edge-scroll check in _process() (which has no -# InputEvent of its own to read a position from). Starts at -ONE (an -# impossible in-bounds position) so edge-scroll never fires before the mouse -# has ever moved over this Control at least once. -var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0) -# T-1145 item 2: whether the OS application window currently has focus — -# edge-scroll is suppressed while false (see _is_cursor_edge_scrolling()'s -# doc). Defaults true: a freshly-entered screen assumes focus until told -# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT. -var _app_has_focus: bool = true -# T-1142/T-1145: true once the user has manually panned or zoomed since the -# last enter()/fit — auto-fit (enter, first window arrival, resize) only -# re-fits BEFORE this flips, so it never fights a player mid-interaction. -# Reset to false on every enter() (a fresh descent always starts fitted). -var _user_adjusted: bool = false -# T-1142: true from enter() until the FIRST _on_window_ready() fires (the -# composite's arrival re-fits once, in case entry used a not-yet-final -# viewport size) — false after, so LATER pan-triggered arrivals never re-fit -# on their own (only entry/first-arrival/resize do). -var _awaiting_first_window: bool = true - -# ── Overlay visibility ───────────────────────────────────────────────────── -var _overlay_visibility: Dictionary = {} - -# ── Child nodes ──────────────────────────────────────────────────────────── -var _canvas: Node2D = null -var _overlay_node: AtlasWindowOverlay = null -var _screen_header: ImplantHeader = null -var _overlay_bar = null -var _legend_panel = null -var _window_request = null # AtlasWindowRequest -var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3) -var _nature_overlay = null # AtlasWindowNatureOverlay (T-1156 wave 1) - -## T-1153 (design doc §4): true while showing the orbital rest state as a -## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set -## by `_enter_tile_mode()`; cleared when `_maybe_reselect_rung()` leaves Region. -var _tile_mode: bool = false - - -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: - _overlay_visibility[def["id"]] = false - _overlay_visibility["gen_rivers"] = true # T-1156 wave 1: RVR default ON - - _canvas = Node2D.new() - _canvas.name = "WindowCanvas" - add_child(_canvas) - - _overlay_node = AtlasWindowOverlay.new() - _overlay_node.name = "WindowOverlay" - _overlay_node.viewer = self - _canvas.add_child(_overlay_node) - - _nature_overlay = AtlasWindowNatureOverlay.new(self) - _nature_overlay.name = "WindowNatureOverlay" - _canvas.add_child(_nature_overlay) - - _window_request = AtlasWindowRequest.new(self) - _window_request.name = "WindowRequest" - add_child(_window_request) - _window_request.window_ready.connect(_on_window_ready) - - _tile_set = AtlasWindowTileSet.new(self) - _tile_set.name = "TileSet" - add_child(_tile_set) - _tile_set.tile_ready.connect(_on_tile_ready) - - _build_screen_header() - _build_overlay_bar() - _build_legend_panel() - - SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) - - -func _exit_tree() -> void: - if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): - SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) - - -## Enter the window screen centered on `district_center` at District -## granularity. n defaults to 32. Thin wrapper over _enter_at_rung() (T-1153); -## survives as a direct-call test entry. T-1142: `district_center` is -## canonicalized BEFORE it becomes `_held_center`, matching the server's -## normalize_window_center(). -func enter( - body: Dictionary, - system: Dictionary, - district_center: Vector2i, - n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N -) -> void: - var radius_km: float = float(body.get("body_radius_km", 0.0)) - var canonical_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center( - district_center, radius_km - ) - _enter_at_rung(body, system, canonical_center, n, AtlasWindowRequest.GRANULARITY_V2_DISTRICT) - - -## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital -## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at -## the canonical origin). The "regional" nav entry point (T-1152 client -## half): the player lands on a fully-derived Region-rung view of the whole -## body, then wheel-zoom descends CONTINUOUSLY from there. Canonical origin -## = district (0,0), same quantity is_fully_zoomed_out()/ -## _maybe_reset_to_canonical_frame() test against. No-radius bodies fall -## back to the District-rung default window. Live round 3 (design doc §4): -## the rest state must TILE — a single wire-capped Region window covers only -## a fraction of a real body's circumference, so once compute_tile_grid() -## returns MORE than one tile, entry goes through _enter_tile_mode() instead. -func enter_orbital(body: Dictionary, system: Dictionary) -> void: - var radius_km: float = float(body.get("body_radius_km", 0.0)) - if radius_km <= 0.0: - _enter_at_rung( - body, system, Vector2i.ZERO, - AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N, - AtlasWindowRequest.GRANULARITY_V2_DISTRICT - ) - return - var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) - if tiles.size() > 1: - _enter_tile_mode(body, system, radius_km) - return - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var n: int = int(extent["cols"]) - _enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION) - - -## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as -## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`. -## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps -## its own TILE_N-sized request independently). -func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void: - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var n: int = int(extent["cols"]) - _body = body - _system = system - _held_center = Vector2i.ZERO - _held_n = n - _held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION - _tile_mode = true - _window = null - _user_adjusted = false - _awaiting_first_window = true - _fit_and_center() - _window_request.reset() - _tile_set.enter(_dict_str(_body, "body_id", ""), radius_km) - _nature_overlay.request_layer1(_dict_str(_body, "body_id", "")) - _refresh_screen_header() - _legend_panel.refresh() - grab_focus() - queue_redraw() - _overlay_node.queue_redraw() - - -## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center` -## must already be canonicalized by the caller. Resets every piece of -## held/request state for a fresh descent, plus _held_granularity_v2. `n` -## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes -## `_held_n` (C1 clamp-mirror, one layer up) — mirroring AtlasWindowRequest. -## request_now()'s own clamp (PR #191 Tyre C1). -func _enter_at_rung( - body: Dictionary, - system: Dictionary, - district_center: Vector2i, - n: int, - granularity_v2: String -) -> void: - var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(n, granularity_v2) - _body = body - _system = system - _held_center = district_center - _held_n = clamped_n - _held_granularity_v2 = granularity_v2 - _tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode - _window = null - _user_adjusted = false - _awaiting_first_window = true - _fit_and_center() - _window_request.reset() - _window_request.request_now( - _dict_str(_body, "body_id", ""), _held_center, clamped_n, granularity_v2 - ) - _nature_overlay.request_layer1(_dict_str(_body, "body_id", "")) - _refresh_screen_header() - _legend_panel.refresh() - grab_focus() - queue_redraw() - _overlay_node.queue_redraw() - - -## T-1142: fit-and-center — applies fit_window_view()'s zoom/offset, then -## re-clamps to the pole wall. Called from enter(), the FIRST -## _on_window_ready() after entry, and NOTIFICATION_RESIZED — never -## mid-interaction (guarded by _user_adjusted). -func _fit_and_center() -> void: - var viewport: Vector2 = get_rect().size - if viewport == Vector2.ZERO: - viewport = Vector2(1280.0, 720.0) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - viewport, _held_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - _view_zoom = fit["zoom"] - _view_offset = _clamp_offset_to_pole_wall(fit["offset"]) - _apply_transform() - - -## T-1142: needs the body's rows_half — a no-radius body has no pole -## concept, so the wall is a no-op there (rows_half=0). -func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2: - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - if radius_km <= 0.0: - return offset - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - return AtlasWindowGeometry.clamp_pan_offset_to_pole_wall( - offset, get_rect().size, _held_center, _held_n, int(extent["rows_half"]), CELL_PIXEL_SIZE, _view_zoom - ) - - -func leave() -> void: - pass - - -## Named get_district_window(), NOT get_window() — Node already defines -## get_window() -> Window; shadowing it with an incompatible type errors. -func get_district_window() -> Variant: - return _window - - -## T-1153: true while showing the orbital rest state as a multi-window -## mosaic instead of the single held composite — AtlasWindowOverlay reads -## this to pick a draw path. -func is_tile_mode() -> bool: - return _tile_mode - - -## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw -## path — only meaningful while is_tile_mode() is true. -func get_tile_set() -> Variant: - return _tile_set - - -## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's -## mosaic draw path converts tile centers into canvas-local space via these -## (see AtlasWindowGeometry.district_to_canvas_local()'s own doc). -func get_held_center() -> Vector2i: - return _held_center - - -func get_held_n() -> int: - return _held_n - - -## Currently-HELD rung tag — legend reads this via spacing_for_rung(), -## mirroring _refresh_screen_header()'s own use of the field. -func get_held_granularity_v2() -> String: - return _held_granularity_v2 - - -## Live round 5: current body's radius — mosaic draw needs `cols` for -## nearest_wrap_image()'s wrap resolution. Mirrors the -## `_body.get("body_radius_km", 0.0)` pattern used throughout this file. -func get_body_radius_km() -> float: - return float(_body.get("body_radius_km", 0.0)) - - -## Cold-start dossier (BUG 2): RegionalScreen.enter() reads this to skip a -## redundant enter_orbital() for a repeat nav.push("regional") on the SAME -## body — see that call site's own doc for why. -func get_body_id() -> String: - return _dict_str(_body, "body_id", "") - - -## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this -## rather than hardcoding CELL_PIXEL_SIZE, so the viewer stays the single -## source of geometry truth ("viewer owns the transform, overlay only draws"). -func get_cell_pixel_size() -> float: - return CELL_PIXEL_SIZE - - -func is_overlay_visible(overlay_id: String) -> bool: - return bool(_overlay_visibility.get(overlay_id, false)) - - -func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: - if not _overlay_visibility.has(overlay_id): - push_warning("AtlasWindowViewer: unknown overlay id '%s'" % overlay_id) - return - _overlay_visibility[overlay_id] = visible_state - _overlay_node.queue_redraw() - _nature_overlay.queue_redraw() - _legend_panel.refresh() - - -func get_overlay_defs() -> Array: - return OVERLAY_DEFS - - -# ============================================================================= -# Window response routing -# ============================================================================= - - -func _on_atlas_layers_received(response: Dictionary) -> void: - _window_request.on_response(response) - - -## T-1153: progressive refinement — the ONE place a new rung's window gets -## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite -## stays drawn until this call — no blank frame, no mode flip (§6) — -## because `_window` only ever gets REPLACED, never nulled, once adopted -## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap). -func _on_window_ready(window: Dictionary) -> void: - # Only adopt if it still matches what THIS viewer is currently showing — - # AtlasWindowRequest already filtered by its own last-asked echo (§2/ - # T-1150/T-1152), but a cache-hit path can fire synchronously from - # enter() before _held_center reflects a re-entrant call, so comparing - # again removes any ordering assumption. granularity_v2 is compared too - # — a district response answering a request since moved on to Region - # (rapid wheel-zoom) must not be adopted just because center/n match. - var w_center := _vec_from_center(window.get("center", [0, 0])) - var w_n := int(window.get("n", 0)) - var w_granularity_v2 := str( - window.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT) - ) - if ( - w_center != _held_center - or w_n != _held_n - or w_granularity_v2 != _window_request.get_granularity_v2() - ): - return - _window = window - _held_granularity_v2 = w_granularity_v2 - # T-1142: re-fit on the FIRST composite arrival only (the entry-time fit - # may have used a viewport size the layout hadn't settled into yet — this - # corrects it once) — never on a later pan-triggered arrival, and never - # once the user has manually zoomed/panned (same _user_adjusted guard - # enter()/NOTIFICATION_RESIZED use). A later rung-swap arrival is - # EXACTLY a "later pan/zoom-triggered arrival" in this sense — it must - # never re-fit either, or a wheel-zoom-triggered rung swap would yank the - # player's view back to a fitted framing mid-gesture. - if _awaiting_first_window and not _user_adjusted: - _fit_and_center() - _awaiting_first_window = false - _refresh_screen_header() - _legend_panel.refresh() - queue_redraw() - _overlay_node.queue_redraw() - _nature_overlay.queue_redraw() # T-1170 B3: courses read THIS window too - - -## T-1153 (design doc §4 "progressive... with visible refinement as tiles -## complete"): a SINGLE tile's window arrived — redraw so the overlay's -## mosaic loop picks it up. No acceptance/staleness logic needed here (each -## tile's OWN AtlasWindowRequest already filtered before this signal fired). -func _on_tile_ready(_index: int) -> void: - queue_redraw() - _overlay_node.queue_redraw() - - -# ============================================================================= -# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR- -# ANCHORED and CONTINUOUS ACROSS RUNGS, D-226 T-1143-rulings: the held -# composite draws client-side-zoomed with NO re-request, but crossing a -# rung's spacing threshold fires a background request at the new granularity -# (progressive refinement, _maybe_reselect_rung()) while the OLD composite -# stays on screen. -# ============================================================================= - - -func _apply_transform() -> void: - _canvas.position = _view_offset - _canvas.scale = Vector2(_view_zoom, _view_zoom) - queue_redraw() - _overlay_node.queue_redraw() - _nature_overlay.queue_redraw() - - -## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays -## fixed on screen. Unclamped across rungs (only the wide MIN_ZOOM/MAX_ZOOM -## safety clamp applies); after applying, checks the top rest state. -func _zoom_at(mouse_pos: Vector2, factor: float) -> void: - var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) - # Live round 6: once at the canonical frame, zoom-OUT must not drift - # below fit (see the reset's own doc — that caused a request storm). - # Clamping here, not the reset guard, keeps the reset edge-triggered. - if new_zoom < _view_zoom and _is_at_canonical_frame(): - var fit_zoom: float = _canonical_fit_zoom() - new_zoom = maxf(new_zoom, fit_zoom) - if is_equal_approx(new_zoom, _view_zoom): - return - var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom - _view_zoom = new_zoom - _view_offset = mouse_pos - local_before * _view_zoom - _apply_transform() - if _maybe_reset_to_canonical_frame(): - return # the reset already re-fit + re-requested at the top rung - _maybe_reselect_rung() - - -## The world extent (metres) currently displayed across the LARGER viewport -## dimension — the `E` half of the §5 rung-selection rule. Thin wrapper over -## AtlasWindowGeometry.world_extent_m() (a pure function of `_view_zoom`). -func _current_world_extent_m() -> float: - return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size) - - -## §5 rung-selection rule + progressive refinement (T-1153): after a zoom -## step, recompute the legal rung for the NOW-displayed world extent. If it -## differs from what's HELD, request the new granularity centered on the -## CURRENT screen-center (same formula as _maybe_refloat_window()). `_held_n` -## MUST be re-clamped for the TARGET rung (C1 clamp-mirror, a third layer -## up) — a stale large `_held_n` desyncs `_on_window_ready()`'s staleness -## check. Does NOT touch `_window`/`_held_granularity_v2` — the OLD -## composite keeps drawing until _on_window_ready() adopts the new one (§6 -## "no mode flip"; this lag is what made the canonical-frame reset's OLD -## guard misfire, live round 5). -func _maybe_reselect_rung() -> void: - if _held_n <= 0: - return - var world_extent_m: float = _current_world_extent_m() - var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y) - var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px) - - # T-1153, live round 3: tile mode is TOP-of-the-ladder only. Staying at - # Region means staying tiled; crossing OUT of Region falls through to - # the single-window path below, flipping `_tile_mode` off. - var leaving_tile_mode := false - if _tile_mode: - if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION: - return - _tile_mode = false - leaving_tile_mode = true - - # `leaving_tile_mode` FORCES the request through even if - # `_window_request`'s own STALE granularity_v2 (untouched while tiled) - # happens to already equal `target_rung` by coincidence. - if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2(): - return # already requesting (or holding) the rung this extent calls for - var new_center: Vector2i = _screen_center_district() - var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung) - _held_center = new_center - _held_n = clamped_n - # Live round 4: `_view_offset` must be recomputed the instant `held_n` - # changes — see recompute_offset_for_held_n_change()'s own doc for why. - # Anchors on the SAME screen center just used above, preserving §6 "no - # layout jump" across the crossing. - _view_offset = AtlasWindowGeometry.recompute_offset_for_held_n_change( - size * 0.5, _view_zoom, clamped_n, CELL_PIXEL_SIZE - ) - _apply_transform() - _window_request.request_debounced( - _dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung - ) - - -## The DistrictPos the current screen center maps to, in RAW absolute -## district space. Thin wrapper over screen_center_to_district() so -## pan-edge/rung-reselect refetch share one formula. -func _screen_center_district() -> Vector2i: - var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district( - size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n - ) - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km) - - -## Live round 6: whether the body needs mosaic tiling — mirrors -## enter_orbital()'s own dispatch condition so entry/predicate/guard agree. -func _canonical_tile_mode(radius_km: float) -> bool: - return AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1 - - -## Live round 6: the fit zoom enter_orbital() lands on for the CURRENT -## body/viewport — read by both the canonical predicate and _zoom_at()'s floor. -func _canonical_fit_zoom() -> float: - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - if radius_km <= 0.0: - return _view_zoom # no-radius body — no canonical frame concept, floor is a no-op - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var canonical_n: int = int(extent["cols"]) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - return float(fit["zoom"]) - - -## Live round 6: true when the CURRENT view EXACTLY matches the canonical -## frame — center/mode/granularity plus `_view_zoom` at fit (float epsilon). -func _is_at_canonical_frame() -> bool: - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - if radius_km <= 0.0: - return false - return ( - _held_center == Vector2i.ZERO - and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION - and _tile_mode == _canonical_tile_mode(radius_km) - and is_equal_approx(_view_zoom, _canonical_fit_zoom()) - ) - - -## Jeroen's HARD condition: "a full zoom-out resets to the original -## canonical planetary frame and location." EDGE-triggered (live round 6): -## fires only on the transition INTO fully-zoomed-out from non-canonical. -## Live round 5 fix: also requires is_tile_mode() to match (a TILING body's -## granularity stayed stale "Region" after zooming IN left tile mode, -## misreading "already canonical" from `_held_center`/`_held_granularity_v2` -## alone — LAGGING fields, updated only on response adoption). Live round 6 -## fix: a per-tick zoom-equality check had made this LEVEL-triggered — -## continued zoom-out flooded the server (889/897 wire responses in one -## phase) via repeated enter_orbital() tearing down/rebuilding the tile set, -## orphaning in-flight responses (nothing held -> black). Fixed by moving -## the zoom-drift concern to `_zoom_at()`'s own floor instead — this guard -## stays edge-triggered. -func _maybe_reset_to_canonical_frame() -> bool: - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - if radius_km <= 0.0: - return false # no-radius body — no canonical frame concept (matches enter_orbital()'s own guard) - var world_extent_m: float = _current_world_extent_m() - if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km): - return false - if _is_at_canonical_frame(): - return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture - enter_orbital(_body, _system) - return true - - -## Programmatic view control (T-1120 capture-API parity). -func get_view_zoom() -> float: - return _view_zoom - - -func get_view_offset() -> Vector2: - return _view_offset - - -func set_view(zoom: float, offset: Vector2) -> void: - _view_zoom = clampf(zoom, MIN_ZOOM, MAX_ZOOM) - _view_offset = offset - _apply_transform() - - -# ============================================================================= -# Pan-edge re-request (§4/§5): re-request ONLY when the view pans past the -# held window's edge; zoom never refetches. -# ============================================================================= - - -## After a pan delta (T-1145: WASD/edge-scroll), check whether the -## screen-center now maps to a DistrictPos outside the held window's extent -## — if so, float a NEW window centered on that point via the debounced path. -## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute -## district space — only the FINAL new_center is canonicalized, matching the -## server's normalize_window_center(). -func _maybe_refloat_window() -> void: - if _held_n <= 0: - return - if _tile_mode: - # T-1153: the tile set already covers the WHOLE body — no "edge" to - # cross while tiled. _apply_pan_delta() still moves _view_offset; - # this only skips the single-window re-float below. - return - var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district( - size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n - ) - if raw_new_center == _held_center: - return - # Edge-crossing check: only re-request if the screen-center point has - # actually left the CURRENTLY HELD window's extent — a pan that stays - # inside the window (even if the nominal "nearest DistrictPos to center" - # ticked over by one cell near a boundary) must not spam a request every - # frame. §4: "re-requests only when a pan carries the view past the held - # window's edge." - var half: float = float(_held_n) / 2.0 - var local_col: float = float(raw_new_center.x - _held_center.x) + half - var local_row: float = float(raw_new_center.y - _held_center.y) + half - var inside: bool = ( - local_col >= 0.0 - and local_col < float(_held_n) - and local_row >= 0.0 - and local_row < float(_held_n) - ) - if inside: - return - var radius_km: float = float(_body.get("body_radius_km", 0.0)) - var new_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center( - raw_new_center, radius_km - ) - _held_center = new_center - # T-1153: pass the CURRENTLY HELD rung — panning must re-request at the - # SAME granularity it's already showing, never silently reset to the - # request object's District default (that default exists for callers with - # no rung concept of their own; this viewer always has one). - _window_request.request_debounced( - _dict_str(_body, "body_id", ""), new_center, _held_n, _held_granularity_v2 - ) - - -# ============================================================================= -# Drawing (background + border-fade + header) -# ============================================================================= - - -func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) - if _tile_mode: - # T-1153: this viewer's own border-fade/pending-wash are single-window - # concepts (one extent, one state) — a mosaic's per-tile pending wash - # is drawn by AtlasWindowOverlay itself (see _draw_tile_mosaic()). - if _tile_set and not _tile_set.has_any_tile_arrived(): - _draw_deriving_terrain_label() - return - if _window == null: - # §5 "what renders during the wait": a border-fade to the underlying - # whole-body context rather than black/a spinner — the honest - # available substitute, this viewer having no resident whole-body - # texture of its own. - _draw_border_fade() - elif _window_request and _window_request.is_pending(): - # T-1153/R6: the border-fade's REFERENT repointed — a rung-crossing - # zoom (progressive refinement) leaves `_window` non-null (the OLD - # composite still on screen) while a NEW rung's request is in flight - # underneath it. A lighter pending wash (not the full opaque fade the - # no-composite-at-all case uses, since real data IS showing through) - # signals "sharper detail incoming" without implying the view is wrong. - _draw_pending_refinement_wash() - - -func _draw_border_fade() -> void: - var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom - var top_left: Vector2 = _view_offset - draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE) - - -func _draw_pending_refinement_wash() -> void: - var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom - var top_left: Vector2 = _view_offset - draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH) - - -## Cold-start dossier: the subtle per-tile COLOR_BORDER_FADE wash alone -## (AtlasWindowOverlay._draw_tile_mosaic()) read as broken darkness in a -## live cold capture (>10s, zero tiles landed), not loading. Drawn in SCREEN -## space (unlike the per-tile washes, which live in the panned/zoomed -## _canvas) so it always reads centered/legible regardless of zoom. Dropped -## the instant even one tile lands (has_any_tile_arrived()) — per-tile -## washes alone read fine once real content fills in around the gaps. -## Positioning math lives in AtlasWindowGeometry.centered_label_baseline() -## (file-length; a pure function either way). -func _draw_deriving_terrain_label() -> void: - var label := "DERIVING TERRAIN…" - var font := ThemeDB.fallback_font - var font_size := 20 - var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) - var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(get_rect().size, text_size) - draw_string( - font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL - ) - - -func _build_screen_header() -> void: - _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) - - -## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure -## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for -## file-length; the "continuous metres-per-pixel readout, never a discrete -## mode label" rationale lives on that function's own doc now). -func _refresh_screen_header() -> void: - if _screen_header == null: - return - var content: Dictionary = AtlasWindowGeometry.screen_header_content( - _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), - _held_center, _held_n, _held_granularity_v2, DISTRICT_M - ) - _screen_header.set_content(content["title"], content["subtitle"]) - - -## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved -## there for file-length) — kept as a method since it's directly tested. -func _location_label() -> String: - return AtlasWindowGeometry.location_label( - _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center - ) - - -# ============================================================================= -# Input -# ============================================================================= - - -## No city panel / sidebar in this mode (yet) — always false. Wired into -## _gui_input exactly where AtlasViewer's own _is_over_ui is, so a future -## sidebar addition only needs to change THIS function's body. -func _is_over_ui(_pos: Vector2) -> bool: - return false - - -## T-1145 item 2: LMB-drag panning is GONE (drag broke click semantics with -## map objects). What remains: wheel zoom and tracking mouse position for -## edge-scroll. WASD/arrow panning does NOT go through _gui_input — it's a -## HELD-key pan polled every frame in _process(). -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 ( - 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_WHEEL_UP and mb.pressed: - _user_adjusted = true - _zoom_at(mb.position, ZOOM_STEP) - elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: - _user_adjusted = true - _zoom_at(mb.position, 1.0 / ZOOM_STEP) - elif event is InputEventMouseMotion: - _last_mouse_pos = (event as InputEventMouseMotion).position - - -func _handle_key(event: InputEventKey) -> void: - if event.keycode == KEY_ESCAPE: - back_pressed.emit() - - -## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both HELD-state -## effects polled every frame, handed to _apply_pan_delta() (split out for -## testability). Skips while hidden (screen not the active nav-stack entry). -## Cold-start self-heal (coordinator live repro): every queue_redraw() site -## is already paired with _overlay_node's own, yet a cold server's slow first -## AnalyzeBody still showed a black mosaic with every tile held/textured, -## only resolving on an unrelated gesture. Rather than chase one signal edge -## that might drop, redraw every frame while any tile is pending — free once -## complete. -func _process(delta: float) -> void: - if not visible: - return - if _tile_mode and _tile_set and _tile_set.has_pending_tiles(): - queue_redraw() - _overlay_node.queue_redraw() - var direction: Vector2 = _held_pan_direction() - if _is_cursor_edge_scrolling(): - direction += _edge_scroll_direction() - if direction == Vector2.ZERO: - return - _apply_pan_delta(direction, delta) - - -## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and -## this frame's delta — frame-rate independent, zoom-scaled, pole-wall -## clamped (T-1142). Sets _user_adjusted and triggers the pan-edge refetch -## (§4). Split from _process() so a test can call it with a synthetic input. -func _apply_pan_delta(direction: Vector2, delta: float) -> void: - var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis - _user_adjusted = true - var delta_offset: Vector2 = -normalized * PAN_SPEED_CANVAS_PX_S * _view_zoom * delta - _view_offset = _clamp_offset_to_pole_wall(_view_offset + delta_offset) - _apply_transform() - _maybe_refloat_window() - - -## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode -## — see AtlasWindowGeometry.held_pan_direction()'s own doc for the full -## rationale (moved there for file-length/testability). -func _held_pan_direction() -> Vector2: - return AtlasWindowGeometry.held_pan_direction() - - -## T-1145 item 2: edge-scroll suppression — see -## AtlasWindowGeometry.is_cursor_edge_scrolling()'s doc (moved there T-1153). -func _is_cursor_edge_scrolling() -> bool: - return AtlasWindowGeometry.is_cursor_edge_scrolling( - _app_has_focus, _is_over_ui(_last_mouse_pos), size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX - ) - - -## Direction toward whichever edge(s) the cursor is near — see -## AtlasWindowGeometry.edge_scroll_direction()'s doc (moved there T-1153). -func _edge_scroll_direction() -> Vector2: - return AtlasWindowGeometry.edge_scroll_direction(size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX) - - -# ============================================================================= -# Overlay bar / legend (reuses atlas_overlay_bar.gd/atlas_legend_panel.gd — -# both call only get_overlay_defs()/is_overlay_visible()/set_overlay_visible(), -# so this Control is a drop-in viewer for either component) -# ============================================================================= - - -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 = "WindowOverlayBar" - 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) - 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) - - -func _build_legend_panel() -> void: - var LegendScript := load("res://ui/implant/apps/atlas/atlas_window_legend.gd") - _legend_panel = LegendScript.new(self) - _legend_panel.name = "WindowLegend" - _legend_panel.theme_resource = _implant_theme - add_child(_legend_panel) - _legend_panel.refresh() - - -func _notification(what: int) -> void: - if what == NOTIFICATION_RESIZED: - if _overlay_bar: - _position_overlay_bar() - if _legend_panel: - _legend_panel.reposition() - # T-1142: re-fit on resize (_user_adjusted guard, as other auto-fit - # events). _canvas guard: NOTIFICATION_RESIZED can fire mid-_ready() - # before _canvas exists (gdUnit add_child() crash, confirmed). - if _canvas and not _user_adjusted: - _fit_and_center() - elif what == NOTIFICATION_APPLICATION_FOCUS_OUT: - _app_has_focus = false - elif what == NOTIFICATION_APPLICATION_FOCUS_IN: - _app_has_focus = true - - -## Safely extract a string field from a dict, falling back when missing or -## null (matches atlas_viewer.gd's own _dict_str verbatim). -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 - - -static func _vec_from_center(center: Variant) -> Vector2i: - if center is Array and center.size() >= 2: - return Vector2i(int(center[0]), int(center[1])) - return Vector2i.ZERO diff --git a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd b/client/ui/implant/apps/atlas/atlas_window_water_clip.gd deleted file mode 100644 index 2fd654a99..000000000 --- a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd +++ /dev/null @@ -1,171 +0,0 @@ -extends RefCounted - -## T-1172 — the river-skeleton waterline-clip fix. Pure geometry, split into -## its own file (not folded into atlas_window_geometry.gd, which is already -## close to the gdlint max-file-lines cap): the river skeleton's own SOURCE -## (server/src/atlas/drainage.rs) is filtered against the RAW heightmap sea -## level, but the DRAWN ocean this client actually paints is the derived -## MorphologyZone verdict (server/src/atlas/district_profile.rs) — which -## post-T-1162 includes the coast-warp invention (the drawn coastline is -## deterministically displaced from the heightmap coast) and, at Region -## rung, aggregates to 204.8 km cells. These are two independently-computed -## waterlines that can legitimately disagree; Tyre's ruling (T-1172): no -## single server waterline is well-defined, so the fix is a CLIENT draw-time -## clip against whichever composite cell is currently ON SCREEN at a given -## river dot's position — strict drop, no snap (a dot that lands on drawn -## water is simply not drawn; Region's 205 km cells may amputate a river's -## final coastal dots, an accepted cost per the ruling). -## -## T-1170 Ruling 3g update (RESTRUCTURED, not blanket-retired): the clip is -## RETIRED for the District/Quarter COURSE-drawing rungs -## (AtlasWindowNatureOverlay._draw_course_path()/_draw_one_course()) — -## courses carry real rung-consistent termini invented server-side against -## the SAME rung's drawn coast, so the clip's job is already done there. This -## file's clip machinery is STILL LIVE and used at the Region SKELETON path -## (_draw_skeleton_chords()/_segment_touches_drawn_water()) — Region still -## draws the whole-body skeleton against a rung-dependent drawn coast, which -## is precisely the presentation-frame reconciliation this file exists for. -## The Region clip is PERMANENT-UNTIL-REGION-GOES-WINDOWED (T-1143 ruling 2's -## progressive tiling) — when Region itself becomes a windowed rung, it -## inherits windowed courses too, and this file retires entirely at that -## point, not before. -## -## const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd") - -const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") - -## Sentinel returned by the lookups below when no arrived composite data -## covers the queried position — either the position is outside every -## held/tiled window's own extent, or the window/tile at that position -## hasn't arrived yet. The caller (AtlasWindowNatureOverlay) must FAIL OPEN -## on this sentinel (draw the dot) — Tyre's rule 5: the clip is a -## presentation refinement, never a data gate. Chosen as -1 (not a legal -## MorphologyZone discriminant, which is always >= 0) so it can never be -## mistaken for a real "not water" zone. -const MORPHOLOGY_ZONE_NO_DATA: int = -1 - - -## The derived per-cell grid side length (CELLS) for a window dict `w` — a -## DELIBERATE duplicate of AtlasWindowOverlay.cell_grid_side_for_window(), -## not a shared call, matching this codebase's own "each file owns its own -## reading of a small pure lookup rather than force a dependency" precedent -## (atlas_overlay_colors.gd's header doc states this explicitly for the -## color-palette case; the SAME rationale applies here: atlas_window_overlay.gd -## already depends on atlas_window_geometry.gd, so a dependency back from -## there — or from this file, if it lived there — would risk a circular or -## at least confusing import graph). Mirrors -## server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side` -## exactly, matching the canonical function's own doc byte-for-byte in intent. -static func cell_grid_side_for_window(w: Dictionary) -> int: - var n: int = int(w.get("n", 0)) - var granularity_v2 := str(w.get("granularity_v2", "District")) - match granularity_v2: - "Quarter": - return n * 4 - "Region": - return maxi(roundi(float(n) / 100.0), 1) - _: - return n - - -## Resolve a FRACTIONAL district position to the MorphologyZone discriminant -## of the composite cell covering it, for a SINGLE window dict `w` (the -## single-window rung path: District/Quarter, and each individual Region -## tile in tile mode share this same per-window shape). Returns -## MORPHOLOGY_ZONE_NO_DATA if `w` is null/malformed, has no morphology array, -## or `district` falls outside `w`'s own `[center - n/2, center + n/2)` -## extent (the SAME containment convention -## AtlasWindowGeometry.district_to_canvas_local() uses, so a position judged -## "inside" here is exactly the position that would draw as part of THIS -## window's composite on screen — no separate containment rule to drift out -## of sync with the actual paint). -static func morphology_zone_in_window(district: Vector2, w: Variant) -> int: - if not w is Dictionary: - return MORPHOLOGY_ZONE_NO_DATA - var window: Dictionary = w - var center_raw: Variant = window.get("center", [0, 0]) - var center: Vector2i = ( - Vector2i(int(center_raw[0]), int(center_raw[1])) if center_raw is Array else Vector2i.ZERO - ) - var n: int = int(window.get("n", 0)) - if n <= 0: - return MORPHOLOGY_ZONE_NO_DATA - var half: float = float(n) * 0.5 - var local_x: float = district.x - (float(center.x) - half) - var local_y: float = district.y - (float(center.y) - half) - if local_x < 0.0 or local_x >= float(n) or local_y < 0.0 or local_y >= float(n): - return MORPHOLOGY_ZONE_NO_DATA - var morphology: Variant = window.get("morphology") - if not (morphology is PackedByteArray or morphology is Array): - return MORPHOLOGY_ZONE_NO_DATA - var grid_side: int = cell_grid_side_for_window(window) - if grid_side <= 0: - return MORPHOLOGY_ZONE_NO_DATA - # T-1172 round 2: SHARED index formula with the terrain painter - # (AtlasWindowGeometry.cell_index_for_local_offset() — see its own doc - # for why this is now factored out instead of duplicated). - var cell: Vector2i = AtlasWindowGeometryRef.cell_index_for_local_offset( - local_x, local_y, n, grid_side - ) - var idx: int = cell.y * grid_side + cell.x - if idx < 0 or idx >= morphology.size(): - return MORPHOLOGY_ZONE_NO_DATA - return int(morphology[idx]) - - -## Resolve a fractional district position to a MorphologyZone discriminant -## across BOTH viewer modes — the single dispatch point -## AtlasWindowNatureOverlay's clip predicate calls, so it never needs its own -## is_tile_mode() branch. Single-window mode: one direct -## morphology_zone_in_window() call against `single_window`. Tile mode: -## linear scan of `tiles` (Array of {"center": Vector2i, "window": Variant}, -## AtlasWindowTileSet.get_tiles()'s own shape) for whichever tile's ON-SCREEN -## extent contains the position — each tile's OWN echoed `window["n"]` is -## used for the actual containment test (not TILE_N assumed), matching this -## cluster's "the response is the source of truth for what it actually -## contains" precedent, since a clamped/still-arriving tile's real extent -## can differ from the nominal per-tile request size. -## -## **Live round 2 fix (coordinator's trace, T-1172):** the wrap resolution -## MUST mirror AtlasWindowOverlay._draw_tile_mosaic()'s own -## `draw_col = nearest_wrap_image(center.x, held_center.x, cols)` EXACTLY — -## wrap the TILE'S OWN CENTER toward `held_center` (the viewer's currently- -## displayed reference frame), then test the (already held-center-wrapped) -## query `district` against that RESOLVED center. The original version did -## the inverse — wrapped the QUERY toward the tile's raw CANONICAL center — -## which is not the same operation and silently tested containment against -## the WRONG wrap-image of the tile for any tile whose canonical center is -## far from `held_center` (i.e. any tile that needs wrapping to appear -## on-screen at all — confirmed live: a dot at district.x=-9569 visibly -## sitting on the painter's WEST wrap-image of the seam tile -## (canonical center 12739, drawn at draw_col=-6400) was tested by the old -## code against that tile's EAST/canonical span `[9539, 15939)` instead — -## landed inside it by coincidence (mod arithmetic), read a real but -## WRONG-LOCATION land cell, and never clipped). `district.x` is assumed -## ALREADY wrap-resolved near `held_center` by the caller (AtlasWindowNatureOverlay. -## _district()'s own contract) — this function does not re-wrap it, only the -## tile centers, exactly mirroring the painter's own asymmetry (the painter -## never wrap-resolves the query either — canvas-local coordinates are -## already in the held-center frame by construction). -static func resolve_morphology_zone( - district: Vector2, is_tile_mode: bool, single_window: Variant, tiles: Array, cols: int, - held_center_x: int = 0 -) -> int: - if not is_tile_mode: - return morphology_zone_in_window(district, single_window) - for tile: Dictionary in tiles: - var window: Variant = tile.get("window") - if not window is Dictionary: - continue - var tile_center: Vector2i = tile.get("center", Vector2i.ZERO) - var draw_col: int = tile_center.x - if cols > 0: - draw_col = AtlasWindowGeometryRef.nearest_wrap_image(tile_center.x, held_center_x, cols) - var effective_window: Dictionary = window - if draw_col != tile_center.x: - effective_window = (window as Dictionary).duplicate() - effective_window["center"] = [draw_col, tile_center.y] - var zone: int = morphology_zone_in_window(district, effective_window) - if zone != MORPHOLOGY_ZONE_NO_DATA: - return zone - return MORPHOLOGY_ZONE_NO_DATA diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index f12d15a4f..2d5b3cbc7 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -1,62 +1,46 @@ class_name RegionalScreen extends Control -## Regional zoom-ladder screen for AtlasApp (#844, D-191; superseded T-1153 — -## D-226 T-1143-rulings amendment). Thin wrapper around AtlasWindowViewer, -## entering at the CANONICAL ORBITAL FRAME (Region rung) via enter_orbital() -## instead of AtlasViewer's retired heightmap-texture show_body() path — -## enter/leave are still the nav interface, unchanged shape. +## Regional zoom-ladder screen for AtlasApp (#844, D-191; rebuilt T-1182 — +## D-255 stepped Atlas ladder). Thin wrapper around StepCanvasViewer, +## entering at the Global opener (rung 0, D-255(a)) via enter() — enter/leave +## are still the nav interface, unchanged shape from the retired +## AtlasWindowViewer/enter_orbital() this replaces. ## -## T-1152 client half: this is the ONE screen for the whole ladder now — -## there is no separate "district" nav hop for the windowed drill-down -## (D-013 "the zoom gesture owns spatial descent" restored for this seam -## means descent is a CONTINUOUS in-screen zoom, not a nav-stack push). Esc +## T-1182: this is the ONE screen for the whole six-rung ladder — Global +## through Chunk are all served by the SAME StepCanvasViewer, no separate +## orbital-mosaic-vs-window split (that split retired with AtlasWindowViewer +## itself, D-255(a): "Global opener is rung 0, one viewer one path"). Esc ## from anywhere in the ladder is a single nav.pop() back to whatever pushed -## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged -## from before this ticket (it already routed Esc through nav.pop() for any -## screen that isn't "reach"/"system"-with-a-panel-open). -## -## `district_descend_requested`/`economics_link_requested` signals retire -## with AtlasViewer's click-through (the reticle/hover-to-descend affordance -## — Jeroen's ruling: retired as the SOLE entry, and no cheap click-target -## exists on the orbital Region-rung view to wire a shortcut onto yet, unlike -## a future settlement-marker click which WOULD have a natural landing -## point — see AtlasWindowViewer's own doc on why enter() still exists as a -## District-rung entry point for exactly that future wiring). -## economics_link_requested is deferred with AtlasViewer's city-click sidebar -## (see the batch report for the full list of what's deferred vs. carried). +## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged. signal back_requested -var _viewer: AtlasWindowViewer = null +var _viewer: StepCanvasViewer = null func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - _viewer = AtlasWindowViewer.new() - _viewer.name = "AtlasWindowViewer" + _viewer = StepCanvasViewer.new() + _viewer.name = "StepCanvasViewer" add_child(_viewer) _viewer.back_pressed.connect(_on_viewer_back) -## Cold-start dossier (BUG 2, PR #192 review): ImplantApp._on_screen_changed() -## calls enter() unconditionally on EVERY screen_changed, including a repeat -## nav.push("regional", ...) that lands on the SAME screen already showing — -## reachable from more than one input path (body-click, panel+Enter) and, -## on a slow cold server, plausible for a player to trigger twice before the -## first descent settles. Without this guard, a repeat push tore down and -## rebuilt the whole tile set (orphaning in-flight requests, live round 6's -## exact storm shape for a different trigger) and refreshed the legend from -## scratch each time — confirmed source of the ~10x legend stack. Guarded -## on body_id alone (not a deep payload compare): the same body, re-entered, -## should always resume the SAME orbital session already in flight, never -## restart it — an actual body CHANGE (different id) still re-enters fresh. +## Cold-start dossier (BUG 2, PR #192 review — carried forward): ImplantApp. +## _on_screen_changed() calls enter() unconditionally on EVERY screen_changed, +## including a repeat nav.push("regional", ...) that lands on the SAME screen +## already showing — reachable from more than one input path (body-click, +## panel+Enter). Guarded on body_id alone (not a deep payload compare): the +## same body, re-entered, should always resume the SAME session already in +## flight, never restart it — an actual body CHANGE (different id) still +## re-enters fresh. func enter(payload: Dictionary) -> void: var body: Dictionary = payload.get("body", {}) var system: Dictionary = payload.get("system", {}) if str(body.get("body_id", "")) == _viewer.get_body_id(): return - _viewer.enter_orbital(body, system) + _viewer.enter(body, system) func leave() -> void: diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd new file mode 100644 index 000000000..8a936c3ee --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd @@ -0,0 +1,178 @@ +class_name StepCanvasAnnotationLayer +extends Node2D + +## The unscaled screen-space annotation sibling (T-1182, D-255(a)/(e)) — +## river course polylines, settlement markers, drawn at LITERAL px sizes, +## positions world->screen transformed per-frame via a plain linear map +## (StepCanvasTransport.world_m_to_canvas_local()). This is the layer that +## makes `_zs()`/`_zs_stroke()`/`_zs_ring_radius()` structurally +## unnecessary: because this Node2D is NEVER scaled (no `.scale` write +## anywhere in this file, unlike the retired `_canvas.scale` model), a +## constant like COURSE_WIDTH_PX below already IS the on-screen width with +## no compensating division — "by construction, not by discipline" per +## Stig's round-1 design doc. +## +## Data source: the SAME decoded StepCanvasResponse `canvas` Dictionary the +## terrain layer reads (`courses`/`cliffs`/`settlement_id` — sparse +## MessagePack-native lists + one dense array, all arriving together on the +## one flat tagged response, D-255(c): "one flat tagged response carries +## every field together"). No separate whole-body Layer-1 fetch (unlike the +## retired atlas_window_nature_overlay.gd's two-source model) — every rung's +## annotations come from that SAME rung's own canvas. +## +## Draw order (bottom to top): course polylines, then settlement markers — +## matches the retired cluster's "basins-under-rivers-under-attractors" +## precedent (features layer over terrain, points over lines). + +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + +## Reused verbatim from the retired atlas_window_nature_overlay.gd (itself +## reused from the retired atlas_marker_overlay.gd, "Araminta's ruling: +## reuse the retired palette exactly") — same values, same source of truth. +const COLOR_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0) +const COLOR_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0) +const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0) + +## River class ids — mirrors server/src/atlas/body_world_state.rs +## RiverNetwork.river_class's own doc (0=stream, 1=tributary, 2=trunk), +## same vocabulary the retired atlas_window_geometry_nature.gd used. +const RIVER_CLASS_STREAM: int = 0 +const RIVER_CLASS_TRIBUTARY: int = 1 +const RIVER_CLASS_TRUNK: int = 2 + +## Course polyline width/opacity per class — LITERAL screen-space px/alpha, +## no zoom compensation needed (this layer is never scaled). Same functional +## defaults as the retired COURSE_CLASS_WIDTH_PX/COURSE_CLASS_OPACITY tables +## (Araminta's ruling, trunk widest/stream thinnest). +const COURSE_CLASS_WIDTH_PX: Dictionary = { + RIVER_CLASS_STREAM: 0.9, RIVER_CLASS_TRIBUTARY: 1.4, RIVER_CLASS_TRUNK: 2.2 +} +const COURSE_CLASS_OPACITY: Dictionary = { + RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0 +} + +const MOUTH_RING_RADIUS_PX: float = 5.0 +const MOUTH_HALO_RADIUS_PX: float = 8.0 +const MOUTH_HALO_ALPHA: float = 0.30 + +const SETTLEMENT_MARKER_RADIUS_PX: float = 3.5 + +const COURSE_TERMINUS_MOUTH: String = "Mouth" + +var _canvas: Variant = null # decoded StepCanvasResponse.canvas — null until first arrival +var _world_center: Vector2 = Vector2.ZERO +var _rung: String = StepCanvasTransport.RUNG_DISTRICT +var _extent_cells: Vector2i = Vector2i.ZERO + + +## Adopt a new canvas + its request frame (world center, rung, extent) — the +## world->screen projection for every drawn feature depends on all three, +## so they're set together, matching the terrain layer's own texture-plus- +## frame handoff. +func set_frame( + canvas: Variant, world_center: Vector2, rung: String, extent_cells: Vector2i +) -> void: + _canvas = canvas + _world_center = world_center + _rung = rung + _extent_cells = extent_cells + queue_redraw() + + +func clear_frame() -> void: + _canvas = null + queue_redraw() + + +func _draw() -> void: + if not _canvas is Dictionary: + return + var d: Dictionary = _canvas + _draw_courses(d.get("courses", [])) + _draw_settlements(d) + + +func _draw_courses(courses: Array) -> void: + for course_raw: Variant in courses: + if not course_raw is Dictionary: + continue + _draw_one_course(course_raw) + + +func _draw_one_course(course: Dictionary) -> void: + var cls: int = int(course.get("class", RIVER_CLASS_TRUNK)) + var points_raw: Variant = course.get("points") + if not points_raw is Array or (points_raw as Array).size() < 2: + return + + var screen_pts := PackedVector2Array() + for pt: Variant in points_raw: + if not (pt is Array and pt.size() >= 2): + continue + var world_m := Vector2(float(pt[0]), float(pt[1])) + screen_pts.append(_world_to_local(world_m)) + if screen_pts.size() < 2: + return + + var width: float = float(COURSE_CLASS_WIDTH_PX.get(cls, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM])) + var opacity: float = float(COURSE_CLASS_OPACITY.get(cls, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM])) + var color := Color(COLOR_RIVER.r, COLOR_RIVER.g, COLOR_RIVER.b, COLOR_RIVER.a * opacity) + draw_polyline(screen_pts, color, width, true) + + var terminus: String = str(course.get("terminus", "")) + if terminus == COURSE_TERMINUS_MOUTH: + _draw_mouth_ring(screen_pts[screen_pts.size() - 1]) + + +func _draw_mouth_ring(local_pt: Vector2) -> void: + var halo_color := Color(COLOR_MOUTH.r, COLOR_MOUTH.g, COLOR_MOUTH.b, MOUTH_HALO_ALPHA) + draw_arc(local_pt, MOUTH_HALO_RADIUS_PX, 0.0, TAU, 24, halo_color, 3.0) + draw_arc(local_pt, MOUTH_RING_RADIUS_PX, 0.0, TAU, 18, COLOR_MOUTH, 1.5) + + +## Settlement markers — one per DISTINCT non-zero `settlement_id` cell, +## drawn at the cell's own world-metre center (not every covered cell — a +## marker per settlement anchor reads as a landmark, a marker per cell would +## flood the layer with a proximity-radius-sized cluster of dots). Cheap: +## settlement coverage is sparse by construction (SETTLEMENT_COVERAGE_RADIUS_M +## is a small disc relative to a fixed-rung canvas), so a single linear scan +## collecting first-seen positions per id is a one-off cost per canvas +## arrival, not a per-frame cost (this function only runs inside _draw(), +## itself only invoked on queue_redraw(), never a _process() poll). +func _draw_settlements(canvas: Dictionary) -> void: + var settlement_id: Variant = canvas.get("settlement_id") + if not (settlement_id is Array or settlement_id is PackedByteArray): + return + var width: int = int(canvas.get("width", 0)) + if width <= 0: + return + var seen: Dictionary = {} # settlement id -> true, first-seen-cell-only + var ids: Array = settlement_id + for i in range(ids.size()): + var sid: int = int(ids[i]) + if sid == 0 or seen.has(sid): + continue + seen[sid] = true + var col: int = i % width + var row: int = i / width + var world_m: Vector2 = _cell_center_world_m(col, row) + var local_pt: Vector2 = _world_to_local(world_m) + draw_circle(local_pt, SETTLEMENT_MARKER_RADIUS_PX, COLOR_SETTLEMENT) + + +## The world-metre center of gridunit (col, row) in the currently-held +## canvas — the inverse of step_canvas.rs's own per-cell placement +## (`center_world_m + (col - half_w) * step_m`), mirrored client-side so a +## settlement marker lands on the exact cell its id was read from. +func _cell_center_world_m(col: int, row: int) -> Vector2: + var spacing: float = StepCanvasTransport.spacing_for_rung(_rung) + var half_w: float = float(_extent_cells.x) * 0.5 + var half_h: float = float(_extent_cells.y) * 0.5 + return Vector2( + _world_center.x + (float(col) - half_w) * spacing, + _world_center.y + (float(row) - half_h) * spacing + ) + + +func _world_to_local(world_m: Vector2) -> Vector2: + return StepCanvasTransport.world_m_to_canvas_local(world_m, _world_center, _rung, _extent_cells) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd new file mode 100644 index 000000000..88ff9c48e --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd @@ -0,0 +1,107 @@ +extends RefCounted + +## Client-side in-memory LRU cache for decoded StepCanvasResponse payloads +## (T-1182, D-255(d): "cheapest-first: client in-memory LRU -> client +## disk-backed FileAccess store... -> server"). This IS the surviving LRU +## SHAPE the T-1182 ticket names — adapted from atlas_window_cache.gd's +## erase+reinsert-is-MRU idiom (Godot Dictionary preserves insertion order, +## so "move to the end on touch, evict from the front on overflow" is the +## whole implementation, no separate linked-list/counter bookkeeping), NOT +## deleted, per the ticket's own "SURVIVING SURFACES" instruction. +## +## Keyed on (body_id, rung, center, extent, min_wl_m) — the exact tuple +## server/src/atlas/step_canvas.rs's own StepCanvasCache keys on +## (StepCanvasKey = (String, StepCanvasRung, (i64,i64), (u32,u32), u32)) — +## so a client-side cache hit and a server-side cache hit are asking the +## identical question. D-227 determinism means a previously-fetched canvas +## is valid FOREVER for that exact key (geometry tier) — this is an +## LRU-evict-only cache: no freshness check, no TTL, no invalidation path. +## The disk-backed tier (T-1183) and its two-axis storage-eviction sweep are +## a SEPARATE, later concern layered underneath this one, per D-255(d)'s own +## three-tier split — this file is tier 1 only. +## +## Global (rung 0) entries key on `center=(0,0)`/`extent=(0,0)` unconditionally +## (the wire's own convention — center/extent are meaningless but still sent +## per step_canvas_protocol.gd's doc) — so every Global request for the same +## body_id collides on ONE cache slot, matching the server's own always-keep +## single-entry-per-body GlobalTierCache. This cache does NOT special-case +## a retention floor for Global the way the server's GlobalTierCache does +## (D-255(d)'s "never evicted by either eviction axis" is a SERVER-side +## always-keep guarantee) — client-side, Global is still just the +## most-recently-touched entry like anything else, protected from ordinary +## LRU pressure only by being re-touched every time the player returns to +## the top of the ladder (the common case, per this cluster's existing +## "Esc-then-re-enter and pan-back... makes this the common path" +## precedent). A dedicated client-side retention floor is exactly the +## T-1183 disk-tier ticket's Tier-1 scope, not duplicated here. + +const DEFAULT_MAX_ENTRIES: int = 24 + +var _max_entries: int = DEFAULT_MAX_ENTRIES +var _entries: Dictionary = {} # key String -> decoded StepCanvasResponse Dictionary + + +func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: + _max_entries = maxi(1, max_entries) + + +## Build the cache key. `rung == "Global"` collapses center/extent to a +## fixed sentinel (0,0) regardless of what's passed — matching the wire's +## own "Global ignores center/extent" contract, so a caller that +## accidentally passes a stale center/extent for a Global request still +## lands on the correct single per-body slot. +static func make_key( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> String: + var key_center: Vector2i = Vector2i.ZERO if rung == "Global" else center + var key_extent: Vector2i = Vector2i.ZERO if rung == "Global" else extent + return "%s:%s:%d,%d:%d,%d:%d" % [ + body_id, rung, key_center.x, key_center.y, key_extent.x, key_extent.y, min_wl_m + ] + + +func has( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> bool: + return _entries.has(make_key(body_id, rung, center, extent, min_wl_m)) + + +## Fetch a cached canvas, touching it (move-to-most-recently-used). Returns +## null on a miss. +func get_canvas( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> Variant: + var key := make_key(body_id, rung, center, extent, min_wl_m) + if not _entries.has(key): + return null + var value: Variant = _entries[key] + _entries.erase(key) + _entries[key] = value + return value + + +## Store a decoded canvas, evicting the least-recently-used entry if over +## capacity. Overwriting an existing key also counts as a touch. +func put( + body_id: String, + rung: String, + center: Vector2i, + extent: Vector2i, + canvas: Dictionary, + min_wl_m: int = 0 +) -> void: + var key := make_key(body_id, rung, center, extent, min_wl_m) + if _entries.has(key): + _entries.erase(key) + _entries[key] = canvas + while _entries.size() > _max_entries: + var oldest_key: String = _entries.keys()[0] + _entries.erase(oldest_key) + + +func size() -> int: + return _entries.size() + + +func clear() -> void: + _entries.clear() diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd new file mode 100644 index 000000000..34fb96b31 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd @@ -0,0 +1,115 @@ +extends RefCounted + +## Per-cell colorize mapping for the step-canvas terrain layer (T-1182, c1 +## ruling: CPU `Image.set_pixel` coloring, confirmed round 2 — "ship CPU +## coloring... use Image.set_pixel, not a hand-rolled buffer write"). Reuses +## AtlasOverlayColors' EXISTING ramp functions verbatim — the same base/ +## toggle/modifier compositing model atlas_window_overlay.gd's +## `_cell_color()`/`_apply_glaciation()` already ship (morphology hue x +## elev_q lightness base layer; temp/moisture/vegetation mutually-exclusive +## toggle overlays that REPLACE the base read; glaciation as an always-on +## post-modifier). No new palette, no new ramp — the c1 ruling's whole +## premise is that today's coloring code already proves the CPU path works; +## this file is that same pipeline pointed at RawStepCanvas-shaped decoded +## fields instead of DistrictWindowLayer ones. +## +## L8 single-channel reads (Stig round-1 measurement appendix ⑤: "4-9x +## cheaper than RGBA8 at every size... a free win if any wire field... can +## ship single-channel before the client colorizes it") — morphology/elev_q/ +## moisture_q/vegetation/glaciation/flooded_q all decode as Grayscale PNGs +## server-side (step_canvas.rs's png_encode_u8_plane: "enc.set_color(png:: +## ColorType::Grayscale)"), so Image.load_png_from_buffer() on the client +## already produces an L8 (FORMAT_L8) Image for each plane — this file reads +## them via get_pixel().r8 (the grayscale intensity IS the raw u8 +## classification/quantized value, no separate channel unpacking needed). + +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + +const TOGGLE_TEMP: String = "gen_dw_temp" +const TOGGLE_MOISTURE: String = "gen_dw_moisture" +const TOGGLE_VEGETATION: String = "gen_dw_veg" + +const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0) +const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) + +const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC + + +## One decoded plane set, pre-extracted from the four L8 Images + the two +## raw-array fields a caller needs per cell — built once per arrived canvas +## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel. +## `temp_dc`/`settlement_id` stay as plain Arrays (their domain doesn't fit +## a byte plane — see step_canvas_protocol.gd's own doc). +class CellPlanes: + var morphology: Image + var elev_q: Image + var moisture_q: Image + var vegetation: Image + var glaciation: Image + var temp_dc: Array + var width: int + var height: int + + +## The composited color for cell (col, row), given the active toggle (empty +## string = base layer only). Mirrors atlas_window_overlay.gd's +## `_cell_color()`/`_apply_glaciation()` composition order exactly: base or +## ONE toggle (never blended), then the glaciation modifier over whichever +## is showing. +static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: String) -> Color: + var base: Color + match active_toggle: + TOGGLE_TEMP: + base = _temp_color(planes, col, row) + TOGGLE_MOISTURE: + base = _moisture_color(planes, col, row) + TOGGLE_VEGETATION: + base = _vegetation_color(planes, col, row) + _: + base = _base_color(planes, col, row) + return _apply_glaciation(planes, col, row, base) + + +static func _base_color(planes: CellPlanes, col: int, row: int) -> Color: + var zone: int = _l8_value(planes.morphology, col, row) + var eq: int = _l8_value(planes.elev_q, col, row) + var base: Color = AtlasOverlayColors.district_window_morphology_color(zone) + return AtlasOverlayColors.district_window_elevation_lightness(base, eq) + + +static func _temp_color(planes: CellPlanes, col: int, row: int) -> Color: + var idx: int = row * planes.width + col + if idx < 0 or idx >= planes.temp_dc.size(): + return Color.TRANSPARENT + var t: int = int(planes.temp_dc[idx]) + if t == REGION_TEMP_NONE_DC: + return Color.TRANSPARENT + return AtlasOverlayColors.region_temp_color(t) + + +static func _moisture_color(planes: CellPlanes, col: int, row: int) -> Color: + var m: int = clampi(_l8_value(planes.moisture_q, col, row), 0, 100) + return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0) + + +static func _vegetation_color(planes: CellPlanes, col: int, row: int) -> Color: + return AtlasOverlayColors.vegetation_color(_l8_value(planes.vegetation, col, row)) + + +static func _apply_glaciation(planes: CellPlanes, col: int, row: int, base: Color) -> Color: + if planes.glaciation == null: + return base + return AtlasOverlayColors.glaciation_tint(base, _l8_value(planes.glaciation, col, row)) + + +## Read one L8 plane's raw byte value at (col, row) — Image.get_pixel() +## returns a Color whose .r channel IS the grayscale intensity in [0,1] for +## an L8/FORMAT_L8 image; multiplying back by 255 and rounding recovers the +## original u8 (Godot's own get_pixel()/set_pixel() round-trip contract for +## 8-bit formats). Out-of-bounds/null plane returns 0 (the same "skip/ +## fall back to zone 0" softness the rest of this cluster's dense-array +## readers already use for a malformed/short field). +static func _l8_value(plane: Image, col: int, row: int) -> int: + if plane == null or col < 0 or row < 0 or col >= plane.get_width() or row >= plane.get_height(): + return 0 + return int(round(plane.get_pixel(col, row).r * 255.0)) diff --git a/client/ui/implant/apps/atlas/atlas_window_legend.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd similarity index 55% rename from client/ui/implant/apps/atlas/atlas_window_legend.gd rename to client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd index b88aea396..339199aa0 100644 --- a/client/ui/implant/apps/atlas/atlas_window_legend.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd @@ -1,34 +1,29 @@ extends ImplantPanel -## Legend for AtlasWindowViewer's regional-window overlays (T-1138, D-226 -## T-1124 amendment §5). Mirrors atlas_legend_panel.gd's data-driven shape — -## one spec entry per overlay id, refresh() shows only the active ones — but -## scoped to the district-window screen's OWN toggle set (gen_dw_temp/ -## gen_dw_moisture/gen_dw_veg) plus the two always-on layers that need a key -## even though they have no toggle id of their own: the morphology base -## (folded to ~5 family rows, per §5's "not everything earns permanent screen -## space" instinct) and the glaciation ice-tint modifier. +## Legend for StepCanvasViewer's terrain layer (T-1182) — adapted from the +## retired atlas_window_legend.gd's data-driven shape (one spec entry per +## overlay id, refresh() shows only the active ones) to the D-255(a) rung +## vocabulary. Chrome only — the compositing model (morphology base folded +## to family rows + glaciation always-on modifier + at-most-one active +## toggle section) is unchanged from the retired panel, per the ticket's own +## "compositing/legend/overlay-bar chrome (call-site updates only)" +## instruction. ## -## No `class_name` on purpose, matching atlas_legend_panel.gd (review #8 -## precedent): the owner (AtlasWindowViewer) passes itself to _init(). +## No `class_name` on purpose, matching every other viewer-owned helper in +## this cluster. const PANEL_MARGIN: float = 16.0 const LEGEND_PANEL_WIDTH: float = 260.0 const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") -## The morphology base layer folds its 17 zones into ~5 family rows (§5: -## "mirroring T-1112's 'not everything earns permanent screen space' -## discipline") — the full 17-zone mapping stays in the city-click sidebar's -## reach, not duplicated here. Representative hue per family, picked from -## MORPHOLOGY_RGB_OPAQUE's own entries rather than a fresh set of colors. const MORPHOLOGY_FAMILY_ROWS: Array = [ - {"label": "water", "zones": [0, 1]}, # OpenOcean, Lake - {"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]}, # TidalFlat..Estuarine - {"label": "plains / river", "zones": [8, 9, 10, 11, 12]}, # AlluvialPlain..ValleyFloor - {"label": "upland", "zones": [13, 14]}, # MountainPass, Alpine - {"label": "volcanic / wetland", "zones": [15, 16]}, # Volcanic, Wetland + {"label": "water", "zones": [0, 1]}, + {"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]}, + {"label": "plains / river", "zones": [8, 9, 10, 11, 12]}, + {"label": "upland", "zones": [13, 14]}, + {"label": "volcanic / wetland", "zones": [15, 16]}, ] const GLACIATION_ROWS: Array = [ @@ -39,7 +34,7 @@ const GLACIATION_ROWS: Array = [ {"grade": 4, "label": "ice cap"}, ] -var _viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) +var _viewer = null # StepCanvasViewer (untyped to avoid cyclic ref) func _init(viewer_ref = null) -> void: @@ -53,31 +48,15 @@ func reposition() -> void: position = Vector2(PANEL_MARGIN, 60.0) -## Always shows the base-layer key (morphology + elevation reading, always -## on) plus glaciation (always-on modifier), then whichever toggle overlay is -## currently active, if any. -## -## The subtitle's km/cell reading is NOT a fixed "district window" label — -## it was until this fix hardcoded District's own 2.048 km/cell, which was a -## 100x lie whenever the viewer actually holds Region (204.8 km/cell) or -## Quarter (0.512 km/cell). Read through AtlasWindowGeometry. -## spacing_for_rung() — the SAME pure lookup _refresh_screen_header() uses -## for the main screen header's subtitle (screen_header_content()) — keyed -## off the viewer's current get_held_granularity_v2(), so the legend and -## header can never disagree. AtlasWindowViewer calls refresh() at every -## point _held_granularity_v2 changes (_enter_tile_mode(), _enter_at_rung(), -## _on_window_ready()'s rung-swap adoption) — see those call sites. func refresh() -> void: if _viewer == null: return clear() visible = true - var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_viewer.get_held_granularity_v2()) / 1000.0 - var subtitle: String = "%s window · %.3f km/cell" % [ - _viewer.get_held_granularity_v2().to_lower(), spacing_km - ] - add_component(ImplantHeader.new("REGIONAL LEGEND", subtitle)) + var spacing_km: float = StepCanvasTransport.spacing_for_rung(_viewer.get_held_rung()) / 1000.0 + var subtitle: String = "%s · %.3f km/gridunit" % [_viewer.get_held_rung().to_lower(), spacing_km] + add_component(ImplantHeader.new("ATLAS LEGEND", subtitle)) add_component(ImplantSeparator.new()) _add_morphology_section() @@ -119,7 +98,7 @@ func _add_glaciation_section() -> void: func _add_toggle_section(overlay_id: String) -> void: match overlay_id: "gen_dw_temp": - add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp (region colorizer, reused)")) + add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp")) add_component( ImplantDataRow.new("▦ cold", AtlasOverlayColors.COLOR_REGION_TEMP_COLD) ) diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_request.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_request.gd new file mode 100644 index 000000000..07303f972 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_request.gd @@ -0,0 +1,194 @@ +extends Node + +## Step-canvas request orchestration for StepCanvasViewer (T-1182, D-255(c)/ +## (d)). Owns the in-memory LRU cache, fires StepCanvasRequest frames, and +## retries on a Pending response — the same D-225 poll/cache/enqueue serving +## model atlas_window_request.gd already established for the legacy +## district_window carrier, now pointed at the new tagged envelope. +## +## No `class_name` on purpose, matching every other viewer-owned helper in +## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd, established +## precedent): the owner (StepCanvasViewer) passes itself to `_init()`. +## +## **The extent ECHO rule (T-1181 wire addendum, mandatory per this ticket's +## own read-first list): read the echoed extent, NEVER assume the requested +## one.** `on_response()` below stores `_held_extent` from the RESPONSE's own +## `extent` field, not from whatever was sent — a server-side clamp +## (STEP_CANVAS_MAX_EXTENT_AXIS/CELLS) can shrink the actual canvas below +## what was asked for, and every downstream consumer (the terrain layer's +## footprint math, the annotation layer's cell-center math) must agree with +## what actually arrived, not what was requested. Global responses echo +## `(0, 0)` for extent (the wire's own convention — see +## step_canvas_protocol.gd's doc) — callers reading `get_held_extent()` for +## a Global-held canvas must special-case it themselves (the viewer does, +## via StepCanvasTransport.RUNG_GLOBAL checks), matching this ticket's own +## "Global rung ignores wire extent" instruction. + +signal canvas_ready(response: Dictionary) # emitted on a cache hit OR a fresh Ready response + +const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd") + +const INITIAL_RETRY_DELAY: float = 0.5 +const MAX_RETRY_DELAY: float = 4.0 +const MAX_RETRIES: int = 30 + +var _owner = null # StepCanvasViewer (untyped to avoid cyclic ref) +var _cache: Variant = null # StepCanvasCache + +var _body_id: String = "" +var _rung: String = "" +var _center: Vector2i = Vector2i.ZERO +var _extent: Vector2i = Vector2i.ZERO +var _min_wl_m: int = 0 + +## The LAST ADOPTED (Ready, matching) response's own echoed extent — see the +## class doc's "extent ECHO rule". Distinct from `_extent` (the most +## recently REQUESTED extent, which may not match what a still-in-flight +## request will echo back). +var _held_extent: Vector2i = Vector2i.ZERO + +var _pending: bool = false +var _retries: int = 0 + + +func _init(owner_ref = null) -> void: + _owner = owner_ref + _cache = StepCanvasCache.new() + + +## Reset in-flight bookkeeping for a fresh body/rung entry — does NOT clear +## the cache (D-227: a cached canvas is valid forever for that exact key). +func reset() -> void: + _pending = false + _retries = 0 + + +## Fire (or serve from cache) a step-canvas request. Cache hit -> immediate +## synchronous canvas_ready emit, no network traffic. Cache miss -> send the +## request now; the response (or a Pending retry chain) arrives later via +## on_response(). +func request_now( + body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0 +) -> void: + _body_id = body_id + _rung = rung + _center = center + _extent = extent + _min_wl_m = min_wl_m + + var cached: Variant = _cache.get_canvas(body_id, rung, center, extent, min_wl_m) + if cached != null: + _pending = false + _retries = 0 + _held_extent = _echoed_extent(cached, rung, extent) + canvas_ready.emit(cached) + return + + _pending = true + _retries = 0 + SimBridge.request_step_canvas(body_id, rung, center, extent, min_wl_m) + + +## Handle a StepCanvasResponse (routed by the owning viewer from its own +## SimBridge.step_canvas_received subscription). Ignores a response for a +## stale body/rung/center/extent/min_wl_m (the player scrolled to a +## different step, or panned, while this was in flight) — the echoed fields +## are the staleness guard, same discipline atlas_window_request.gd's own +## on_response() uses for the legacy carrier. +func on_response(response: Dictionary) -> void: + if not _matches_this_request(response): + return + var status := str(response.get("status", "")) + if status == "Pending": + _retry_if_pending() + return + if status != "Ready": + _pending = false # NotFound / Error — a real failure, give up now + return + if not _echo_matches_held_frame(response): + return + + var canvas: Variant = response.get("canvas") + if canvas == null: + # A Ready-status response with no canvas payload — treat as still + # generating (matches atlas_window_request.gd's own "district_window: + # None inside an outer Ready" precedent for the same underlying + # concept, one carrier over). + _retry_if_pending() + return + + _pending = false + _retries = 0 + _held_extent = _echoed_extent(canvas, _rung, response.get("extent", Vector2i.ZERO)) + _cache.put(_body_id, _rung, _center, _extent, canvas, _min_wl_m) + canvas_ready.emit(canvas) + + +## Coarse staleness gate: body_id/rung identity alone (before touching the +## echoed frame fields, which only mean something once identity matches). +func _matches_this_request(response: Dictionary) -> bool: + if str(response.get("body_id", "")) != _body_id: + return false + return str(response.get("rung", "")) == _rung + + +## Fine staleness gate: the echoed center/min_wl_m must match what THIS +## object most recently asked for. Global ignores center/extent server-side +## (step_canvas_protocol.gd's own doc) — the guard skips both fields for +## that rung, matching the wire's own "meaningless but still echoed" +## contract. +func _echo_matches_held_frame(response: Dictionary) -> bool: + var echoed_min_wl_m: int = int(response.get("min_wl_m", 0)) + if echoed_min_wl_m != _min_wl_m: + return false + if _rung == "Global": + return true + var echoed_center: Vector2i = response.get("center", Vector2i.ZERO) + return echoed_center == _center + + +## Extent to hold for downstream consumers — echoed extent for a fixed rung, +## the CANVAS's own width/height for Global (whose wire extent echo is a +## fixed (0,0) sentinel, per the class doc). +static func _echoed_extent(canvas: Variant, rung: String, echoed: Vector2i) -> Vector2i: + if rung == "Global" and canvas is Dictionary: + var d: Dictionary = canvas + return Vector2i(int(d.get("width", 0)), int(d.get("height", 0))) + return echoed + + +func _retry_if_pending() -> void: + if not _pending: + return + if _retries < MAX_RETRIES: + _retries += 1 + _schedule_retry() + else: + _pending = false + + +static func _retry_delay_for(retry_count: int) -> float: + var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0))) + return minf(base, MAX_RETRY_DELAY) + + +func _schedule_retry() -> void: + var delay: float = _retry_delay_for(_retries) + var timer := get_tree().create_timer(delay) + timer.timeout.connect( + func() -> void: + if _pending: + SimBridge.request_step_canvas(_body_id, _rung, _center, _extent, _min_wl_m) + ) + + +func is_pending() -> bool: + return _pending + + +func get_held_extent() -> Vector2i: + return _held_extent + + +func get_cache() -> Variant: + return _cache diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd new file mode 100644 index 000000000..937a91091 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd @@ -0,0 +1,146 @@ +class_name StepCanvasTerrainLayer +extends Node2D + +## The RTT terrain layer (T-1182, D-255(a)/(b)/(e)) — one ImageTexture per +## held step canvas, built server-side-derived / client-colorized, drawn +## texel-exact at the rung's own display ratio. This is the PROMOTION Stig's +## round-1 doc names: "_rebuild_texture_if_needed/_build_tile_texture in +## atlas_window_overlay.gd generalized from 'per-tile mosaic composite' to +## 'the one and only terrain path'" — same Image/ImageTexture/set_pixel +## pipeline, now universal across all six D-255(a) rungs instead of being +## split across a single-window path and a tile-mosaic path. +## +## **texture.update() reuse on step-cross** (c1/⑤ ruling: "prefer +## texture.update() reuse over fresh create_from_image() per step... not for +## raw speed... but because it avoids per-step Texture object churn on the +## RenderingServer side"): rebuild_from_canvas() reuses `_texture` via +## `.update()` whenever the new canvas is the SAME pixel size as the held +## one (the common case — every fixed rung requests the same viewport-fit +## extent repeatedly), and only calls `ImageTexture.create_from_image()` when +## the size actually changes (a rung crossing to a differently-sized canvas, +## or the very first canvas). +## +## **NEAREST/LINEAR per rung** (_filter_for_rung(), T-1161's surviving +## ruling, ticket SURVIVING SURFACES: "_filter_for_granularity_v2... is +## wired"): Global/Region sample NEAREST (coarse orbital-scale data — +## bilinear blending between real derived samples reads as smoothing-over- +## absence); District through Chunk sample LINEAR (cell density earns the +## blend). Renamed for the D-255(a) rung vocabulary but the POLICY is +## unchanged from the retired atlas_window_overlay.gd's own ruling. +## +## **Determinism boundary (D-255(e)):** this node draws EXACTLY the cells +## the server supplied, at the display ratio's texture-to-viewport resize +## (the one sanctioned display-time scale) — it never invents a sample +## between two server cells, never smooths across a canvas edge. The GPU's +## own LINEAR filter, where selected, blends between ADJACENT REAL SAMPLES +## already present in the texture — this is presentation resampling of a +## closed input set, not invention of a new one, matching D-255(e)'s own +## "texture-to-viewport resize" exemption. + +const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd") +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") + +var _texture: ImageTexture = null +var _texture_size: Vector2i = Vector2i.ZERO +var _canvas_ref: Variant = null # reference-identity rebuild-only-on-change guard +var _active_toggle: String = "" +var _footprint_px: Vector2 = Vector2.ZERO +var _held_rung: String = StepCanvasTransport.RUNG_DISTRICT + + +## Rebuild (or reuse) the held texture from a decoded StepCanvasResponse's +## `canvas` Dictionary (step_canvas_protocol.gd's shape: width/height + +## PackedByteArray PNG planes + raw temp_dc/settlement_id arrays). No-op if +## `canvas` is the SAME object (reference identity, is_same()) and the +## active toggle hasn't changed — matches atlas_window_overlay.gd's own +## "REBUILT only when its inputs change" discipline, now the terrain layer's +## own hold contract for the "hold-fetch-swap, not blend-fetch-swap" +## step-cross sequence (round-1 §2): the OLD texture keeps drawing (this +## function simply isn't called) while a new canvas is in flight. +func rebuild_from_canvas(canvas: Dictionary, rung: String, active_toggle: String) -> void: + if is_same(_canvas_ref, canvas) and _active_toggle == active_toggle and _texture != null: + return + var width: int = int(canvas.get("width", 0)) + var height: int = int(canvas.get("height", 0)) + if width <= 0 or height <= 0: + return + + var planes := _decode_planes(canvas, width, height) + var img := Image.create(width, height, false, Image.FORMAT_RGBA8) + for row in range(height): + for col in range(width): + img.set_pixel(col, row, StepCanvasColorize.cell_color(planes, col, row, active_toggle)) + + var new_size := Vector2i(width, height) + if _texture != null and _texture_size == new_size: + _texture.update(img) + else: + _texture = ImageTexture.create_from_image(img) + _texture_size = new_size + + _canvas_ref = canvas + _active_toggle = active_toggle + _held_rung = rung + _footprint_px = StepCanvasTransport.canvas_footprint_px(rung, new_size) + texture_filter = _filter_for_rung(rung) + queue_redraw() + + +## Decode the four L8-plane PackedByteArrays (via Image.load_png_from_buffer, +## per step_canvas_protocol.gd's own PNG-per-field wire note) plus the two +## raw arrays into one CellPlanes bundle, once per rebuild. +func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasColorize.CellPlanes: + var planes := StepCanvasColorize.CellPlanes.new() + planes.width = width + planes.height = height + planes.morphology = _decode_l8_plane(canvas.get("morphology")) + planes.elev_q = _decode_l8_plane(canvas.get("elev_q")) + planes.moisture_q = _decode_l8_plane(canvas.get("moisture_q")) + planes.vegetation = _decode_l8_plane(canvas.get("vegetation")) + planes.glaciation = _decode_l8_plane(canvas.get("glaciation")) + planes.temp_dc = canvas.get("temp_dc", []) + return planes + + +## PackedByteArray PNG bytes -> Image, or null on a decode failure/empty +## input (a malformed/missing plane draws as the composite's own fallback +## color for that cell — StepCanvasColorize's _l8_value() treats a null +## plane as 0, never crashes). +static func _decode_l8_plane(field: Variant) -> Variant: + if not field is PackedByteArray or (field as PackedByteArray).is_empty(): + return null + var img := Image.new() + var err: int = img.load_png_from_buffer(field) + if err != OK: + push_warning("StepCanvasTerrainLayer: PNG plane decode failed (err %d)" % err) + return null + return img + + +## T-1161's surviving per-rung filter ruling (ticket: "_filter_for_ +## granularity_v2... is wired"), repointed at the D-255(a) rung vocabulary. +static func _filter_for_rung(rung: String) -> CanvasItem.TextureFilter: + if StepCanvasTransport.is_orbital_rung(rung): + return CanvasItem.TEXTURE_FILTER_NEAREST + return CanvasItem.TEXTURE_FILTER_LINEAR + + +func _draw() -> void: + if _texture == null or _footprint_px == Vector2.ZERO: + return + draw_texture_rect(_texture, Rect2(Vector2.ZERO, _footprint_px), false) + + +## The currently-held canvas's on-screen footprint (px) — the annotation +## layer's world->screen projection targets this SAME rect (D-255(a): +## both layers agree on placement by construction, not reconciliation). +func get_footprint_px() -> Vector2: + return _footprint_px + + +func get_held_rung() -> String: + return _held_rung + + +func has_texture() -> bool: + return _texture != null diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd new file mode 100644 index 000000000..1e8b21403 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd @@ -0,0 +1,243 @@ +extends RefCounted + +## Pure stepped-transport geometry for the D-255(a) six-rung Atlas ladder +## (T-1182). This is the FULL replacement for the retired +## `_canvas.scale`/`_view_zoom` continuous-zoom model — there is no float +## zoom here at all, only a discrete rung INDEX (0-5) and integer world-metre +## step math. Every function is a pure function of its arguments (no Node/ +## scene-tree dependency), matching this cluster's existing +## atlas_window_geometry.gd discipline, so the transport state machine is +## directly unit-testable. +## +## D-255(a) ladder, rung index -> D-243 gridunit spacing (metres). Rung 0 +## (Global) has no single spacing float in the same sense as the fixed rungs +## (its gridunit is "one region" but its CANVAS EXTENT floats per body) — see +## RUNG_SPACING_M's own doc. +const RUNG_GLOBAL: String = "Global" +const RUNG_REGION: String = "Region" +const RUNG_DISTRICT: String = "District" +const RUNG_QUARTER: String = "Quarter" +const RUNG_BLOCK: String = "Block" +const RUNG_CHUNK: String = "Chunk" + +## Coarsest (0) to deepest (5) — the SIX levels D-255(a) names, index IS the +## rung. scroll_step()/rung_at_index() below are the only places this table +## is walked; every other consumer works in terms of the rung NAME (matching +## the wire's own bare-string vocabulary, never a raw integer sent over the +## bridge). +const RUNG_LADDER: Array = [ + RUNG_GLOBAL, RUNG_REGION, RUNG_DISTRICT, RUNG_QUARTER, RUNG_BLOCK, RUNG_CHUNK +] + +## D-243 gridunit spacing in metres for every FIXED rung — mirrors +## server/src/atlas/scale.rs's own constants exactly (REGION_M/DISTRICT_M/ +## QUARTER_M/BLOCK_M/CHUNK_M), so the client's rung table can never silently +## drift from the wire contract it's choosing between. Global has no single +## spacing value in the fixed sense (D-255(a): "a Global gridunit and a +## Region gridunit are both 'one region' wide" — step_canvas.rs's own +## StepCanvasRung::spacing_m() returns REGION_M for Global too, "a +## harmless-but-correct value... so ordering/comparison call sites... get a +## sane, documented number rather than 0 or a panic") — mirrored here for the +## same reason. +const RUNG_SPACING_M: Dictionary = { + RUNG_GLOBAL: 204_800.0, + RUNG_REGION: 204_800.0, + RUNG_DISTRICT: 2_048.0, + RUNG_QUARTER: 512.0, + RUNG_BLOCK: 128.0, + RUNG_CHUNK: 64.0, +} + +## D-255(a) display-ratio band: screen-px per gridunit. 1x1 at the deep/mid +## rungs (Stig round-2 §(d): "crisper display costs nothing extra... no cost +## reason to fall back to 5x5 at the steps where fidelity matters most"), the +## ~5x5 fallback earning its keep only at the shallow/orbital end where +## canvas EXTENT (not spacing) is what's growing. This is PURELY a +## presentation parameter (D-255(a)/D-243 gridunit amendment: "a free, +## client-side, viewport-dependent parameter, kept architecturally separate +## from gridunit spacing") — it never touches a cache key or a wire request, +## only how many screen px one already-fetched gridunit occupies. +const DISPLAY_RATIO_DEEP: float = 1.0 +const DISPLAY_RATIO_SHALLOW: float = 5.0 + +## Global/Region read at the shallow ratio (orbital-scale canvases — extent +## is what dominates, not per-cell fidelity); District..Chunk read at 1x1 +## (D-255(a): "the deep, ground-level steps where the player is closest to +## visible detail"). Chunk's own "1 screen px per 64 m gridunit, no +## magnification margin" bottom-out rule (D-255(a)) is exactly DISPLAY_RATIO_DEEP. +const DISPLAY_RATIO_BY_RUNG: Dictionary = { + RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW, + RUNG_REGION: DISPLAY_RATIO_SHALLOW, + RUNG_DISTRICT: DISPLAY_RATIO_DEEP, + RUNG_QUARTER: DISPLAY_RATIO_DEEP, + RUNG_BLOCK: DISPLAY_RATIO_DEEP, + RUNG_CHUNK: DISPLAY_RATIO_DEEP, +} + +## Fixed-rung canvas pixel budget (D-255(a)/(b): "the fixed 3840x2160 px +## budget... every step except Global") — mirrors +## server/src/atlas/step_canvas.rs's STEP_CANVAS_MAX_EXTENT_AXIS/ +## STEP_CANVAS_MAX_EXTENT_CELLS ceiling. The client requests THIS extent +## (subject to viewport-fit shrinking, see viewport_fit_extent()) for every +## fixed rung; Global's extent is never read from this constant at all (the +## server derives it from the body's own region grid — the client sends +## SOME extent value per the wire's unconditional field, but it is IGNORED +## server-side, per step_canvas_protocol.gd's own doc). +const FIXED_CANVAS_MAX_AXIS: int = 3_840 + + +## The rung name at ladder index `i`, clamped to the legal [0, 5] range — +## the one place RUNG_LADDER is indexed into, so a caller passing an +## out-of-range index (a scroll past either end) gets the nearest legal rung +## rather than an array-bounds error. +static func rung_at_index(index: int) -> String: + var clamped: int = clampi(index, 0, RUNG_LADDER.size() - 1) + return RUNG_LADDER[clamped] + + +## The ladder index for a rung name, or -1 if unrecognized (defensive — every +## real caller passes a RUNG_* constant, but an unrecognized wire echo must +## never silently alias to index 0/Global). +static func index_for_rung(rung: String) -> int: + return RUNG_LADDER.find(rung) + + +## Scroll one notch: `direction` > 0 descends (coarser -> finer, e.g. +## Region -> District), < 0 ascends (finer -> coarser). Clamped at both ends +## — scrolling past Chunk stays at Chunk, scrolling past Global stays at +## Global (the "full-zoom-out reset" is a SEPARATE explicit action, not a +## side effect of this function — see StepCanvasViewer's own reset-to-Global +## handling). +static func scroll_step(current_index: int, direction: int) -> int: + var delta: int = 1 if direction > 0 else (-1 if direction < 0 else 0) + return clampi(current_index + delta, 0, RUNG_LADDER.size() - 1) + + +static func spacing_for_rung(rung: String) -> float: + return float(RUNG_SPACING_M.get(rung, RUNG_SPACING_M[RUNG_DISTRICT])) + + +static func display_ratio_for_rung(rung: String) -> float: + return float(DISPLAY_RATIO_BY_RUNG.get(rung, DISPLAY_RATIO_DEEP)) + + +## True for the two rungs that ride derive_orbital_at_metres server-side +## (Global/Region, step_canvas.rs's own StepCanvasRung::uses_orbital_derive()) +## — mirrored here purely for READABILITY at call sites that branch on it +## (e.g. "does this rung's canvas ever carry courses" — Global/Region never +## do, matching invent_courses_for_canvas()'s own early return), not because +## the client makes any derivation decision itself (D-255(e): derivation +## stays server-side, full stop). +static func is_orbital_rung(rung: String) -> bool: + return rung == RUNG_GLOBAL or rung == RUNG_REGION + + +## World-metre HALF-EXTENT (radius from center to edge) a fixed-rung canvas +## of `extent_cells` x `extent_cells` covers, given the rung's own gridunit +## spacing — the request-sizing half of the cursor-anchored step math. +static func half_extent_m(rung: String, extent_cells: int) -> float: + return float(extent_cells) * 0.5 * spacing_for_rung(rung) + + +## Cursor-anchored step center (D-255(a)/(e), the workshop's own "the center +## the new canvas should be requested around is the world point under the +## cursor" rule): given the CURRENT step's world transform (center in world +## metres, spacing, canvas cell extent, display ratio) and a cursor position +## in canvas-local px (canvas-local = the terrain layer's own local space, +## origin at the canvas's top-left, BEFORE any screen offset), returns the +## world-metre point under the cursor. This is the point the NEXT step's +## request should center on — computed once per scroll notch, not per frame. +static func canvas_local_to_world_m( + canvas_local: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i +) -> Vector2: + var spacing: float = spacing_for_rung(rung) + var ratio: float = display_ratio_for_rung(rung) + var px_per_gridunit: float = maxf(ratio, 0.0001) + var half_w_m: float = float(extent_cells.x) * 0.5 * spacing + var half_h_m: float = float(extent_cells.y) * 0.5 * spacing + var local_m: Vector2 = canvas_local / px_per_gridunit * spacing + return Vector2( + world_center.x - half_w_m + local_m.x, world_center.y - half_h_m + local_m.y + ) + + +## Inverse of canvas_local_to_world_m() — world metres -> this step's +## canvas-local px (the terrain/annotation layers' shared world->screen +## projection, D-255(e): "texture-to-viewport resize" is the one sanctioned +## display-time scale, this is that same linear map applied to a point +## rather than a texture). +static func world_m_to_canvas_local( + world_m: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i +) -> Vector2: + var spacing: float = spacing_for_rung(rung) + var ratio: float = display_ratio_for_rung(rung) + var px_per_gridunit: float = maxf(ratio, 0.0001) + var half_w_m: float = float(extent_cells.x) * 0.5 * spacing + var half_h_m: float = float(extent_cells.y) * 0.5 * spacing + var local_m: Vector2 = Vector2(world_m.x - world_center.x + half_w_m, world_m.y - world_center.y + half_h_m) + return local_m / spacing * px_per_gridunit + + +## The on-screen footprint (px) of a fixed-rung canvas at its display ratio — +## `extent_cells * display_ratio`. The terrain layer draws its texture into +## exactly this Rect2 size; the annotation layer's world->screen projection +## targets the same footprint so both layers agree on where a world point +## lands, by construction (no separate reconciliation step). +static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2: + var ratio: float = display_ratio_for_rung(rung) + return Vector2(extent_cells) * ratio + + +## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped +## at FIXED_CANVAS_MAX_AXIS per axis (D-255(a)/(b)'s own budget) and at the +## rung's own display ratio — this is the CLIENT's half of "viewport-sized +## canvas" (Dudley's server-side policy is the derivation-cost/D-226(d) +## argument; this is the client not requesting more than it can display). +## `viewport_px` is the on-screen area to fill; the request extent in +## GRIDUNITS is `viewport_px / display_ratio`, clamped to +## [1, FIXED_CANVAS_MAX_AXIS] per axis — the server clamps independently too +## (never trust the echo to equal the request), see StepCanvasViewer's own +## "read the echoed extent" discipline. +static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i: + var ratio: float = maxf(display_ratio_for_rung(rung), 0.0001) + var cells: Vector2 = viewport_px / ratio + var w: int = clampi(int(ceil(cells.x)), 1, FIXED_CANVAS_MAX_AXIS) + var h: int = clampi(int(ceil(cells.y)), 1, FIXED_CANVAS_MAX_AXIS) + return Vector2i(w, h) + + +## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode +## (not an InputMap action) — same rationale the retired +## atlas_window_geometry.gd's own held_pan_direction() documented: this +## project's global InputMap already binds W/S/A/D to gameplay movement +## actions, so reading the raw physical keycode keeps this screen's pan +## input independent of whatever gameplay's own action bindings are. Returns +## a raw (non-normalized) direction — the caller normalizes once after +## adding the edge-scroll contribution. +static func held_pan_direction() -> Vector2: + var direction := Vector2.ZERO + if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP): + direction.y -= 1.0 + if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN): + direction.y += 1.0 + if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT): + direction.x -= 1.0 + if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT): + direction.x += 1.0 + return direction + + +## Snap a world-metre point to the rung's own gridunit grid — the request +## `center` a fixed-rung StepCanvasRequest sends should land on a grid line +## so repeated requests at "the same spot" produce the identical cache key +## (D-227: a canvas for a fixed seed never changes, so exact-repeat cache +## hits are the common case worth protecting). Global ignores center +## entirely server-side (step_canvas_protocol.gd's own doc) so snapping is a +## harmless no-op there. +static func snap_to_gridunit(world_m: Vector2, rung: String) -> Vector2i: + var spacing: float = spacing_for_rung(rung) + if spacing <= 0.0: + return Vector2i(int(round(world_m.x)), int(round(world_m.y))) + return Vector2i( + int(round(world_m.x / spacing)) * int(spacing), int(round(world_m.y / spacing)) * int(spacing) + ) 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 new file mode 100644 index 000000000..f4a87cdc8 --- /dev/null +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -0,0 +1,523 @@ +class_name StepCanvasViewer +extends Control + +## The stepped Atlas ladder viewer (T-1182, D-255) — replaces +## AtlasWindowViewer/the `_canvas.scale` continuous-zoom model wholesale. +## "Global opener is rung 0, one viewer one path" (D-255(a) design premise, +## carried verbatim from the ticket): there is no separate orbital-mosaic +## viewer and windowed-drill-down viewer — every rung, including the Global +## body-surface opener, is served by this ONE Control through the SAME +## StepCanvasRequest/Response tagged envelope (T-1181). +## +## Structure (Stig round-1 §1, "naturally three pieces again"): +## - THIS Control: input/pan, rung-transport orchestration, chrome. +## - StepCanvasTerrainLayer (Node2D child of `_canvas`): RTT terrain, +## texel-exact, drawn at the rung's display ratio. +## - StepCanvasAnnotationLayer (Node2D child of `_canvas`, drawn AFTER the +## terrain layer): unscaled screen-space courses/settlement markers. +## Both layers live under ONE `_canvas` Node2D whose `.position` is the pan +## offset ONLY — there is no `.scale` write anywhere in this file (the whole +## point of the retirement: "there is no more zoom-scaled canvas"). +## +## Stepped transport (D-255(a)): a discrete rung INDEX (0-5, +## StepCanvasTransport.RUNG_LADDER), never a float zoom. Mouse wheel scrolls +## one rung notch per detent, cursor-anchored (the world point under the +## cursor becomes the next step's request center — Stig round-1 §2). +## Edge-scroll/WASD pan within a held rung; panning past the held canvas's +## own edge re-requests the SAME rung at a new center (mirrors the retired +## viewer's own pan-edge refetch, just against the new wire). A hard +## zoom-out past rung 0 resets to the Global frame (D-255(a)'s "hard +## full-zoom-out reset"). +## +## Hold-fetch-swap (Stig round-1 §2, the shipped baseline — morph/tween is +## an optional cosmetic follow-on, NOT built here per the ticket): on a +## scroll-step, the CURRENT step's texture stays displayed, unscaled, while +## the new step's request is in flight (StepCanvasTerrainLayer simply isn't +## rebuilt until the new canvas arrives — "hold" is the ABSENCE of a +## premature rebuild, not a separate code path). + +signal back_pressed + +const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd") +const StepCanvasTerrainLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd") +const StepCanvasAnnotationLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd") +const StepCanvasRequest := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_request.gd") + +const PANEL_MARGIN: float = 16.0 +const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 + +const PAN_SPEED_PX_S: float = 220.0 +const EDGE_SCROLL_MARGIN_PX: float = 24.0 + +const COLOR_BG: Color = Color("#0d1117") +const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55) +const COLOR_PENDING_WASH: Color = Color(0.20, 0.24, 0.30, 0.12) +const COLOR_DERIVING_LABEL: Color = Color("#667788") + +const OVERLAY_DEFS: Array = [ + { + "id": "gen_dw_temp", + "label": "TMP", + "group": "toggle", + "tooltip": "Temperature — region-ramp colorizer." + }, + { + "id": "gen_dw_moisture", + "label": "MST", + "group": "toggle", + "tooltip": "Moisture — dry-to-wet ramp." + }, + { + "id": "gen_dw_veg", + "label": "VEG", + "group": "toggle", + "tooltip": "Vegetation — green-family ramp. Marine reads transparent." + }, +] + +# ── Context (set by enter()) ────────────────────────────────────────────── +var _body: Dictionary = {} +var _system: Dictionary = {} +var _implant_theme = null + +# ── Rung transport state ────────────────────────────────────────────────── +var _rung_index: int = 0 # 0 == Global, the entry rung (D-255(a)) +var _world_center: Vector2 = Vector2.ZERO +var _held_rung: String = StepCanvasTransport.RUNG_GLOBAL +var _held_extent: Vector2i = Vector2i.ZERO + +# ── Pan state (position only — NO scale/zoom field anywhere) ───────────── +var _view_offset: Vector2 = Vector2.ZERO +var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0) +var _app_has_focus: bool = true + +# ── Overlay visibility ───────────────────────────────────────────────────── +var _overlay_visibility: Dictionary = {} + +# ── Child nodes ──────────────────────────────────────────────────────────── +var _canvas: Node2D = null +var _terrain_layer: StepCanvasTerrainLayer = null +var _annotation_layer: StepCanvasAnnotationLayer = null +var _screen_header: ImplantHeader = null +var _overlay_bar = null +var _legend_panel = null +var _request = null # StepCanvasRequest + + +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: + _overlay_visibility[def["id"]] = false + + _canvas = Node2D.new() + _canvas.name = "StepCanvas" + add_child(_canvas) + + _terrain_layer = StepCanvasTerrainLayer.new() + _terrain_layer.name = "TerrainLayer" + _canvas.add_child(_terrain_layer) + + _annotation_layer = StepCanvasAnnotationLayer.new() + _annotation_layer.name = "AnnotationLayer" + _canvas.add_child(_annotation_layer) + + _request = StepCanvasRequest.new(self) + _request.name = "Request" + add_child(_request) + _request.canvas_ready.connect(_on_canvas_ready) + + _build_screen_header() + _build_overlay_bar() + _build_legend_panel() + + SimBridge.step_canvas_received.connect(_on_step_canvas_received) + + +func _exit_tree() -> void: + if SimBridge.step_canvas_received.is_connected(_on_step_canvas_received): + SimBridge.step_canvas_received.disconnect(_on_step_canvas_received) + + +## Enter the ladder at the Global opener (rung 0) — the sole entry point +## (D-255(a): "Global opener is rung 0, one viewer one path"). Replaces both +## the retired enter()/enter_orbital() split — there is no District-rung +## direct-entry variant anymore, since descent from Global is a continuous +## scroll gesture, not a nav-stack choice. +func enter(body: Dictionary, system: Dictionary) -> void: + _body = body + _system = system + _rung_index = 0 + _world_center = Vector2.ZERO + _held_rung = StepCanvasTransport.RUNG_GLOBAL + _held_extent = Vector2i.ZERO + _view_offset = Vector2.ZERO + _request.reset() + _annotation_layer.clear_frame() + _fire_request() + _refresh_screen_header() + if _legend_panel: + _legend_panel.refresh() + grab_focus() + queue_redraw() + + +func leave() -> void: + pass + + +func get_body_id() -> String: + return _dict_str(_body, "body_id", "") + + +func get_held_rung() -> String: + return _held_rung + + +func is_overlay_visible(overlay_id: String) -> bool: + return bool(_overlay_visibility.get(overlay_id, false)) + + +func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: + if not _overlay_visibility.has(overlay_id): + push_warning("StepCanvasViewer: unknown overlay id '%s'" % overlay_id) + return + _overlay_visibility[overlay_id] = visible_state + _rebuild_terrain_texture() + if _legend_panel: + _legend_panel.refresh() + + +func get_overlay_defs() -> Array: + return OVERLAY_DEFS + + +# ============================================================================= +# Request lifecycle +# ============================================================================= + + +## Fire the current step's request — cursor-anchored center + viewport-fit +## extent for a fixed rung; Global sends the same fields unconditionally +## (ignored server-side, per step_canvas_protocol.gd's own doc). +func _fire_request() -> void: + var extent: Vector2i = _request_extent() + var center: Vector2i = StepCanvasTransport.snap_to_gridunit(_world_center, _held_rung) + _request.request_now(get_body_id(), _held_rung, center, extent) + + +func _request_extent() -> Vector2i: + var viewport: Vector2 = get_rect().size + if viewport == Vector2.ZERO: + viewport = Vector2(1280.0, 720.0) + return StepCanvasTransport.viewport_fit_extent(viewport, _held_rung) + + +func _on_step_canvas_received(response: Dictionary) -> void: + _request.on_response(response) + + +## A canvas is ready (cache hit OR a fresh Ready response) — adopt it. This +## is the ONE place the terrain/annotation layers are told to rebuild; until +## this fires, the layers keep showing whatever they already held (the +## hold-fetch-swap contract — Stig round-1 §2). +func _on_canvas_ready(canvas: Dictionary) -> void: + _held_extent = _request.get_held_extent() + _rebuild_terrain_texture(canvas) + _annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent) + _refresh_screen_header() + if _legend_panel: + _legend_panel.refresh() + queue_redraw() + + +func _rebuild_terrain_texture(canvas: Variant = null) -> void: + var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref + if not c is Dictionary: + return + _terrain_layer.rebuild_from_canvas(c, _held_rung, _active_toggle_overlay()) + + +func _active_toggle_overlay() -> String: + if is_overlay_visible("gen_dw_temp"): + return "gen_dw_temp" + if is_overlay_visible("gen_dw_moisture"): + return "gen_dw_moisture" + if is_overlay_visible("gen_dw_veg"): + return "gen_dw_veg" + return "" + + +# ============================================================================= +# Rung transport — cursor-anchored scroll step, edge-crossing pan re-request +# ============================================================================= + + +## One scroll-wheel notch: `direction` > 0 descends (coarser -> finer), +## < 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. +func _scroll_rung(direction: int, cursor_local: Vector2) -> void: + 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 + _view_offset = Vector2.ZERO + _fire_request() + _refresh_screen_header() + queue_redraw() + + +## 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: + return + _rung_index = 0 + _held_rung = StepCanvasTransport.RUNG_GLOBAL + _world_center = Vector2.ZERO + _view_offset = Vector2.ZERO + _fire_request() + _refresh_screen_header() + queue_redraw() + + +## Pan-edge re-request: once the view has panned far enough that the held +## canvas's own edge would show, re-request the SAME rung at a new center — +## mirrors the retired viewer's own _maybe_refloat_window(), against the new +## wire's request shape. Global never re-requests on pan (its canvas is the +## whole body, D-255(a) — no edge to cross). +func _maybe_refloat() -> void: + if _held_rung == StepCanvasTransport.RUNG_GLOBAL: + return + var footprint: Vector2 = _terrain_layer.get_footprint_px() + if footprint == Vector2.ZERO: + return + var half: Vector2 = footprint * 0.5 + # view_offset is the SCREEN position of canvas-local (0,0) — the canvas's + # center in canvas-local space is `half`. Once panning has moved that + # point more than half the footprint away from screen-center, the edge + # is at or past the viewport's own center — time to re-float. + var screen_center: Vector2 = get_rect().size * 0.5 + var canvas_center_screen: Vector2 = _view_offset + half + var drift: Vector2 = canvas_center_screen - screen_center + if absf(drift.x) < half.x * 0.5 and absf(drift.y) < half.y * 0.5: + return + var new_center_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m( + screen_center - _view_offset, _world_center, _held_rung, _held_extent + ) + _world_center = new_center_world + _view_offset = Vector2.ZERO + _fire_request() + + +# ============================================================================= +# Drawing +# ============================================================================= + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG) + if not _terrain_layer.has_texture(): + _draw_border_fade() + elif _request.is_pending(): + _draw_pending_wash() + + +func _draw_border_fade() -> void: + draw_rect(Rect2(_view_offset, get_rect().size), COLOR_BORDER_FADE) + _draw_deriving_label() + + +func _draw_pending_wash() -> void: + draw_rect(Rect2(_view_offset, get_rect().size), COLOR_PENDING_WASH) + + +func _draw_deriving_label() -> void: + var label := "DERIVING TERRAIN…" + var font := ThemeDB.fallback_font + var font_size := 20 + var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + var viewport: Vector2 = get_rect().size + var center: Vector2 = viewport * 0.5 + var baseline: Vector2 = center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5) + draw_string( + font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL + ) + + +func _apply_transform() -> void: + _canvas.position = _view_offset + queue_redraw() + + +# ============================================================================= +# Chrome +# ============================================================================= + + +func _build_screen_header() -> void: + _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 name_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) + var spacing_km: float = StepCanvasTransport.spacing_for_rung(_held_rung) / 1000.0 + var title := "ATLAS — %s" % name_label.to_upper() + var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km] + _screen_header.set_content(title, subtitle) + + +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) + 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) + + +func _build_legend_panel() -> void: + var LegendScript := load("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd") + _legend_panel = LegendScript.new(self) + _legend_panel.name = "Legend" + _legend_panel.theme_resource = _implant_theme + add_child(_legend_panel) + _legend_panel.refresh() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + if _overlay_bar: + _position_overlay_bar() + if _legend_panel: + _legend_panel.reposition() + elif what == NOTIFICATION_APPLICATION_FOCUS_OUT: + _app_has_focus = false + elif what == NOTIFICATION_APPLICATION_FOCUS_IN: + _app_has_focus = true + + +# ============================================================================= +# Input +# ============================================================================= + + +func _is_over_ui(_pos: Vector2) -> bool: + 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 ( + 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_WHEEL_UP and mb.pressed: + _scroll_rung(1, mb.position) + elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + _scroll_rung(-1, mb.position) + elif event is InputEventMouseMotion: + _last_mouse_pos = (event as InputEventMouseMotion).position + + +func _handle_key(event: InputEventKey) -> void: + if event.keycode == KEY_ESCAPE: + back_pressed.emit() + + +func _process(delta: float) -> void: + if not visible: + return + var direction: Vector2 = StepCanvasTransport.held_pan_direction() + if _is_cursor_edge_scrolling(): + direction += _edge_scroll_direction() + if direction == Vector2.ZERO: + return + _apply_pan_delta(direction, delta) + + +func _apply_pan_delta(direction: Vector2, delta: float) -> void: + var normalized: Vector2 = direction.normalized() + _view_offset -= normalized * PAN_SPEED_PX_S * delta + _apply_transform() + _maybe_refloat() + + +func _is_cursor_edge_scrolling() -> bool: + if not _app_has_focus or _is_over_ui(_last_mouse_pos): + return false + var viewport: Vector2 = size + if viewport.x <= 0.0 or viewport.y <= 0.0: + return false + return ( + _last_mouse_pos.x >= 0.0 + and _last_mouse_pos.y >= 0.0 + and _last_mouse_pos.x <= viewport.x + and _last_mouse_pos.y <= viewport.y + and ( + _last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX + or _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX + or _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX + or _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX + ) + ) + + +func _edge_scroll_direction() -> Vector2: + var viewport: Vector2 = size + var direction := Vector2.ZERO + if _last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX: + direction.x -= 1.0 + elif _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX: + direction.x += 1.0 + if _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX: + direction.y -= 1.0 + elif _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX: + direction.y += 1.0 + return direction + + +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