feat(ui): step-canvas map component — RTT terrain + stepped zoom (D-255, T-1182)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: <bare string, one of "Global"|"Region"|"District"|"Quarter"|
|
||||
## "Block"|"Chunk">, 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<u8>` with NO
|
||||
## serde_bytes annotation anywhere in this codebase (confirmed: grep for
|
||||
## serde_bytes across server/src returns nothing), so serde's blanket
|
||||
## Vec<T> 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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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: `, <digits[.digits]>)` 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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 <Node> 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)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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<u8> 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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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<u8>, 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
+21
-42
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user