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