Merge remote-tracking branch 'origin/zoom-ladder-continuous'
This commit is contained in:
@@ -435,20 +435,32 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
|
||||
##
|
||||
## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the
|
||||
## zoom-ladder quarter rung — district (0/omitted) stays the default for
|
||||
## every caller in this codebase today; requesting quarter granularity is
|
||||
## T-1153's job, not wired here.
|
||||
## every caller in this codebase today.
|
||||
##
|
||||
## window_granularity_v2 (T-1152/T-1153): the R5-redesigned string-tag
|
||||
## granularity ("Quarter"/"District"/"Region") — the ONLY way to request the
|
||||
## coarser-than-district Region rung the legacy u32 field cannot express.
|
||||
## Empty string (omitted) is the default for every caller that doesn't pass
|
||||
## it, byte-compatible with every pre-T-1152 request.
|
||||
func request_atlas_layers(
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
window_min_wl_m: int = 0,
|
||||
window_granularity_v2: String = ""
|
||||
) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
|
||||
body_id,
|
||||
up_to,
|
||||
window_center,
|
||||
window_n,
|
||||
window_granularity,
|
||||
window_min_wl_m,
|
||||
window_granularity_v2
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
|
||||
@@ -40,6 +40,22 @@ class_name AtlasMapProtocol
|
||||
## it possible to ask, byte-compatible with every existing caller that
|
||||
## doesn't pass them.
|
||||
##
|
||||
## `window_granularity_v2` (T-1152, R5 redesign — see
|
||||
## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the ONLY way to
|
||||
## express a coarser-than-district rung (`"Region"`) the legacy `u32` field
|
||||
## cannot encode. A plain STRING variant tag ("Quarter" | "District" |
|
||||
## "Region"), matching `RoadNodeKind`'s existing wire precedent on this same
|
||||
## carrier (a bare `#[derive(Serialize, Deserialize)]` enum with no
|
||||
## `#[serde(rename_all)]` — rmp_serde encodes the Rust variant NAME verbatim,
|
||||
## not an integer discriminant). OMITTED (not sent as "") when
|
||||
## `window_granularity_v2` is the empty string — `#[serde(default)]` on the
|
||||
## Rust side decodes absence as `None`, falling back to the legacy `u32`
|
||||
## field's `resolve_window_granularity_v2()` precedence rule (that field wins
|
||||
## over the legacy one whenever present — see that Rust doc for the full
|
||||
## precedence contract). Every pre-T-1152 caller (and every T-1150 caller that
|
||||
## only ever sends `window_granularity`) omits this field entirely and stays
|
||||
## byte-compatible.
|
||||
##
|
||||
## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m`
|
||||
## is sent HERE as a raw, unquantized value — this codec does NOT snap it to
|
||||
## the design doc §5 fixed band set. The SERVER is the one place quantization
|
||||
@@ -56,7 +72,8 @@ static func encode_atlas_layer_request(
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
window_min_wl_m: int = 0,
|
||||
window_granularity_v2: String = ""
|
||||
) -> PackedByteArray:
|
||||
var msg := {"body_id": body_id, "up_to": up_to}
|
||||
if window_center != null:
|
||||
@@ -67,6 +84,8 @@ static func encode_atlas_layer_request(
|
||||
msg["window_granularity"] = window_granularity
|
||||
if window_min_wl_m != 0:
|
||||
msg["window_min_wl_m"] = window_min_wl_m
|
||||
if not window_granularity_v2.is_empty():
|
||||
msg["window_granularity_v2"] = window_granularity_v2
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
|
||||
@@ -91,7 +110,9 @@ static func encode_atlas_layer_request(
|
||||
## wire decodes to GDScript `null` exactly like every other Option field here.
|
||||
## The response's `center`/`n` echo (inside the layer dict itself) is the
|
||||
## client's race-condition/staleness guard (§2) — read by the window cache,
|
||||
## not unwrapped here.
|
||||
## not unwrapped here. `granularity_v2` (T-1152) rides inside the same dict,
|
||||
## a bare string variant tag ("Quarter"/"District"/"Region") — no separate
|
||||
## top-level unwrap needed, it passes through with everything else.
|
||||
## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/
|
||||
## "district_window" are the CONFIRMED wire contract — identical to
|
||||
## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names
|
||||
|
||||
@@ -782,16 +782,27 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
## Omitted callers (every whole-body-layer call site predating T-1138) are
|
||||
## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same
|
||||
## byte-compatibility contract, see atlas_map_protocol.gd.
|
||||
## window_granularity_v2 (T-1152): the R5-redesigned string-tag granularity
|
||||
## ("Quarter"/"District"/"Region") — the only way to request the Region rung.
|
||||
## Omitted (empty string) by every caller that doesn't pass it.
|
||||
static func encode_atlas_layer_request(
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
window_min_wl_m: int = 0,
|
||||
window_granularity_v2: String = ""
|
||||
) -> PackedByteArray:
|
||||
return _amp().encode_atlas_layer_request(
|
||||
_mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
|
||||
_mp(),
|
||||
body_id,
|
||||
up_to,
|
||||
window_center,
|
||||
window_n,
|
||||
window_granularity,
|
||||
window_min_wl_m,
|
||||
window_granularity_v2
|
||||
)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -240,6 +240,36 @@ func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void:
|
||||
assert_that(decoded.value.get("window_min_wl_m")).is_equal(512)
|
||||
|
||||
|
||||
## T-1152/T-1153: window_granularity_v2 is OMITTED (not sent as "") when at
|
||||
## its empty-string default — same byte-compatibility contract as
|
||||
## window_granularity/window_min_wl_m's own default-omission.
|
||||
func test_encode_atlas_layer_request_omits_granularity_v2_by_default() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
"GJ1c", "Topography", Vector2i(140, 260), 32
|
||||
)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_bool(decoded.value.has("window_granularity_v2")).is_false()
|
||||
|
||||
|
||||
## T-1152/T-1153: a Region-rung request carries window_granularity_v2 as the
|
||||
## bare string "Region" — the ONLY way to express the coarser-than-district
|
||||
## rung (WindowGranularity's Rust doc: "the ONLY way to actually request
|
||||
## Region is window_granularity_v2 = Some(WindowGranularity::Region)"), a
|
||||
## plain rmp_serde variant-name encoding matching RoadNodeKind's existing
|
||||
## wire precedent, NOT an integer discriminant.
|
||||
func test_encode_atlas_layer_request_carries_granularity_v2_region() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
"GJ1c", "Topography", Vector2i(0, 0), 6400, 0, 0, "Region"
|
||||
)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.value.get("window_granularity_v2")).is_equal("Region")
|
||||
# The legacy window_granularity field is independently omittable — a
|
||||
# Region request sends ONLY the v2 tag, never a legacy value pretending
|
||||
# to mean something for Region (WINDOW_GRANULARITY_REGION_KEY is a
|
||||
# key-space tag the SERVER echoes, never a legal wire INPUT).
|
||||
assert_bool(decoded.value.has("window_granularity")).is_false()
|
||||
|
||||
|
||||
## §2: district_window is a distinct payload (echoes center/n for the
|
||||
## client's staleness guard) but the codec passthrough is the same shape as
|
||||
## every sibling layer — raw.get(), no reshaping. Field types follow the
|
||||
@@ -265,6 +295,36 @@ func test_atlas_response_district_window_passthrough() -> void:
|
||||
assert_that((decoded as Dictionary).get("district_window")).is_equal(window)
|
||||
|
||||
|
||||
## T-1152/T-1153, design doc §6 encoding-continuity acceptance: a Region-rung
|
||||
## response passes through the EXACT SAME codec path as District/Quarter —
|
||||
## granularity_v2 is just another field in the same dict, no special-cased
|
||||
## decode branch for the coarser rung. This is the direct regression test for
|
||||
## "one colorizer family, no per-rung palettes": the wire contract itself
|
||||
## draws no distinction, so nothing downstream (the overlay's
|
||||
## cell_grid_side_for_window()/_cell_color()) needs a rung-specific decode
|
||||
## path either.
|
||||
func test_atlas_response_district_window_passthrough_region_rung() -> void:
|
||||
var window := {
|
||||
"center": [0, 0],
|
||||
"n": 6400,
|
||||
"granularity": 4294967295, # WINDOW_GRANULARITY_REGION_KEY (u32::MAX) — key-space tag, not a real multiplier
|
||||
"granularity_v2": "Region",
|
||||
"min_wl_m": 0,
|
||||
"morphology": PackedByteArray([8, 14, 0, 5]),
|
||||
"elev_q": PackedByteArray([40, 62, 5, 88]),
|
||||
"temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
var raw := {"body_id": "GJ1c", "status": "Ready", "district_window": window}
|
||||
var decoded: Variant = Protocol.atlas_response_from_raw(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
var district_window: Dictionary = (decoded as Dictionary).get("district_window")
|
||||
assert_that(district_window).is_equal(window)
|
||||
assert_str(str(district_window.get("granularity_v2"))).is_equal("Region")
|
||||
|
||||
|
||||
## A body with no window requested (or not yet derived — §1's background-queue
|
||||
## serving model: the completion may not have landed yet) must decode with
|
||||
## district_window absent -> null, same "layer hasn't produced yet" contract
|
||||
|
||||
@@ -371,16 +371,19 @@ func test_no_descend_signal_without_a_loaded_heightmap() -> void:
|
||||
assert_int(received.size()).is_equal(0)
|
||||
|
||||
|
||||
## RegionalScreen forwards AtlasViewer's district_descend_requested verbatim —
|
||||
## the nav-stack wiring atlas_app.gd depends on.
|
||||
func test_regional_screen_forwards_district_descend_requested() -> void:
|
||||
## T-1153 (D-226 T-1143-rulings amendment): RegionalScreen no longer wraps
|
||||
## AtlasViewer or forwards district_descend_requested — the "regional" nav
|
||||
## entry now opens the continuous zoom ladder (AtlasWindowViewer) directly at
|
||||
## the canonical orbital frame, retiring the click-through as the sole entry
|
||||
## (see regional_screen.gd's own doc). This regression-guards the NEW
|
||||
## wiring: entering "regional" reaches AtlasWindowViewer, not AtlasViewer.
|
||||
func test_regional_screen_wraps_atlas_window_viewer_not_atlas_viewer() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
var received: Array = []
|
||||
screen.district_descend_requested.connect(func(c: Vector2i) -> void: received.append(c))
|
||||
screen._viewer.district_descend_requested.emit(Vector2i(5, 7))
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_that(received[0]).is_equal(Vector2i(5, 7))
|
||||
assert_object(screen._viewer).override_failure_message(
|
||||
"RegionalScreen must wrap AtlasWindowViewer (the zoom ladder) since T-1153,"
|
||||
+ " not the retired AtlasViewer heightmap-texture display"
|
||||
).is_instanceof(AtlasWindowViewer)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -180,3 +180,72 @@ func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> v
|
||||
cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY)
|
||||
).is_equal(district_window)
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# granularity_v2 (T-1152/T-1153): the string-tag axis — the ONLY thing that
|
||||
# distinguishes Region from District/Quarter, since Region has no legal
|
||||
# legacy-int representation (WindowGranularity::legacy_u32() returns None for
|
||||
# Region — see the server's own doc). This is the SAME mandatory-aliasing
|
||||
# regression class as the granularity/min_wl_m tests above, extended to the
|
||||
# new axis.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## **MANDATORY aliasing regression (T-1152/T-1153):** a Region-rung key and a
|
||||
## District-rung key at the IDENTICAL (body_id, center, n, legacy
|
||||
## granularity, min_wl_m) must be DISTINCT cache keys — the legacy int slot
|
||||
## alone (both "District" and default-omitted callers pass
|
||||
## DISTRICT_GRANULARITY=1) cannot tell them apart; granularity_v2 is what
|
||||
## does.
|
||||
func test_make_key_distinguishes_granularity_v2_region_from_district() -> void:
|
||||
var k_district := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
|
||||
)
|
||||
var k_region := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region"
|
||||
)
|
||||
assert_str(k_district).is_not_equal(k_region)
|
||||
|
||||
|
||||
## Omitting granularity_v2 (every pre-T-1152 call site) must produce the SAME
|
||||
## key as passing the explicit "District" default — byte/string
|
||||
## compatibility, same contract as the legacy granularity/min_wl_m defaults.
|
||||
func test_omitted_granularity_v2_matches_explicit_district_default() -> void:
|
||||
var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32)
|
||||
var k_explicit := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
|
||||
)
|
||||
assert_str(k_omitted).is_equal(k_explicit)
|
||||
|
||||
|
||||
## End-to-end through put()/get_window(): a Region-rung window and a
|
||||
## District-rung window at the identical (body, center, n) must both be
|
||||
## independently retrievable — the exact scenario a full-zoom-out-then-back-in
|
||||
## at the SAME (center, n) would hit if a player oscillates across the
|
||||
## District/Region boundary.
|
||||
func test_region_and_district_windows_coexist_at_identical_body_center_n() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
var district_window := {"granularity_v2": "District", "id": "district"}
|
||||
var region_window := {"granularity_v2": "Region", "id": "region"}
|
||||
|
||||
cache.put(
|
||||
"GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0,
|
||||
"District"
|
||||
)
|
||||
cache.put(
|
||||
"GJ1c", Vector2i(10, 20), 32, region_window, AtlasWindowCache.DISTRICT_GRANULARITY, 0,
|
||||
"Region"
|
||||
)
|
||||
|
||||
assert_int(cache.size()).is_equal(2)
|
||||
assert_that(
|
||||
cache.get_window(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "District"
|
||||
)
|
||||
).is_equal(district_window)
|
||||
assert_that(
|
||||
cache.get_window(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0, "Region"
|
||||
)
|
||||
).is_equal(region_window)
|
||||
|
||||
@@ -265,3 +265,667 @@ func test_pole_wall_rows_half_matches_canonicalize_rows_half() -> void:
|
||||
Vector2i(0, rows_half), radius_km
|
||||
)
|
||||
assert_int(canonical.y).is_equal(rows_half)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: select_rung() — REDESIGNED (live round 3 finding) per-rung
|
||||
# single-window COVERAGE CEILING model, superseding the original
|
||||
# `2x`-visual-tolerance-only reading of design doc §5. Select the FINEST
|
||||
# rung whose own single-window coverage ceiling (MAX_COVERAGE_M) still
|
||||
# covers the current world extent: Quarter <= 32,768 m; District <=
|
||||
# 131,072 m; Region otherwise (including tiled coverage beyond its own
|
||||
# single-window ceiling, a viewer-level concern — see select_rung()'s own
|
||||
# doc for the full derivation and why this REPLACES the earlier two-gate
|
||||
# design entirely, not just patches it).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Deep zoom-in (a tiny extent) selects Quarter — comfortably under its own
|
||||
## 32,768 m ceiling.
|
||||
func test_select_rung_picks_quarter_well_under_its_ceiling() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0)
|
||||
assert_str(rung).is_equal("Quarter")
|
||||
|
||||
|
||||
## An extent past Quarter's own ceiling but under District's selects
|
||||
## District — the finest rung that can still cover it in one window.
|
||||
func test_select_rung_picks_district_between_the_two_ceilings() -> void:
|
||||
# 60,000 m is past Quarter's 32,768 m ceiling but well under District's
|
||||
# 131,072 m one.
|
||||
var rung: String = AtlasWindowGeometry.select_rung(60_000.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## An extent past BOTH Quarter's and District's ceilings selects Region —
|
||||
## neither finer rung's single window can cover this much world.
|
||||
func test_select_rung_picks_region_past_both_finer_ceilings() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0)
|
||||
assert_str(rung).is_equal("Region")
|
||||
|
||||
|
||||
## Exactly AT Quarter's own ceiling (32,768 m) must still select Quarter —
|
||||
## the rule is `<=`, not `<`.
|
||||
func test_select_rung_quarter_ceiling_boundary_is_inclusive() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(32_768.0, 100.0)
|
||||
assert_str(rung).is_equal("Quarter")
|
||||
|
||||
|
||||
## One metre past Quarter's ceiling must flip to District — confirms the
|
||||
## ceiling bites right at its own boundary, not one cell short of it.
|
||||
func test_select_rung_one_past_quarter_ceiling_is_district() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(32_769.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## Exactly AT District's own ceiling (131,072 m) must still select District.
|
||||
func test_select_rung_district_ceiling_boundary_is_inclusive() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0)
|
||||
assert_str(rung).is_equal("District")
|
||||
|
||||
|
||||
## One metre past District's ceiling must flip to Region.
|
||||
func test_select_rung_one_past_district_ceiling_is_region() -> void:
|
||||
var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0)
|
||||
assert_str(rung).is_equal("Region")
|
||||
|
||||
|
||||
## canvas_px is unused by the coverage rule (kept for signature stability,
|
||||
## see select_rung()'s own doc) — degenerate/zero values must not change the
|
||||
## selected rung at all, unlike the old `2x`-tolerance design's special-cased
|
||||
## fallback.
|
||||
func test_select_rung_canvas_px_does_not_affect_selection() -> void:
|
||||
var with_real_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0)
|
||||
var with_zero_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 0.0)
|
||||
assert_str(with_zero_canvas).is_equal(with_real_canvas)
|
||||
|
||||
|
||||
## spacing_for_rung() is select_rung()'s inverse lookup — pin the three known
|
||||
## values against the D-243 constants directly (not against RUNG_TABLE
|
||||
## indices, which would just restate the implementation).
|
||||
func test_spacing_for_rung_matches_d243_constants() -> void:
|
||||
assert_float(AtlasWindowGeometry.spacing_for_rung("Quarter")).is_equal_approx(512.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.spacing_for_rung("District")).is_equal_approx(2048.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.spacing_for_rung("Region")).is_equal_approx(204_800.0, 0.001)
|
||||
|
||||
|
||||
## An unknown tag falls back to District — matching the server's own
|
||||
## "unknown -> District" posture at every wire-decode boundary.
|
||||
func test_spacing_for_rung_unknown_tag_falls_back_to_district() -> void:
|
||||
assert_float(AtlasWindowGeometry.spacing_for_rung("Nonsense")).is_equal_approx(2048.0, 0.001)
|
||||
|
||||
|
||||
## MAX_COVERAGE_M's three values, pinned directly against the formulas
|
||||
## select_rung()'s own doc derives them from — a regression guard
|
||||
## independent of select_rung()'s own boundary tests above, so a future
|
||||
## accidental edit to the constants table itself (not just the selection
|
||||
## logic) is caught here too.
|
||||
func test_max_coverage_m_matches_derived_formulas() -> void:
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"]).is_equal_approx(32_768.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["District"]).is_equal_approx(131_072.0, 0.001)
|
||||
assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Region"]).is_equal_approx(13_107_200.0, 0.001)
|
||||
|
||||
|
||||
## The exact scenario that surfaced the original design flaw
|
||||
## (live-testing enter_orbital()'s own fit zoom): a whole Earth-like body's
|
||||
## circumference (~40,075 km, matching AtlasDescendGeometry.district_extent()'s
|
||||
## own cols*DISTRICT_M for radius=6371km) fitted to a 1920px-wide viewport at
|
||||
## CELL_PIXEL_SIZE=16 must select Region — the direct regression guard for
|
||||
## the bug an early version of select_rung() had (picking District here,
|
||||
## which would have meant the canonical orbital frame requests a
|
||||
## District-tier derive spanning an entire planet — the exact R1-catastrophe
|
||||
## cost scenario the design doc §4 rejects).
|
||||
func test_select_rung_at_orbital_fit_zoom_selects_region() -> void:
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
var composite_native: float = float(n) * CELL_PIXEL_SIZE
|
||||
var viewport := Vector2(1920.0, 1080.0)
|
||||
var fit_zoom: float = maxf(viewport.x, viewport.y) / composite_native
|
||||
var world_extent: float = AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, fit_zoom, viewport)
|
||||
var rung: String = AtlasWindowGeometry.select_rung(
|
||||
world_extent, maxf(viewport.x, viewport.y)
|
||||
)
|
||||
assert_str(rung).override_failure_message(
|
||||
"the canonical orbital fit-zoom (whole-planet view) must select Region,"
|
||||
+ " never a District-tier derive spanning an entire planet"
|
||||
).is_equal("Region")
|
||||
|
||||
|
||||
## **Live round 3 regression, the direct fix target:** at 1600x900 (the
|
||||
## coordinator's capture viewport), zooming IN from the orbital fit all the
|
||||
## way to Quarter's own ceiling must pass through District along the way —
|
||||
## a wheel-zoom gesture crossing world_extent_m from Region's territory down
|
||||
## to Quarter's must select District for SOME real span of extent in
|
||||
## between, not skip straight from Region to Quarter (the exact "money shot"
|
||||
## the coordinator wants capture-worthy: a visible SHARPEN in place, not a
|
||||
## jump).
|
||||
func test_select_rung_district_is_reachable_between_region_and_quarter() -> void:
|
||||
# An extent comfortably between District's and Quarter's ceilings (e.g.
|
||||
# the midpoint) must select District — proving the band is non-empty,
|
||||
# unlike the old two-gate design where it was empty by construction at
|
||||
# every real viewport (see git history / the coordinator's live-round
|
||||
# finding for the retired analysis).
|
||||
var midpoint: float = (
|
||||
(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"] as float)
|
||||
+ (AtlasWindowGeometry.MAX_COVERAGE_M["District"] as float)
|
||||
) * 0.5
|
||||
var rung: String = AtlasWindowGeometry.select_rung(midpoint, 1600.0)
|
||||
assert_str(rung).override_failure_message(
|
||||
"District must be reachable between Quarter's and District's own"
|
||||
+ " coverage ceilings — the redesigned rule must not skip it"
|
||||
).is_equal("District")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: world_extent_m() — the `E` half of the §5 rule, computed from the
|
||||
# viewer's own zoom/viewport state.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## At zoom=1.0, CELL_PIXEL_SIZE=16: one DISTRICT (2,048 m, the fixed display
|
||||
## unit — see world_extent_m()'s own doc for why this is rung-INDEPENDENT)
|
||||
## occupies 16 screen px, so a 1920px-wide viewport shows
|
||||
## 1920/16 * 2048 = 245,760 m.
|
||||
func test_world_extent_m_at_zoom_one() -> void:
|
||||
var extent: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
assert_float(extent).is_equal_approx(1920.0 / CELL_PIXEL_SIZE * 2048.0, 1.0)
|
||||
|
||||
|
||||
## Doubling the zoom must HALVE the displayed world extent — zooming in
|
||||
## shows less world, not more.
|
||||
func test_world_extent_m_halves_when_zoom_doubles() -> void:
|
||||
var extent_1x: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
var extent_2x: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 2.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
assert_float(extent_2x).is_equal_approx(extent_1x * 0.5, 1.0)
|
||||
|
||||
|
||||
## The composite's on-screen footprint is rung-invariant (world_extent_m()'s
|
||||
## own doc) — a change in held rung with NO change in zoom/viewport must
|
||||
## leave the displayed world extent UNCHANGED. This is the direct regression
|
||||
## test for the bug this function's signature once had (a granularity_v2
|
||||
## parameter that silently changed the formula per rung, when only zoom
|
||||
## should) — the function no longer TAKES a rung parameter at all, so this
|
||||
## pins that omission is intentional, not an oversight.
|
||||
func test_world_extent_m_has_no_rung_parameter() -> void:
|
||||
var extent_a: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
var extent_b: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 1.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
assert_float(extent_a).is_equal_approx(extent_b, 0.001)
|
||||
|
||||
|
||||
## Degenerate zoom (<=0) must not divide by zero — a safe zero extent.
|
||||
func test_world_extent_m_degenerate_zoom_is_safe() -> void:
|
||||
var extent: float = AtlasWindowGeometry.world_extent_m(
|
||||
CELL_PIXEL_SIZE, 0.0, Vector2(1920.0, 1080.0)
|
||||
)
|
||||
assert_float(extent).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: is_fully_zoomed_out() — Jeroen's HARD condition's trigger predicate.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_is_fully_zoomed_out_true_when_extent_covers_full_circumference() -> void:
|
||||
var radius_km := 6371.0
|
||||
var circumference_m: float = TAU * radius_km * 1000.0
|
||||
assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(circumference_m, radius_km)).is_true()
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 1.5, radius_km)
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_is_fully_zoomed_out_false_when_extent_is_less_than_circumference() -> void:
|
||||
var radius_km := 6371.0
|
||||
var circumference_m: float = TAU * radius_km * 1000.0
|
||||
assert_bool(
|
||||
AtlasWindowGeometry.is_fully_zoomed_out(circumference_m * 0.5, radius_km)
|
||||
).is_false()
|
||||
|
||||
|
||||
## A no-radius body (tiny test body) has no circumference concept — never
|
||||
## auto-resets, matching enter_orbital()'s own no-radius fallback disposition.
|
||||
func test_is_fully_zoomed_out_false_for_no_radius_body() -> void:
|
||||
assert_bool(AtlasWindowGeometry.is_fully_zoomed_out(1e12, 0.0)).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: screen_center_to_district() — the shared screen<->district formula
|
||||
# behind both the pan-edge refetch and the rung-reselect refetch.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## At the exact center of a symmetric fit (offset centers the composite,
|
||||
## zoom=1.0), the screen center must map back to the held center exactly.
|
||||
func test_screen_center_to_district_at_rest_returns_held_center() -> void:
|
||||
var held_n := 32
|
||||
var held_center := Vector2i(10, 20)
|
||||
var composite_native: float = float(held_n) * CELL_PIXEL_SIZE
|
||||
var viewport := Vector2(composite_native, composite_native)
|
||||
var offset := Vector2.ZERO # composite exactly fills the viewport, top-left at origin
|
||||
var result: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
viewport, offset, 1.0, CELL_PIXEL_SIZE, held_center, held_n
|
||||
)
|
||||
assert_that(result).is_equal(held_center)
|
||||
|
||||
|
||||
## Panning the offset must shift the recovered district position in the
|
||||
## OPPOSITE direction of the offset shift (dragging the composite right
|
||||
## reveals districts to the WEST at screen-center).
|
||||
func test_screen_center_to_district_shifts_with_pan_offset() -> void:
|
||||
var held_n := 32
|
||||
var held_center := Vector2i(0, 0)
|
||||
var composite_native: float = float(held_n) * CELL_PIXEL_SIZE
|
||||
var viewport := Vector2(composite_native, composite_native)
|
||||
var at_rest: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
viewport, Vector2.ZERO, 1.0, CELL_PIXEL_SIZE, held_center, held_n
|
||||
)
|
||||
var panned: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
viewport, Vector2(CELL_PIXEL_SIZE * 4.0, 0.0), 1.0, CELL_PIXEL_SIZE, held_center, held_n
|
||||
)
|
||||
assert_int(panned.x).override_failure_message(
|
||||
"dragging the composite EAST (positive offset) must reveal districts to the WEST"
|
||||
).is_less(at_rest.x)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153 (moved from atlas_window_viewer.gd for testability): WASD held-pan
|
||||
# direction is exercised live only (reads the global Input singleton) —
|
||||
# edge-scroll suppression/direction are pure and covered here directly.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_is_cursor_edge_scrolling_true_near_an_edge() -> void:
|
||||
var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
true, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0
|
||||
)
|
||||
assert_bool(result).is_true()
|
||||
|
||||
|
||||
func test_is_cursor_edge_scrolling_false_away_from_any_edge() -> void:
|
||||
var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
true, false, Vector2(800.0, 600.0), Vector2(400.0, 300.0), 24.0
|
||||
)
|
||||
assert_bool(result).is_false()
|
||||
|
||||
|
||||
func test_is_cursor_edge_scrolling_suppressed_when_over_ui() -> void:
|
||||
var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
true, true, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0
|
||||
)
|
||||
assert_bool(result).is_false()
|
||||
|
||||
|
||||
func test_is_cursor_edge_scrolling_suppressed_without_app_focus() -> void:
|
||||
var result: bool = AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
false, false, Vector2(800.0, 600.0), Vector2(10.0, 300.0), 24.0
|
||||
)
|
||||
assert_bool(result).is_false()
|
||||
|
||||
|
||||
func test_edge_scroll_direction_points_west_near_left_edge() -> void:
|
||||
var direction: Vector2 = AtlasWindowGeometry.edge_scroll_direction(
|
||||
Vector2(800.0, 600.0), Vector2(5.0, 300.0), 24.0
|
||||
)
|
||||
assert_float(direction.x).is_less(0.0)
|
||||
assert_float(direction.y).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153, live round 3 (Jeroen's ruling, design doc §4): compute_tile_grid()
|
||||
# — the orbital rest state's multi-window mosaic.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The exact live-round scenario: GJ380c/Lendel (radius 6238.4 km) needs a
|
||||
## 3x2 = 6-tile grid — the coordinator's own estimate, confirmed here as an
|
||||
## executable regression.
|
||||
func test_compute_tile_grid_lendel_produces_six_tiles() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
assert_int(tiles.size()).override_failure_message(
|
||||
"GJ380c/Lendel must tile into 3x2=6 windows, matching the coordinator's own"
|
||||
+ " live-round finding (13,107.2 km single-window coverage vs. 39,198 km"
|
||||
+ " circumference)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
## A tiny body whose whole circumference fits in ONE Region window's
|
||||
## coverage ceiling must produce exactly ONE tile — tiling degenerates
|
||||
## gracefully to the pre-existing single-window behavior when it isn't
|
||||
## actually needed.
|
||||
func test_compute_tile_grid_tiny_body_produces_one_tile() -> void:
|
||||
# radius small enough that circumference << MAX_COVERAGE_M["Region"]
|
||||
# (13,107,200 m) — a few hundred km radius comfortably qualifies.
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(50.0)
|
||||
assert_int(tiles.size()).is_equal(1)
|
||||
assert_that(tiles[0]).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## A no-radius body (tiny test body) must produce exactly one tile at the
|
||||
## canonical origin — matching enter_orbital()'s own no-radius fallback
|
||||
## disposition (no circumference/tiling concept without a radius).
|
||||
func test_compute_tile_grid_no_radius_produces_single_origin_tile() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(0.0)
|
||||
assert_int(tiles.size()).is_equal(1)
|
||||
assert_that(tiles[0]).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## Every tile center must be a LEGAL canonicalized DistrictPos — column
|
||||
## wrapped into [0, cols), row clamped into [-rows_half, rows_half] — the
|
||||
## same range canonicalize_district_center() enforces everywhere else in
|
||||
## this cluster (pan refetch, entry, rung-reselect). A raw, uncanonicalized
|
||||
## tile center would fail the server's own normalize_window_center() (or
|
||||
## silently alias to a different tile than intended).
|
||||
func test_compute_tile_grid_tiles_are_all_canonicalized() -> void:
|
||||
var radius_km := 6238.4
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
|
||||
for tile: Vector2i in tiles:
|
||||
assert_int(tile.x).override_failure_message(
|
||||
"tile column %d must be wrapped into [0, %d)" % [tile.x, cols]
|
||||
).is_greater_equal(0)
|
||||
assert_int(tile.x).is_less(cols)
|
||||
assert_int(tile.y).override_failure_message(
|
||||
"tile row %d must be clamped into [-%d, %d]" % [tile.y, rows_half, rows_half]
|
||||
).is_greater_equal(-rows_half)
|
||||
assert_int(tile.y).is_less_equal(rows_half)
|
||||
|
||||
|
||||
## No two tiles may share the same canonicalized center — compute_tile_grid()
|
||||
## must dedupe (a pole-row clamp or column-wrap collision producing the exact
|
||||
## same DistrictPos twice would otherwise request/draw the same tile twice,
|
||||
## wasting a request and drawing one tile over another).
|
||||
func test_compute_tile_grid_has_no_duplicate_centers() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
var seen: Dictionary = {}
|
||||
for tile: Vector2i in tiles:
|
||||
assert_bool(seen.has(tile)).override_failure_message(
|
||||
"tile center %s appears more than once in the grid" % str(tile)
|
||||
).is_false()
|
||||
seen[tile] = true
|
||||
|
||||
|
||||
## The tile grid's own center of mass must land on the canonical origin
|
||||
## (0,0) — the tile-set's symmetric layout (each axis' centers computed as
|
||||
## `(index - (count-1)/2) * TILE_N`) is centered on the SAME canonical origin
|
||||
## enter_orbital() uses, so the tile-set's overall framing agrees with
|
||||
## single-window enter_orbital()'s own "center on (0,0)" contract.
|
||||
func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void:
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4)
|
||||
var sum_col := 0
|
||||
var sum_row := 0
|
||||
for tile: Vector2i in tiles:
|
||||
sum_col += tile.x
|
||||
sum_row += tile.y
|
||||
# Column centers wrap (periodic), so a raw average isn't meaningful there
|
||||
# the way it is for rows — assert row symmetry directly instead (rows
|
||||
# never wrap, so their average must be very close to 0 for a
|
||||
# symmetric grid).
|
||||
var avg_row: float = float(sum_row) / float(tiles.size())
|
||||
assert_float(avg_row).override_failure_message(
|
||||
"the tile grid's row centers must average to ~0 (symmetric around the"
|
||||
+ " canonical origin's equator row)"
|
||||
).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Live round 4: district_to_canvas_local() + recompute_offset_for_held_n_change()
|
||||
# — the two pure functions behind both round-4 draw-path fixes (tile mosaic
|
||||
# placement, single-window offset recompute across a rung crossing).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A district AT the held window's own center must land at canvas-local
|
||||
## `(held_n/2 * cell_px, held_n/2 * cell_px)` — the center of the
|
||||
## `[0, held_n*cell_px)` square the single-window `Rect2(0,0,extent,extent)`
|
||||
## draw call already assumes.
|
||||
func test_district_to_canvas_local_center_district_lands_at_half_extent() -> void:
|
||||
var held_center := Vector2i(100, 200)
|
||||
var held_n := 64
|
||||
var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
Vector2(held_center), held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var expected: float = float(held_n) * 0.5 * CELL_PIXEL_SIZE
|
||||
assert_that(result).is_equal(Vector2(expected, expected))
|
||||
|
||||
|
||||
## The window's own top-left corner (held_center - held_n/2) must land at
|
||||
## canvas-local (0,0) — the exact invariant single-window `_draw()` and
|
||||
## `fit_window_view()` both assume.
|
||||
func test_district_to_canvas_local_top_left_corner_lands_at_origin() -> void:
|
||||
var held_center := Vector2i(0, 0)
|
||||
var held_n := 32
|
||||
var top_left := Vector2(held_center) - Vector2.ONE * (float(held_n) * 0.5)
|
||||
var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
top_left, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(result).is_equal(Vector2.ZERO)
|
||||
|
||||
|
||||
## Live round 4's OWN repro, pinned directly: a tile far from held_center
|
||||
## (0,0) at whole-body scale (held_n ~19,139, Lendel's raw circumference)
|
||||
## must NOT land near canvas-local (0,0) — the round-4 bug's exact failure
|
||||
## mode (treating absolute district (0,0) as the canvas origin regardless of
|
||||
## held_center/held_n) would place it there instead.
|
||||
func test_district_to_canvas_local_matches_the_live_round_4_repro_scale() -> void:
|
||||
var held_center := Vector2i.ZERO
|
||||
var held_n := 19139 # Lendel's raw district-column count (live round 4's own repro)
|
||||
var tile_center := Vector2(6400, 0) # one TILE_N east of the body's own center
|
||||
var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
tile_center, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var buggy_result: Vector2 = tile_center * CELL_PIXEL_SIZE # the round-4 bug's own formula
|
||||
assert_bool(is_equal_approx(result.x, buggy_result.x)).override_failure_message(
|
||||
"a tile away from held_center must NOT land where the round-4 bug's"
|
||||
+ " absolute-district-(0,0)-relative formula would put it — got %.1f, the"
|
||||
+ " buggy formula's own value is %.1f"
|
||||
% [result.x, buggy_result.x]
|
||||
).is_false()
|
||||
|
||||
|
||||
## Zero held_n is a degenerate/never-real-in-practice input (a body always
|
||||
## has SOME district extent) but must not divide-by-zero or crash — `half`
|
||||
## is simply 0, so the district maps 1:1 to canvas-local (scaled by cell_px).
|
||||
func test_district_to_canvas_local_zero_held_n_does_not_crash() -> void:
|
||||
var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
Vector2(5, 5), Vector2i.ZERO, 0, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(result).is_equal(Vector2(5, 5) * CELL_PIXEL_SIZE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Live round 5: nearest_wrap_image() — the tile-mosaic WRAP half of "the
|
||||
# mosaic doesn't fully draw" (the left-third-black repro).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Live round 5's OWN repro, pinned exactly: Lendel's wrapped tile
|
||||
## canonicalizes to column 12739 (`-6400 mod 19139`) — the CORRECT
|
||||
## request/cache key — but its nearest wrap-image relative to the canonical
|
||||
## origin (held_center.x = 0) is -6400, the actual visible position
|
||||
## immediately west of center.
|
||||
func test_nearest_wrap_image_matches_the_lendel_repro() -> void:
|
||||
var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 19139)
|
||||
assert_int(result).override_failure_message(
|
||||
"the wrapped tile's nearest wrap-image relative to held_center=0 must be"
|
||||
+ " -6400 (its actual on-screen position), not 12739 (the correct REQUEST"
|
||||
+ " key, but the wrong DRAW position)"
|
||||
).is_equal(-6400)
|
||||
|
||||
|
||||
## The two Lendel tiles that were NEVER wrapped (already close to
|
||||
## held_center) must round-trip unchanged — the fix must not perturb tiles
|
||||
## that were already drawing correctly.
|
||||
func test_nearest_wrap_image_is_a_noop_for_already_nearby_columns() -> void:
|
||||
var cols := 19139
|
||||
for col: int in [0, 6400]:
|
||||
var result: int = AtlasWindowGeometry.nearest_wrap_image(col, 0, cols)
|
||||
assert_int(result).override_failure_message(
|
||||
"column %d is already the nearest wrap-image to held_center=0 — must"
|
||||
+ " be returned unchanged" % col
|
||||
).is_equal(col)
|
||||
|
||||
|
||||
## The result must always be a LEGAL wrap-image of the canonical column —
|
||||
## i.e. `result mod cols == canonical_col mod cols` — regardless of which
|
||||
## image is nearest. This is the correctness invariant the whole function
|
||||
## exists to preserve: re-expressing a column for DRAWING must never change
|
||||
## WHICH district it actually refers to.
|
||||
func test_nearest_wrap_image_preserves_the_canonical_identity() -> void:
|
||||
var cols := 19139
|
||||
for held_col: int in [-50000, -1, 0, 1, 9569, 19138, 50000]:
|
||||
var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, held_col, cols)
|
||||
assert_int(posmod(result, cols)).override_failure_message(
|
||||
"nearest_wrap_image(12739, %d, %d) = %d must still canonicalize back"
|
||||
+ " to 12739 — it may only pick a DIFFERENT wrap-image, never a"
|
||||
+ " different district" % [held_col, cols, result]
|
||||
).is_equal(12739)
|
||||
|
||||
|
||||
## The chosen wrap-image must be the CLOSEST one to held_center — never
|
||||
## farther than half the circumference away (otherwise a different
|
||||
## wrap-image would have been nearer).
|
||||
func test_nearest_wrap_image_is_within_half_circumference_of_held_center() -> void:
|
||||
var cols := 19139
|
||||
for canonical_col: int in [0, 1, 9569, 12739, 19138]:
|
||||
for held_col: int in [-30000, -500, 0, 500, 25000]:
|
||||
var result: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_col, cols)
|
||||
var distance: int = absi(result - held_col)
|
||||
assert_int(distance).override_failure_message(
|
||||
(
|
||||
"nearest_wrap_image(%d, %d, %d) = %d is %d districts from"
|
||||
+ " held_center — must never exceed half the circumference"
|
||||
+ " (%d), or a closer wrap-image exists"
|
||||
)
|
||||
% [canonical_col, held_col, cols, result, distance, cols / 2]
|
||||
).is_less_equal(cols / 2)
|
||||
|
||||
|
||||
## `cols <= 0` (no-radius bodies, which never tile per compute_tile_grid()'s
|
||||
## own doc) must be a safe no-op passthrough — no periodicity to resolve.
|
||||
func test_nearest_wrap_image_zero_cols_is_a_passthrough() -> void:
|
||||
var result: int = AtlasWindowGeometry.nearest_wrap_image(12739, 0, 0)
|
||||
assert_int(result).is_equal(12739)
|
||||
|
||||
|
||||
## The coordinator's own draw-position counterpart to
|
||||
## test_compute_tile_grid_tiles_are_all_canonicalized(): the wrapped tile's
|
||||
## DRAW rect (via district_to_canvas_local(), fed through
|
||||
## nearest_wrap_image() the way _draw_tile_mosaic() now does) must land
|
||||
## SUBSTANTIALLY on-canvas when the view covers the whole body — the exact
|
||||
## Lendel shape (whole-body fit at entry, held_center at the canonical
|
||||
## origin). A bare `Rect2.intersects()` check is NOT discriminating enough
|
||||
## here: at Lendel's own whole-body-fit scale, the BUGGY placement (feeding
|
||||
## the canonical column directly) happens to clip the viewport edge by only
|
||||
## a couple of px (confirmed by hand-computation — the tile-grid's own
|
||||
## edge-to-edge tiling means a full-circumference shift lands almost
|
||||
## exactly one screen-width away, so `intersects()` alone would pass on a
|
||||
## near-miss that still reads as "the left third is black" visually).
|
||||
## Asserting a MEANINGFUL overlap FRACTION (at least half the tile's own
|
||||
## area) is what actually distinguishes "correctly drawn" from "barely
|
||||
## clipping the edge."
|
||||
func test_wrapped_tile_draw_rect_lands_substantially_on_canvas_at_whole_body_view() -> void:
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var held_center := Vector2i.ZERO
|
||||
var held_n: int = cols # enter_orbital()'s own whole-body held_n
|
||||
var tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
var half_tile: float = float(tile_n) * 0.5
|
||||
|
||||
# The whole-body fit zoom/viewport (matching enter_orbital()'s own fit).
|
||||
var viewport := Vector2(1600.0, 900.0)
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
viewport, held_n, CELL_PIXEL_SIZE, 0.0001, 64.0
|
||||
)
|
||||
var view_zoom: float = fit["zoom"]
|
||||
var view_offset: Vector2 = fit["offset"]
|
||||
|
||||
# The wrapped tile's own canonical center — mirrors compute_tile_grid()'s
|
||||
# own dedup/canonicalize step for Lendel's westmost tile.
|
||||
var wrapped_raw_col := -6400
|
||||
var canonical_col: int = posmod(wrapped_raw_col, cols)
|
||||
|
||||
var draw_col: int = AtlasWindowGeometry.nearest_wrap_image(canonical_col, held_center.x, cols)
|
||||
var tile_top_left := Vector2(float(draw_col) - half_tile, 0.0 - half_tile)
|
||||
var local_origin: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
|
||||
tile_top_left, held_center, held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var extent_px: float = float(tile_n) * CELL_PIXEL_SIZE
|
||||
|
||||
# Canvas-local -> screen space: _canvas.position = view_offset,
|
||||
# _canvas.scale = view_zoom (AtlasWindowViewer._apply_transform()'s own
|
||||
# transform, mirrored here since this is a pure-geometry test with no
|
||||
# live Control/Node2D tree).
|
||||
var screen_top_left: Vector2 = view_offset + local_origin * view_zoom
|
||||
var screen_extent: Vector2 = Vector2(extent_px, extent_px) * view_zoom
|
||||
var tile_rect := Rect2(screen_top_left, screen_extent)
|
||||
var viewport_rect := Rect2(Vector2.ZERO, viewport)
|
||||
|
||||
var overlap: Rect2 = viewport_rect.intersection(tile_rect)
|
||||
var tile_area: float = screen_extent.x * screen_extent.y
|
||||
var overlap_fraction: float = 0.0
|
||||
if tile_area > 0.0:
|
||||
overlap_fraction = (overlap.size.x * overlap.size.y) / tile_area
|
||||
|
||||
assert_float(overlap_fraction).override_failure_message(
|
||||
(
|
||||
"the wrapped tile's draw rect %s overlaps the viewport %s by only"
|
||||
+ " %.1f%% of its own area — must be at least 50%% when the view"
|
||||
+ " covers the whole body. This is live round 5's 'left third of the"
|
||||
+ " mosaic is black' repro: drawing the CANONICAL column (%d) directly"
|
||||
+ " (without nearest_wrap_image()) places this tile off-canvas RIGHT"
|
||||
+ " instead of its true position on the LEFT"
|
||||
)
|
||||
% [tile_rect, viewport_rect, overlap_fraction * 100.0, canonical_col]
|
||||
).is_greater_equal(0.5)
|
||||
|
||||
|
||||
## The core contract this function exists for: recomputing `_view_offset` so
|
||||
## a KNOWN screen point continues to map to canvas-local
|
||||
## `new_held_n/2 * cell_px` (the new window's own center) — i.e. feeding the
|
||||
## OUTPUT back through district_to_canvas_local()'s own "center district ->
|
||||
## half-extent local" identity (tested above) and applying the resulting
|
||||
## transform must reproduce the SAME screen point exactly.
|
||||
func test_recompute_offset_for_held_n_change_preserves_the_screen_point() -> void:
|
||||
var screen_point := Vector2(800.0, 450.0)
|
||||
var view_zoom := 2.5
|
||||
var new_held_n := 16
|
||||
var offset: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change(
|
||||
screen_point, view_zoom, new_held_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * CELL_PIXEL_SIZE)
|
||||
var reconstructed_screen_point: Vector2 = new_local * view_zoom + offset
|
||||
assert_that(reconstructed_screen_point).is_equal_approx(screen_point, Vector2.ONE * 0.01)
|
||||
|
||||
|
||||
## Live round 4's OWN repro: crossing from Region (~thousands-districts held_n)
|
||||
## to District (64) or Quarter (16) must produce a DIFFERENT offset than
|
||||
## leaving `_view_offset` untouched would — pinning that this function's
|
||||
## OUTPUT actually depends on `new_held_n` (the exact thing the round-4 bug
|
||||
## got wrong by never calling this function at all).
|
||||
func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> void:
|
||||
var screen_point := Vector2(800.0, 450.0)
|
||||
var view_zoom := 3.378 # live round 4's own District-band zoom value
|
||||
var offset_district: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change(
|
||||
screen_point, view_zoom, 64, CELL_PIXEL_SIZE
|
||||
)
|
||||
var offset_quarter: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change(
|
||||
screen_point, view_zoom, 16, CELL_PIXEL_SIZE
|
||||
)
|
||||
assert_that(offset_district).override_failure_message(
|
||||
"a rung crossing that changes held_n must recompute a DIFFERENT"
|
||||
+ " _view_offset — reusing the same offset across the crossing is"
|
||||
+ " exactly the live round 4 bug (composite renders off-canvas)"
|
||||
).is_not_equal(offset_quarter)
|
||||
|
||||
@@ -23,10 +23,14 @@ static func _mock_window(n: int = 2) -> Dictionary:
|
||||
|
||||
|
||||
## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through
|
||||
## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a
|
||||
## bare stub with just those three methods is a legitimate "viewer" for
|
||||
## these tests, matching the duck-typed-viewer precedent this whole overlay
|
||||
## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd).
|
||||
## get_district_window()/is_overlay_visible()/get_cell_pixel_size()/
|
||||
## is_tile_mode(), so a bare stub with just those methods is a legitimate
|
||||
## "viewer" for these tests, matching the duck-typed-viewer precedent this
|
||||
## whole overlay cluster already relies on (atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd). is_tile_mode() always returns false — this suite
|
||||
## covers the single-window composite-cache path only; the tile mosaic path
|
||||
## is covered separately by test_atlas_window_tile_set.gd + the viewer's own
|
||||
## is_tile_mode()-branching tests.
|
||||
class _ViewerStub:
|
||||
var window: Variant = null
|
||||
var active_overlay: String = ""
|
||||
@@ -40,6 +44,9 @@ class _ViewerStub:
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func is_tile_mode() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func test_composite_smooth_defaults_true() -> void:
|
||||
assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message(
|
||||
@@ -150,3 +157,111 @@ func test_draw_builds_a_texture_through_the_viewer_stub() -> void:
|
||||
o.viewer = stub
|
||||
o._draw()
|
||||
assert_that(o._cached_texture).is_not_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1152/T-1153: cell_grid_side_for_window() — the district-extent-vs-
|
||||
# derived-cell-grid split every rung's response now carries.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## District (the default/omitted tag): cell_grid_side == n, unchanged from
|
||||
## the pre-T-1152 identity mapping.
|
||||
func test_cell_grid_side_for_window_district_matches_n() -> void:
|
||||
var window: Dictionary = {"n": 32, "granularity_v2": "District"}
|
||||
assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32)
|
||||
|
||||
|
||||
## Quarter: 4x MORE cells than districts (WINDOW_GRANULARITY_QUARTER).
|
||||
func test_cell_grid_side_for_window_quarter_multiplies_by_four() -> void:
|
||||
var window: Dictionary = {"n": 32, "granularity_v2": "Quarter"}
|
||||
assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(128)
|
||||
|
||||
|
||||
## Region: FAR FEWER cells than districts — round(n/100), matching
|
||||
## WindowGranularity::cell_grid_side's own Region branch exactly (the
|
||||
## "inversion" the server doc calls out: finer rungs multiply, Region divides).
|
||||
func test_cell_grid_side_for_window_region_divides_by_districts_per_region() -> void:
|
||||
var window: Dictionary = {"n": 6400, "granularity_v2": "Region"}
|
||||
assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(64)
|
||||
|
||||
|
||||
## A Region window smaller than one region (n < 100) must still derive a
|
||||
## minimum 1x1 cell grid, never 0 — matching the server's `.max(1)`.
|
||||
func test_cell_grid_side_for_window_region_minimum_is_one() -> void:
|
||||
var window: Dictionary = {"n": 50, "granularity_v2": "Region"}
|
||||
assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(1)
|
||||
|
||||
|
||||
## Missing granularity_v2 (an old-shape response) falls back to District —
|
||||
## matching the server's own "unknown -> District" posture at every
|
||||
## resolution boundary.
|
||||
func test_cell_grid_side_for_window_missing_tag_falls_back_to_district() -> void:
|
||||
var window: Dictionary = {"n": 32}
|
||||
assert_int(AtlasWindowOverlay.cell_grid_side_for_window(window)).is_equal(32)
|
||||
|
||||
|
||||
## T-1152/T-1153, design doc §6 encoding continuity: a Region-rung window (a
|
||||
## FAR SPARSER cell grid — one region cell spans 100 districts) renders
|
||||
## through the EXACT SAME _draw()/_rebuild_texture_if_needed() path as
|
||||
## District — no separate branch, no crash reading past the (much smaller)
|
||||
## per-cell arrays. This is the direct "same colorizer family at every rung"
|
||||
## behavioral test the ticket asks for.
|
||||
func test_draw_builds_a_texture_for_a_region_rung_window() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var stub := _ViewerStub.new()
|
||||
# n=200 districts -> cell_grid_side = round(200/100) = 2 -> 4 cells,
|
||||
# matching the 4-entry per-cell arrays below (same shape _mock_window()
|
||||
# uses, just at Region's district-to-cell ratio).
|
||||
stub.window = {
|
||||
"center": [0, 0],
|
||||
"n": 200,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
o.viewer = stub
|
||||
o._draw()
|
||||
assert_that(o._cached_texture).override_failure_message(
|
||||
"a Region-rung window must render through the same composite path as District"
|
||||
).is_not_null()
|
||||
assert_int(o._cached_texture.get_width()).override_failure_message(
|
||||
"the built texture's resolution must be the DERIVED cell-grid side (2),"
|
||||
+ " not the window's district extent (200)"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
## §6 "no mode flip" acceptance criterion, restated at the texture-cache
|
||||
## level: swapping from a District-rung window to a Region-rung window at a
|
||||
## NEW window object (the progressive-refinement swap) must still go through
|
||||
## a single rebuild call producing a fresh texture — not a crash, not a
|
||||
## silently-stale texture sized for the wrong rung.
|
||||
func test_rebuild_handles_a_rung_swap_from_district_to_region() -> void:
|
||||
var o: AtlasWindowOverlay = auto_free(AtlasWindowOverlay.new())
|
||||
var district_window: Dictionary = _mock_window() # n=2, District, 4 cells
|
||||
o._rebuild_texture_if_needed(
|
||||
district_window, AtlasWindowOverlay.cell_grid_side_for_window(district_window), ""
|
||||
)
|
||||
assert_int(o._cached_texture.get_width()).is_equal(2)
|
||||
|
||||
var region_window: Dictionary = {
|
||||
"center": [0, 0],
|
||||
"n": 200,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
o._rebuild_texture_if_needed(
|
||||
region_window, AtlasWindowOverlay.cell_grid_side_for_window(region_window), ""
|
||||
)
|
||||
assert_int(o._cached_texture.get_width()).override_failure_message(
|
||||
"a rung swap must rebuild at the NEW rung's derived cell-grid resolution"
|
||||
).is_equal(2) # region_window's cell_grid_side is also 2 here (200/100) — same size, different data
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
## Live round 4: a REAL draw smoke test — the "does anything draw at all"
|
||||
## gap has now bitten twice (round 4's tile-mosaic coordinate bug AND its
|
||||
## per-tile-texture-lifetime bug, both invisible to test_atlas_window_overlay.gd's
|
||||
## existing suite, which only asserts on the CACHE FIELDS being populated —
|
||||
## never on an actual composited pixel). This file closes that gap
|
||||
## structurally: render AtlasWindowOverlay into a REAL SubViewport, force a
|
||||
## GPU sync, grab the rendered Image, and assert a meaningful fraction of
|
||||
## pixels differ from the background color — for BOTH the single-window path
|
||||
## (a) and the tile-mosaic path (b), matching the coordinator's explicit ask.
|
||||
##
|
||||
## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under
|
||||
## `tests/run-godot`'s hardcoded `--headless`** (dummy driver, no GPU texture
|
||||
## output; confirmed directly: SubViewport.get_texture().get_image() returns
|
||||
## an all-zero/unusable image under it). This matters beyond "the assertions
|
||||
## are meaningless there": the push gate runs the FULL suite through
|
||||
## `tests/run-godot --headless` for every push, for everyone — a loud FAILURE
|
||||
## here would bounce every future push project-wide, not just report a local
|
||||
## false negative. Every test below carries the gdUnit4 fuzzer-arg skip
|
||||
## convention (`_do_skip`/`_skip_reason`, matching test_input_gate_live.gd's
|
||||
## own server-binary-not-built skip) keyed on `_dummy_renderer_active()`, so
|
||||
## `tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports
|
||||
## green-with-skips under headless, not red.
|
||||
##
|
||||
## To actually exercise this file's assertions, run it with a real driver:
|
||||
## godot4 --display-driver x11 --rendering-driver opengl3 \
|
||||
## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \
|
||||
## -a res://tests/test_atlas_window_overlay_draw_smoke.gd
|
||||
## (matching tests/visual_capture.gd's own documented "requires a real
|
||||
## rendering driver" precedent — see docs/DEVOPS.md's own note on this file.)
|
||||
class_name TestAtlasWindowOverlayDrawSmoke
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowOverlay := preload("res://ui/implant/apps/atlas/atlas_window_overlay.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const)
|
||||
const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512)
|
||||
|
||||
## Minimum fraction of the captured image that must differ from COLOR_BG for
|
||||
## a draw to count as "genuinely rendered something" — low enough to tolerate
|
||||
## a mostly-water/mostly-one-color composite (round 4's own repro shots were
|
||||
## legitimately near-uniform ocean at some zooms), high enough that a
|
||||
## fully-blank/fully-background/fully-white frame (both round 4 bugs) fails it.
|
||||
const MIN_NON_BACKGROUND_FRACTION: float = 0.05
|
||||
|
||||
const SKIP_REASON: String = (
|
||||
"no real rendering driver (dummy/headless) — run with e.g."
|
||||
+ " `godot4 --display-driver x11 --rendering-driver opengl3"
|
||||
+ " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c"
|
||||
+ " -a res://tests/test_atlas_window_overlay_draw_smoke.gd` to exercise this file"
|
||||
)
|
||||
|
||||
|
||||
## True under Godot's `--display-driver headless` (the dummy renderer
|
||||
## `tests/run-godot`'s hardcoded `--headless` flag selects) — `DisplayServer.
|
||||
## get_name()` reports `"headless"` there and the real driver name (`"X11"`,
|
||||
## `"Wayland"`, etc.) otherwise, confirmed directly against both this
|
||||
## worktree's `tests/run-godot` invocation and a real `--display-driver x11
|
||||
## --rendering-driver opengl3` run. Named as a function, not a const, since
|
||||
## `DisplayServer` singleton state isn't available at script-parse time.
|
||||
static func _dummy_renderer_active() -> bool:
|
||||
return DisplayServer.get_name() == "headless"
|
||||
|
||||
|
||||
## Single-window viewer stub — mirrors test_atlas_window_overlay.gd's
|
||||
## _ViewerStub exactly (is_tile_mode() -> false), so this exercises the
|
||||
## SAME single-window draw path that suite's cache tests cover, just
|
||||
## through a REAL render instead of inspecting `_cached_texture` directly.
|
||||
class _SingleWindowViewerStub:
|
||||
var window: Variant = null
|
||||
|
||||
func get_district_window() -> Variant:
|
||||
return window
|
||||
|
||||
func is_overlay_visible(_overlay_id: String) -> bool:
|
||||
return false
|
||||
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func is_tile_mode() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## Tile-mode viewer stub — is_tile_mode() -> true, get_tile_set() returns a
|
||||
## bare object exposing get_tiles() (AtlasWindowOverlay's own duck-typed
|
||||
## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly:
|
||||
## Array of {"center": Vector2i, "window": Variant}).
|
||||
class _TileModeViewerStub:
|
||||
var tiles: Array = []
|
||||
var held_center: Vector2i = Vector2i.ZERO
|
||||
var held_n: int = 0
|
||||
# Live round 5: nearest_wrap_image()'s cols input — 0 here (a no-radius
|
||||
# passthrough) is fine for these tests, which don't exercise the wrap
|
||||
# seam itself (that's test_atlas_window_geometry.gd's own coverage);
|
||||
# this stub only needs to satisfy _draw_tile_mosaic()'s duck-typed call.
|
||||
var body_radius_km: float = 0.0
|
||||
|
||||
func get_district_window() -> Variant:
|
||||
return null
|
||||
|
||||
func is_overlay_visible(_overlay_id: String) -> bool:
|
||||
return false
|
||||
|
||||
func get_cell_pixel_size() -> float:
|
||||
return 16.0
|
||||
|
||||
func is_tile_mode() -> bool:
|
||||
return true
|
||||
|
||||
func get_tile_set() -> Variant:
|
||||
return _TileSetStub.new(tiles)
|
||||
|
||||
func get_held_center() -> Vector2i:
|
||||
return held_center
|
||||
|
||||
func get_held_n() -> int:
|
||||
return held_n
|
||||
|
||||
func get_body_radius_km() -> float:
|
||||
return body_radius_km
|
||||
|
||||
|
||||
class _TileSetStub:
|
||||
var _tiles: Array = []
|
||||
|
||||
func _init(tiles: Array) -> void:
|
||||
_tiles = tiles
|
||||
|
||||
func get_tiles() -> Array:
|
||||
return _tiles
|
||||
|
||||
|
||||
## A `Node2D._draw()`-based flat-fill background — deliberately NOT a
|
||||
## `ColorRect` (a `Control`). A `ColorRect` parented directly under a bare
|
||||
## `SubViewport` (no intervening `Control` container establishing its own
|
||||
## layout rect) did not reliably render in this harness: sampled pixels came
|
||||
## back fully transparent `(0,0,0,0)` regardless of the ColorRect's `color`/
|
||||
## `size`, even after entering the tree before sizing. `AtlasWindowOverlay`
|
||||
## itself is a bare `Node2D` using `draw_rect()` for its own background wash
|
||||
## (`AtlasWindowViewer._draw()`'s own `COLOR_BG` fill) — matching that same,
|
||||
## already-proven-working `Node2D.draw_rect()` pattern here sidesteps
|
||||
## whatever `Control`-specific layout/compositing gap caused the ColorRect
|
||||
## failure, rather than debugging that gap for its own sake.
|
||||
class _BackgroundRect extends Node2D:
|
||||
var fill_color: Color = Color.BLACK
|
||||
var fill_size: Vector2 = Vector2.ZERO
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color)
|
||||
|
||||
|
||||
static func _mock_window(center: Vector2i = Vector2i.ZERO, n: int = 64) -> Dictionary:
|
||||
# A checkerboard-ish morphology spread (not all-one-zone) so the built
|
||||
# composite has genuine color VARIATION, not just "one flat non-background
|
||||
# color" — closer to what a real terrain response looks like.
|
||||
var cells: int = n * n
|
||||
var morphology := PackedByteArray()
|
||||
var elev_q := PackedByteArray()
|
||||
morphology.resize(cells)
|
||||
elev_q.resize(cells)
|
||||
for i in range(cells):
|
||||
morphology[i] = (i % 4) as int # cycles through the 4 morphology zones
|
||||
elev_q[i] = (i * 7) % 100 as int
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"granularity_v2": "District",
|
||||
"morphology": morphology,
|
||||
"elev_q": elev_q,
|
||||
"temp_dc": [],
|
||||
"moisture_q": PackedByteArray(),
|
||||
"vegetation": PackedByteArray(),
|
||||
"glaciation": PackedByteArray(),
|
||||
}
|
||||
|
||||
|
||||
## Renders `overlay` (any Node2D with its own `_draw()` — an
|
||||
## AtlasWindowOverlay for (a)/(b) below, or a bare `_BackgroundRect` probe for
|
||||
## the harness sanity check) parented under a Node2D positioned/scaled the
|
||||
## way AtlasWindowViewer._canvas would be, into a fresh SubViewport, and
|
||||
## returns the captured Image. `zoom` mirrors AtlasWindowViewer._canvas.scale
|
||||
## (real `fit_window_view()` output is a small fraction, e.g. ~0.006 for a
|
||||
## whole-body tile mosaic per live round 4's own repro) — WITHOUT it, a
|
||||
## tile's real-world extent (TILE_N * cell_px = 102,400 local units) is so
|
||||
## much larger than any realistic test viewport that a mis-POSITIONED tile
|
||||
## still overlaps the frame purely by being gigantic, making the position
|
||||
## math this test exists to catch silently unfalsifiable (confirmed
|
||||
## directly: an earlier version of this test without a zoom scale kept
|
||||
## passing even with live round 4's tile-coordinate bug deliberately
|
||||
## reintroduced). Also confirmed directly: a hand-rolled duplicate of this
|
||||
## same SubViewport/settle-loop setup (the harness sanity check's ORIGINAL
|
||||
## standalone version) was measurably less reliable under a real driver than
|
||||
## going through this shared path — reuse over duplication here isn't just
|
||||
## tidiness, it's the more reliable rendering path.
|
||||
func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image:
|
||||
var sub_viewport := SubViewport.new()
|
||||
sub_viewport.size = VIEWPORT_SIZE
|
||||
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
sub_viewport.transparent_bg = false
|
||||
add_child(sub_viewport)
|
||||
auto_free(sub_viewport)
|
||||
|
||||
var bg := _BackgroundRect.new()
|
||||
bg.fill_color = COLOR_BG
|
||||
bg.fill_size = Vector2(VIEWPORT_SIZE)
|
||||
sub_viewport.add_child(bg)
|
||||
bg.queue_redraw()
|
||||
|
||||
var canvas := Node2D.new()
|
||||
canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 # center the composite's local (0,0)
|
||||
canvas.scale = Vector2(zoom, zoom)
|
||||
sub_viewport.add_child(canvas)
|
||||
canvas.add_child(overlay)
|
||||
overlay.queue_redraw()
|
||||
|
||||
# Bounded settle wait, NOT `await RenderingServer.frame_post_draw` — that
|
||||
# signal never fires under the dummy/headless driver (confirmed directly:
|
||||
# a first version of this file using it hung for the full 300s
|
||||
# tests/run-godot wall-clock cap and was force-killed, producing a FALSE
|
||||
# "0 tests, passed" result — exactly the silent-hang failure mode the
|
||||
# `_do_skip`/`_dummy_renderer_active()` gate (this file's header doc) now
|
||||
# avoids structurally instead). A fixed small number of `process_frame`
|
||||
# awaits settles real rendering — confirmed sufficient against a real
|
||||
# driver during this fix's own live verification.
|
||||
for _i in range(6):
|
||||
await get_tree().process_frame
|
||||
|
||||
return sub_viewport.get_texture().get_image()
|
||||
|
||||
|
||||
## Fraction of `image`'s pixels whose RGB differs from COLOR_BG (alpha
|
||||
## ignored — the ColorRect background is opaque, everything drawn on top of
|
||||
## it is what's under test).
|
||||
static func _non_background_fraction(image: Image) -> float:
|
||||
var w: int = image.get_width()
|
||||
var h: int = image.get_height()
|
||||
if w <= 0 or h <= 0:
|
||||
return 0.0
|
||||
var total: int = w * h
|
||||
var differing: int = 0
|
||||
var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
var px: Color = image.get_pixel(x, y)
|
||||
var px_rgb: Color = Color(px.r, px.g, px.b, 1.0)
|
||||
if not px_rgb.is_equal_approx(bg_rgb):
|
||||
differing += 1
|
||||
return float(differing) / float(total)
|
||||
|
||||
|
||||
## (a) Single-window path: a District-rung response must render as visibly
|
||||
## non-background pixels through AtlasWindowOverlay._draw()'s own
|
||||
## Rect2(0,0,extent,extent) draw call — the "does the OVERLAY actually paint
|
||||
## something for a real window" half of the gap. Positions it at the
|
||||
## SubViewport's center via the surrounding Node2D, mirroring _canvas's role
|
||||
## in the real viewer.
|
||||
##
|
||||
## Honest scope note: live round 4's SECOND bug (leaving `_view_offset`
|
||||
## stale across a rung crossing in `_maybe_reselect_rung()`) lived entirely
|
||||
## in AtlasWindowViewer's transform bookkeeping, ONE LAYER ABOVE this
|
||||
## overlay-only test's boundary — it never touched `_draw()` itself, so a
|
||||
## pure-overlay smoke test structurally cannot reproduce it (there is no
|
||||
## "stale vs. fresh offset" state to compare inside the overlay alone). That
|
||||
## regression's coverage is `_maybe_reselect_rung()`'s own unit tests in
|
||||
## test_atlas_zoom_ladder.gd. This test's job is narrower and still real:
|
||||
## proving the overlay's draw call itself produces visible output for
|
||||
## legitimate window data, closing the "the composite Rect2 call is
|
||||
## silently a no-op" class of bug regardless of which layer caused it.
|
||||
func test_single_window_draw_produces_visible_pixels() -> void:
|
||||
# Guarded early-return instead of the _do_skip fuzzer-arg convention:
|
||||
# gdUnit4 leaks one internal <Node> per fuzzer-skipped test, tripping the
|
||||
# orphan detector (exit 101) and bouncing the push gate even at 0 failures
|
||||
# (PR gate run 2026-07-22: "2 skipped | 2 orphans | Exit code: 101").
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new()
|
||||
var stub := _SingleWindowViewerStub.new()
|
||||
stub.window = _mock_window(Vector2i.ZERO, 32)
|
||||
overlay.viewer = stub
|
||||
|
||||
var image: Image = await _render_to_image(overlay)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"single-window composite must render VISIBLE non-background pixels — got"
|
||||
+ " only %.2f%% of the frame differing from COLOR_BG. This is exactly the"
|
||||
+ " shape of live round 4's second bug: _view_offset left stale across a"
|
||||
+ " rung crossing pushed the composite off-canvas, so nothing but"
|
||||
+ " background/chrome ever appeared, despite the underlying window data"
|
||||
+ " and draw calls being individually 'correct' in isolation."
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
|
||||
|
||||
## (b) Tile-mosaic path: a tile at a district center AWAY from the body's
|
||||
## own origin must still render as visibly non-background pixels once
|
||||
## correctly placed via `district_to_canvas_local()`'s shared held_center/
|
||||
## held_n convention — this is the path round 4's FIRST and THIRD bugs
|
||||
## (tile-local-origin math ignoring that convention, and per-tile
|
||||
## ImageTexture objects with no persistent reference being garbage-
|
||||
## collected/GPU-desynced before their draw command flushed) would both
|
||||
## have failed. Uses production-realistic scale (live round 4's own Lendel
|
||||
## repro: raw circumference ~19,139 districts) — see the tile-center
|
||||
## comment below for why scale matters here specifically.
|
||||
func test_tile_mosaic_draw_produces_visible_pixels() -> void:
|
||||
# Guarded early-return, not _do_skip — see the sibling test's comment.
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new()
|
||||
var stub := _TileModeViewerStub.new()
|
||||
var tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
var cell_px: float = stub.get_cell_pixel_size()
|
||||
stub.held_center = Vector2i.ZERO
|
||||
stub.held_n = 19139
|
||||
# A SINGLE tile chosen so the CORRECT canvas-local formula
|
||||
# (`district_to_canvas_local()`, anchored at `held_center - held_n/2`)
|
||||
# lands it centered in the viewport, while the round-4 BUGGY formula
|
||||
# (anchored at absolute district (0,0) directly) lands it almost
|
||||
# `held_n/2 * cell_px` local units away — tens of thousands of units at
|
||||
# this scale, i.e. genuinely fully off a 512x512 viewport, not just
|
||||
# "shifted but still overlapping" (confirmed by hand-computation: a
|
||||
# smaller/toy-scale version of this test stayed green with the bug
|
||||
# reintroduced, because the shift stayed within the viewport bounds
|
||||
# either way — this scale/center combination is chosen specifically to
|
||||
# avoid that false-negative).
|
||||
var half_tile: float = float(tile_n) * 0.5
|
||||
var half_body: float = float(stub.held_n) * 0.5
|
||||
var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body))
|
||||
stub.tiles = [
|
||||
{"center": lone_tile_center, "window": _mock_window(lone_tile_center, 64)},
|
||||
]
|
||||
overlay.viewer = stub
|
||||
|
||||
# A zoom small enough that the buggy-vs-correct shift (~half_body * cell_px
|
||||
# local units) is comfortably larger than the viewport — see the center
|
||||
# choice's own doc above for why this specific magnitude matters.
|
||||
var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px)
|
||||
var image: Image = await _render_to_image(overlay, zoom)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"tile mosaic must render VISIBLE non-background pixels across its tiles —"
|
||||
+ " got only %.2f%% of the frame differing from COLOR_BG. This is exactly"
|
||||
+ " the shape of live round 4's bugs: (1) tile local-origin computed"
|
||||
+ " relative to absolute district (0,0) instead of the shared"
|
||||
+ " held_center/held_n canvas-local convention pushed the whole mosaic"
|
||||
+ " off-canvas, and (2) even once correctly positioned, an unstored"
|
||||
+ " per-draw-call ImageTexture rendered as a blank/white gap despite"
|
||||
+ " provably-correct CPU-side pixel data — both invisible to any test that"
|
||||
+ " only inspects Dictionary/cache state, never an actual composited pixel."
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
@@ -14,19 +14,36 @@ extends GdUnitTestSuite
|
||||
# duplicated-load).
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) —
|
||||
## `u32::MAX`, the RESERVED KEY-SPACE TAG a real server ALWAYS puts in the
|
||||
## legacy `granularity` slot for every Region response (never a real
|
||||
## multiplier — District=1/Quarter=4 are the only legal wire multipliers).
|
||||
## Do NOT "fix" this to 1 — using a convenient value here is EXACTLY the gap
|
||||
## the live round caught (a mock that diverges from the wire in the one
|
||||
## field that matters silently un-repros the bug). See
|
||||
## AtlasWindowRequest's `_echoed_granularity_matches()` doc for the full
|
||||
## rationale.
|
||||
const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict, granularity-aware
|
||||
## (T-1150) — mirrors test_atlas_window_viewer.gd's own _mock_window(), with
|
||||
## granularity/min_wl_m added as optional params so callers can build both
|
||||
## rungs' echo shapes with one helper.
|
||||
## (T-1150, extended T-1152/T-1153 for granularity_v2) — mirrors
|
||||
## test_atlas_window_viewer.gd's own _mock_window(), with
|
||||
## granularity/min_wl_m/granularity_v2 added as optional params so callers
|
||||
## can build any rung's echo shape with one helper.
|
||||
static func _mock_window(
|
||||
center: Vector2i, n: int = 2, granularity: int = 1, min_wl_m: int = 0
|
||||
center: Vector2i,
|
||||
n: int = 2,
|
||||
granularity: int = 1,
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = "District"
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"granularity": granularity,
|
||||
"min_wl_m": min_wl_m,
|
||||
"granularity_v2": granularity_v2,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
@@ -57,12 +74,24 @@ func _make_request() -> Variant:
|
||||
## center/n must be dropped as stale, not accepted — a different rung's
|
||||
## derive answering a request for a different rung is exactly as stale as a
|
||||
## mismatched center (T-1150 extends §2's guard to this axis).
|
||||
##
|
||||
## **Live-round correction:** the mock MUST carry a mismatched
|
||||
## `granularity_v2` too (explicit `"Quarter"`, not `_mock_window()`'s
|
||||
## `"District"` default) — a real Quarter response ALWAYS carries
|
||||
## `granularity_v2: "Quarter"` on the wire, never the District default this
|
||||
## test's fixture used to leave implicit. Under the v2-authoritative-when-
|
||||
## present precedence rule (see on_response()'s own doc), a v2-MATCHING
|
||||
## response is accepted regardless of what the legacy int says — leaving
|
||||
## granularity_v2 at its District default here would have made this test
|
||||
## pass for the wrong reason (an accidentally-matching v2 field masking a
|
||||
## genuinely mismatched legacy int), exactly the class of gap the live round
|
||||
## caught in the oversized-orbital round-trip test.
|
||||
func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0)
|
||||
var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0, "Quarter")
|
||||
req.on_response(_mock_response("GJ380c", quarter_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
@@ -70,6 +99,94 @@ func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void:
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (a2) granularity_v2 mismatch on the echo -> dropped as stale (T-1152/T-1153,
|
||||
# the axis the legacy int alone cannot express — Region has no legacy value)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## request_now() can now ask for Region explicitly (T-1153's rung-reselect
|
||||
## caller) — a response echoing "District" for the SAME center/n must be
|
||||
## dropped as stale, the granularity_v2 twin of test (a) above, and the
|
||||
## ONLY guard that can catch this specific mismatch (the legacy int is
|
||||
## DISTRICT_GRANULARITY=1 on BOTH sides here, since Region has no legacy
|
||||
## representation — see WindowGranularity::legacy_u32()'s doc).
|
||||
func test_on_response_with_mismatched_granularity_v2_is_dropped_as_stale() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
var district_window: Dictionary = _mock_window(Vector2i(0, 0), 6400, 1, 0, "District")
|
||||
req.on_response(_mock_response("GJ380c", district_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a granularity_v2-mismatched response (District answering a Region request)"
|
||||
+ " must be dropped as stale, leaving the request still pending"
|
||||
).is_true()
|
||||
|
||||
|
||||
## The matching case: request_now() asking for Region, answered by a Region
|
||||
## echo at the SAME (center, n) — must be ACCEPTED and cached under the
|
||||
## Region key, retrievable on a follow-up request without a new network round
|
||||
## trip.
|
||||
##
|
||||
## **Live-round correction:** the mock's legacy `granularity` field is now
|
||||
## Dudley's ACTUAL wire sentinel (`WINDOW_GRANULARITY_REGION_KEY` =
|
||||
## `u32::MAX` = 4294967295), not a convenient `1` — the original version of
|
||||
## this test used `1`, which coincidentally matched the request's own
|
||||
## pinned `_granularity` and therefore never exercised the real mismatch a
|
||||
## live server actually produces. See _echoed_granularity_matches()'s own
|
||||
## doc (atlas_window_request.gd) for why this is load-bearing: without the
|
||||
## v2-authoritative-when-present fix, THIS test would have failed with the
|
||||
## real sentinel — it only passed before because the mock was wrong.
|
||||
func test_on_response_matching_granularity_v2_region_is_accepted_and_cached() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
var region_window: Dictionary = _mock_window(
|
||||
Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region"
|
||||
)
|
||||
req.on_response(_mock_response("GJ380c", region_window))
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a response carrying the REAL legacy sentinel (u32::MAX) in the old"
|
||||
+ " granularity slot must still be accepted — v2 is authoritative"
|
||||
+ " whenever present, the legacy field must not be compared at all"
|
||||
).is_false()
|
||||
|
||||
var received: Array = []
|
||||
req.window_ready.connect(func(w: Dictionary) -> void: received.append(w))
|
||||
req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
assert_int(received.size()).override_failure_message(
|
||||
"a second Region request at the same (center, n) must hit the cache"
|
||||
).is_equal(1)
|
||||
assert_bool(req.is_pending()).is_false()
|
||||
|
||||
|
||||
## **The direct precedence-rule proof (live-round finding #2, the sharpest
|
||||
## case):** a response whose `granularity_v2` MATCHES the request but whose
|
||||
## LEGACY `granularity` field could never possibly match (the Region
|
||||
## sentinel) must still be ACCEPTED — proving the legacy comparison is
|
||||
## SKIPPED entirely when v2 is present, not merely "also checked and
|
||||
## happens to pass." This is the literal shape of the live bug: real server
|
||||
## responses ALWAYS carry the Region sentinel in the legacy slot, so any
|
||||
## code path that still consults the legacy field when v2 is already
|
||||
## authoritative would drop every single one of these, forever.
|
||||
func test_on_response_v2_match_is_accepted_regardless_of_legacy_field_value() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(0, 0), 6400, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
var region_window: Dictionary = _mock_window(
|
||||
Vector2i(0, 0), 6400, SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, 0, "Region"
|
||||
)
|
||||
req.on_response(_mock_response("GJ380c", region_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"v2 match must be sufficient on its own — the legacy sentinel value must"
|
||||
+ " never be consulted once granularity_v2 is present on the response"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults
|
||||
# =============================================================================
|
||||
@@ -116,6 +233,48 @@ func test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted()
|
||||
assert_bool(req.is_pending()).is_false()
|
||||
|
||||
|
||||
## **Live-round sibling test (instruction #2's "old-server path stays
|
||||
## covered"):** a response that carries the LEGACY `granularity` key WITH AN
|
||||
## EXPLICIT VALUE (1, i.e. genuinely present, not merely defaulted via
|
||||
## absence — the case test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted
|
||||
## above doesn't exercise, since it omits the key entirely) but has NO
|
||||
## `granularity_v2` key at all — the true "hypothetically old, pre-T-1152
|
||||
## server" shape — must still be accepted for a plain District request via
|
||||
## the legacy-comparison FALLBACK branch in `_echoed_granularity_matches()`.
|
||||
## This is the other half of the v2-authoritative-when-present precedence
|
||||
## rule: v2 present -> v2 alone decides; v2 ABSENT -> legacy alone decides
|
||||
## (never both, never neither).
|
||||
func test_on_response_legacy_only_no_v2_key_still_accepted_for_district() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(4, 4), 2) # defaults to District granularity
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
# Legacy-only shape: "granularity" IS present (district=1), "granularity_v2"
|
||||
# key is absent entirely — not present-with-a-District-value, ABSENT.
|
||||
var legacy_only_window := {
|
||||
"center": [4, 4],
|
||||
"n": 2,
|
||||
"granularity": AtlasWindowRequest.DEFAULT_GRANULARITY,
|
||||
"min_wl_m": 0,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
assert_bool(legacy_only_window.has("granularity_v2")).override_failure_message(
|
||||
"sanity: this fixture must NOT carry granularity_v2 at all — that's the point"
|
||||
).is_false()
|
||||
|
||||
req.on_response(_mock_response("GJ380c", legacy_only_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a legacy-only response (granularity=1 present, granularity_v2 absent) must"
|
||||
+ " still be accepted for a District request via the legacy-fallback branch"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (c) n-clamp mirror (Tyre C1) — quarter n=32 stores clamped n=16
|
||||
# =============================================================================
|
||||
@@ -175,3 +334,123 @@ func test_oversized_n_request_stores_clamped_n_and_accepts_matching_echo() -> vo
|
||||
+ "already clamped to 64 before the request fired"
|
||||
)
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (d) Region clamp mirror (T-1152/T-1153) — mirrors
|
||||
# server/src/atlas/layer_proxy.rs's clamp_window_n_v2 EXACTLY, including the
|
||||
# Region branch's bounded halving loop.
|
||||
#
|
||||
# PR #192 review (Dudley, server-side analysis): the halving loop is
|
||||
# PROVABLY UNREACHABLE at current constants — the per-axis clamp to
|
||||
# SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) forecloses it. Brute-forced,
|
||||
# the max cell_grid_side over ALL reachable (post-per-axis-clamp) n is
|
||||
# exactly 64 — the wire-cap boundary itself, never over it — so the loop's
|
||||
# `>` guard is never true for any input. Ruling: the loop STAYS as
|
||||
# defensive code (a future constant change could make it reachable again),
|
||||
# but the test suite must not claim it "fires" when it provably doesn't.
|
||||
# See server/src/atlas/layer_proxy.rs's
|
||||
# clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs
|
||||
# for the server-side property-sweep pin this client-side suite mirrors.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## District/Quarter through the v2 mirror must be BYTE-IDENTICAL to the
|
||||
## legacy mirror — the server's own
|
||||
## `clamp_window_n_v2_delegates_to_legacy_for_district_and_quarter`
|
||||
## guarantee, restated client-side.
|
||||
func test_clamp_window_n_mirror_v2_matches_legacy_for_district_and_quarter() -> void:
|
||||
assert_int(
|
||||
AtlasWindowRequest._clamp_window_n_mirror_v2(32, AtlasWindowRequest.GRANULARITY_V2_QUARTER)
|
||||
).is_equal(AtlasWindowRequest._clamp_window_n_mirror(32, 4))
|
||||
assert_int(
|
||||
AtlasWindowRequest._clamp_window_n_mirror_v2(640, AtlasWindowRequest.GRANULARITY_V2_DISTRICT)
|
||||
).is_equal(AtlasWindowRequest._clamp_window_n_mirror(640, 1))
|
||||
|
||||
|
||||
## The clean Region boundary case: n=6,400 (DISTRICT_WINDOW_MAX_N_REGION,
|
||||
## the per-axis cap exactly) derives cell_grid_side(6400) = round(6400/100) =
|
||||
## 64, and 64² = 4,096 = WIRE_CAP_CELLS EXACTLY — the halving loop's `>`
|
||||
## condition is false at the boundary, so this must clamp to EXACTLY 6,400,
|
||||
## not halve further. This is the server's own
|
||||
## `clamp_window_n_v2_region_exact_boundary_n6400_uncontested` guarantee,
|
||||
## restated client-side (WIRE_CAP_CELLS_SQRT * DISTRICTS_PER_REGION is
|
||||
## DERIVED to land here exactly, per that constant's own doc).
|
||||
func test_clamp_window_n_mirror_v2_region_boundary_is_exact() -> void:
|
||||
var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(
|
||||
AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
)
|
||||
assert_int(n).is_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
|
||||
|
||||
## Region's per-axis cap: a raw `n` far over DISTRICT_WINDOW_MAX_N_REGION
|
||||
## (mirroring the server's own `region_request_oversized_n_clamps_and_echoes_clamped_n`
|
||||
## test's `DISTRICT_WINDOW_MAX_N_REGION * 10` shape) must clamp DOWN — never
|
||||
## trust the wire — and the result must satisfy BOTH invariants the server's
|
||||
## own test asserts: `n <= DISTRICT_WINDOW_MAX_N_REGION` AND
|
||||
## `cell_grid_side(n)^2 <= WIRE_CAP_CELLS`.
|
||||
func test_clamp_window_n_mirror_v2_region_oversized_n_clamps_within_both_bounds() -> void:
|
||||
var oversized: int = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10
|
||||
var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(
|
||||
oversized, AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
)
|
||||
assert_int(n).override_failure_message(
|
||||
"echoed n must be clamped to DISTRICT_WINDOW_MAX_N_REGION, not the raw oversized value"
|
||||
).is_less_equal(AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(n)
|
||||
assert_int(side * side).override_failure_message(
|
||||
"clamped cell count must never exceed WIRE_CAP_CELLS at Region granularity either"
|
||||
).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS)
|
||||
|
||||
|
||||
## PR #192 review (Dudley's unreachability finding, applied client-side): the
|
||||
## halving loop's `>` guard is PROVABLY never true at current constants — the
|
||||
## per-axis clamp to SERVER_DISTRICT_WINDOW_MAX_N_REGION (6,400) happens
|
||||
## FIRST and unconditionally, and cell_grid_side(6400) = 64 lands EXACTLY on
|
||||
## the wire-cap boundary (64² = WIRE_CAP_CELLS), never over it. A prior
|
||||
## version of this test claimed n=6,450 "exercises" the loop firing — it does
|
||||
## not: 6,450 clamps to 6,400 before the loop ever runs, so the test was
|
||||
## passing on the per-axis clamp alone, not on anything the loop itself did
|
||||
## (the same mock-diverges-from-reality class of bug hunted in review round
|
||||
## 2). Reframed as a property sweep, mirroring the server's own
|
||||
## `clamp_window_n_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs`
|
||||
## (Dudley): for every raw n across the legal range (including values far
|
||||
## past the per-axis cap), (i) the per-axis-clamped n never gets modified any
|
||||
## further by the loop — pre-loop n and post-clamp n are byte-identical —
|
||||
## and (ii) the wire-cap invariant holds regardless. The loop itself stays as
|
||||
## defensive code (a future constant change could make it reachable again);
|
||||
## this test documents that it is a no-op today rather than asserting a
|
||||
## behavior that never actually happens.
|
||||
func test_clamp_window_n_mirror_v2_region_per_axis_cap_alone_satisfies_wire_cap_for_all_inputs() -> void:
|
||||
var sample_raw_ns: Array = [
|
||||
1, 100, 6399, 6400, 6401, 6450, 6500,
|
||||
AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION * 10,
|
||||
]
|
||||
for raw_n: int in sample_raw_ns:
|
||||
var pre_loop_n: int = clampi(raw_n, 1, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(
|
||||
raw_n, AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
)
|
||||
assert_int(clamped_n).override_failure_message(
|
||||
(
|
||||
"the per-axis clamp alone must already satisfy the wire cap for"
|
||||
+ " raw_n=%d — the halving loop is provably unreachable at current"
|
||||
+ " constants (max cell_grid_side over all reachable n is exactly"
|
||||
+ " 64, the wire-cap boundary itself), so it must never further"
|
||||
+ " modify what the per-axis clamp already produced"
|
||||
) % raw_n
|
||||
).is_equal(pre_loop_n)
|
||||
|
||||
var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(clamped_n)
|
||||
assert_int(side * side).override_failure_message(
|
||||
"the wire-cap invariant must hold for raw_n=%d regardless" % raw_n
|
||||
).is_less_equal(AtlasWindowRequest.SERVER_WIRE_CAP_CELLS)
|
||||
|
||||
|
||||
## n smaller than one region (n < 100) must clamp its cell-grid side to a
|
||||
## minimum of 1 — cell_grid_side_for_window()'s own `.max(1)` — never a
|
||||
## degenerate 0x0 grid, matching WindowGranularity::cell_grid_side's own
|
||||
## documented minimum.
|
||||
func test_cell_grid_side_region_mirror_minimum_is_one() -> void:
|
||||
assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(1)).is_equal(1)
|
||||
assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(50)).is_equal(1)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for
|
||||
## AtlasWindowTileSet — the orbital rest-state multi-window mosaic
|
||||
## orchestration. Same hand-built-response-dict conventions as
|
||||
## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is
|
||||
## about the ORCHESTRATION (N tiles, progressive per-tile arrival,
|
||||
## teardown), not the tile-grid MATH (already covered directly against
|
||||
## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd).
|
||||
class_name TestAtlasWindowTileSet
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
|
||||
static func _mock_window(center: Vector2i, n: int) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([1, 2, 3, 4]),
|
||||
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
||||
"temp_dc": [0, 0, 0, 0],
|
||||
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
||||
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
||||
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
func _make_tile_set() -> Variant:
|
||||
var owner_stub := RefCounted.new()
|
||||
var ts = auto_free(AtlasWindowTileSet.new(owner_stub))
|
||||
add_child(ts)
|
||||
return ts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# enter() — tile grid computation + one request per tile
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## enter() on a real, tiling-sized body must produce the SAME tile count
|
||||
## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own
|
||||
## live-round number.
|
||||
func test_enter_produces_the_expected_tile_count_for_lendel() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_int(ts.get_tile_count()).is_equal(6)
|
||||
assert_bool(ts.is_multi_tile()).is_true()
|
||||
|
||||
|
||||
## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case
|
||||
## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION
|
||||
## (not just the grid math) handles it without crashing or requesting zero
|
||||
## tiles.
|
||||
func test_enter_tiny_body_produces_one_tile() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("TinyBody", 50.0)
|
||||
assert_int(ts.get_tile_count()).is_equal(1)
|
||||
assert_bool(ts.is_multi_tile()).is_false()
|
||||
|
||||
|
||||
## Every tile must start with a null window (nothing has arrived yet) and
|
||||
## the tile set must not report "fully arrived" before any response lands.
|
||||
func test_enter_all_tiles_start_unarrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
for tile: Dictionary in ts.get_tiles():
|
||||
assert_that(tile["window"]).is_null()
|
||||
assert_bool(ts.is_fully_arrived()).is_false()
|
||||
|
||||
|
||||
## An empty tile set (never entered) must not report "fully arrived" either
|
||||
## — an empty AND-over-nothing must not vacuously read true.
|
||||
func test_empty_tile_set_is_not_fully_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
assert_bool(ts.is_fully_arrived()).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Progressive per-tile arrival (design doc §4: "with visible refinement as
|
||||
# tiles complete") — each tile's response is independent of every other's.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Delivering ONE tile's response must populate ONLY that tile's window,
|
||||
## leaving every other tile still null — the direct "progressive, not
|
||||
## block-on-all" regression.
|
||||
func test_one_tile_arriving_does_not_affect_the_others() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var first_center: Vector2i = tiles[0]["center"]
|
||||
var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
var updated_tiles: Array = ts.get_tiles()
|
||||
assert_that(updated_tiles[0]["window"]).override_failure_message(
|
||||
"the tile whose response arrived must have its window populated"
|
||||
).is_equal(window)
|
||||
for i in range(1, updated_tiles.size()):
|
||||
assert_that(updated_tiles[i]["window"]).override_failure_message(
|
||||
"tile %d must still be unarrived — only tile 0's response was delivered" % i
|
||||
).is_null()
|
||||
|
||||
|
||||
## tile_ready must fire with the INDEX of the tile that actually arrived —
|
||||
## the viewer/overlay needs this to know WHICH tile to redraw, not just
|
||||
## "something changed".
|
||||
func test_tile_ready_signal_fires_with_the_correct_index() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var received_indices: Array = []
|
||||
ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index))
|
||||
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var second_center: Vector2i = tiles[1]["center"]
|
||||
var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_int(received_indices.size()).is_equal(1)
|
||||
assert_int(received_indices[0]).is_equal(1)
|
||||
|
||||
|
||||
## Delivering EVERY tile's response must flip is_fully_arrived() to true —
|
||||
## the mosaic-complete signal the viewer/legend chrome can use.
|
||||
func test_all_tiles_arriving_flips_fully_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
for tile: Dictionary in tiles:
|
||||
var window: Dictionary = _mock_window(
|
||||
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_bool(ts.is_fully_arrived()).override_failure_message(
|
||||
"once every tile's response has arrived, the tile set must report fully arrived"
|
||||
).is_true()
|
||||
|
||||
|
||||
## A response for a body the tile set is NOT currently showing (a stale
|
||||
## response from a body the player has since navigated away from) must not
|
||||
## be adopted by any tile — the SAME body_id staleness guard every other
|
||||
## AtlasWindowRequest-based path already relies on (this is inherited for
|
||||
## free since each tile IS an AtlasWindowRequest, but pinned here as an
|
||||
## orchestration-level regression too).
|
||||
func test_response_for_a_different_body_is_ignored() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
var window: Dictionary = _mock_window(
|
||||
tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window))
|
||||
|
||||
assert_that(ts.get_tiles()[0]["window"]).is_null()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Teardown — re-entering (a fresh body, or the same body again) must not
|
||||
# leave stale tile request nodes wired up.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or
|
||||
## switching to a different body) must replace the tile set entirely — the
|
||||
## OLD tiles' indices/centers must not linger.
|
||||
func test_second_enter_replaces_the_tile_set() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_count: int = ts.get_tile_count()
|
||||
assert_int(first_count).is_equal(6)
|
||||
|
||||
ts.enter("TinyBody", 50.0)
|
||||
assert_int(ts.get_tile_count()).override_failure_message(
|
||||
"a second enter() must fully replace the tile set, not append to it"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
## A response matching an OLD tile set's (body, center) — arriving AFTER a
|
||||
## second enter() has already torn it down — must not be adopted (or crash):
|
||||
## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no
|
||||
## longer references it, so a stale signal (if it could somehow still fire)
|
||||
## has no live entry left to update.
|
||||
func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var old_tiles: Array = ts.get_tiles()
|
||||
var old_center: Vector2i = old_tiles[0]["center"]
|
||||
|
||||
ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid
|
||||
|
||||
# A response shaped like it's answering the OLD tile set's first tile —
|
||||
# must not crash, and must not corrupt the NEW tile set's single tile.
|
||||
var stale_window: Dictionary = _mock_window(
|
||||
old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window))
|
||||
|
||||
assert_int(ts.get_tile_count()).is_equal(1)
|
||||
@@ -144,11 +144,15 @@ func test_set_view_and_getters_round_trip() -> void:
|
||||
assert_that(v.get_view_offset()).is_equal(Vector2(30.0, -10.0))
|
||||
|
||||
|
||||
## T-1153: MIN_ZOOM widened to 0.0005 (from the pre-ladder 0.5) so a
|
||||
## gas-giant-scale body's enter_orbital() fit zoom is never itself clamped —
|
||||
## see MIN_ZOOM's own doc. Values here are chosen well outside the new wide
|
||||
## range on both ends, not the old range's boundary values.
|
||||
func test_set_view_clamps_to_min_max_zoom() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.set_view(0.01, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.001)
|
||||
v.set_view(0.0000001, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MIN_ZOOM, 0.0001)
|
||||
v.set_view(1000.0, Vector2.ZERO)
|
||||
assert_that(v.get_view_zoom()).is_equal_approx(AtlasWindowViewer.MAX_ZOOM, 0.001)
|
||||
|
||||
@@ -352,25 +356,28 @@ func test_wasd_diagonal_pan_is_not_faster_than_single_axis() -> void:
|
||||
).is_equal_approx(axis_distance, 0.01)
|
||||
|
||||
|
||||
## The real scene-tree path: DistrictScreen -> AtlasWindowViewer. Unlike
|
||||
## drag (which needed _gui_input event delivery, hence the old
|
||||
## "does an ancestor eat the event" test), WASD pan lives in _process() —
|
||||
## Godot delivers _process() to every node in the tree regardless of Control
|
||||
## mouse_filter/ancestry (there is no "topmost control" routing for
|
||||
## per-frame process callbacks the way there is for _gui_input), so there is
|
||||
## no equivalent "does DistrictScreen eat it" question for _process() itself.
|
||||
## What DOES still matter through the real chain is _is_over_ui()'s edge-
|
||||
## scroll suppression and visibility gating — pinned directly below instead.
|
||||
func test_wasd_pan_reaches_viewer_through_district_screen_chain() -> void:
|
||||
var screen: DistrictScreen = auto_free(DistrictScreen.new())
|
||||
## The real scene-tree path: RegionalScreen -> AtlasWindowViewer (T-1153 —
|
||||
## RegionalScreen is now the WHOLE ladder's nav entry, superseding the
|
||||
## retired DistrictScreen nav hop; see atlas_app.gd's own doc for why the
|
||||
## separate "district" screen retired). Unlike drag (which needed
|
||||
## _gui_input event delivery, hence the old "does an ancestor eat the
|
||||
## event" test), WASD pan lives in _process() — Godot delivers _process()
|
||||
## to every node in the tree regardless of Control mouse_filter/ancestry
|
||||
## (there is no "topmost control" routing for per-frame process callbacks
|
||||
## the way there is for _gui_input), so there is no equivalent "does the
|
||||
## screen eat it" question for _process() itself. What DOES still matter
|
||||
## through the real chain is _is_over_ui()'s edge-scroll suppression and
|
||||
## visibility gating — pinned directly below instead.
|
||||
func test_wasd_pan_reaches_viewer_through_regional_screen_chain() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
screen.enter({"body": {"body_id": "GJ380c"}, "district_center": Vector2i(0, 0)})
|
||||
screen.enter({"body": {"body_id": "GJ380c"}, "system": {}})
|
||||
var offset_before: Vector2 = screen._viewer.get_view_offset()
|
||||
|
||||
screen._viewer._apply_pan_delta(Vector2(1.0, 0.0), 0.1)
|
||||
|
||||
assert_that(screen._viewer.get_view_offset()).override_failure_message(
|
||||
"a pan tick driven through DistrictScreen's child viewer must still move"
|
||||
"a pan tick driven through RegionalScreen's child viewer must still move"
|
||||
+ " _view_offset — no ancestor in the real screen chain blocks it"
|
||||
).is_not_equal(offset_before)
|
||||
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
## T-1153 (D-226 T-1143-rulings amendment): tests for the continuous
|
||||
## cursor-anchored zoom ladder — enter_orbital() (the canonical planetary
|
||||
## frame), progressive refinement (held composite survives a rung-crossing
|
||||
## request), the full-zoom-out reset (Jeroen's HARD condition), rung
|
||||
## reselection on zoom, and E/W wrap + pole-wall clamps at Region
|
||||
## granularity. Split out of test_atlas_window_viewer.gd (which owns the
|
||||
## pre-T-1153 window-viewer behavior — entry, cache reuse, WASD/edge-scroll,
|
||||
## fit-and-center) purely for file-length reasons (gdlint max-file-lines);
|
||||
## same instantiation/mock-response conventions as that file, not a
|
||||
## different testing philosophy.
|
||||
class_name TestAtlasZoomLadder
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## Dudley's WINDOW_GRANULARITY_REGION_KEY (server/src/atlas/layer_proxy.rs) —
|
||||
## `u32::MAX`, a RESERVED KEY-SPACE TAG the real server ALWAYS puts in the
|
||||
## legacy `granularity` slot for every Region response (never a real
|
||||
## multiplier — District=1/Quarter=4 are the only legal wire multipliers).
|
||||
## Do NOT "fix" this to 1 — that would silently un-repro the live-round bug
|
||||
## this constant exists to guard against (a real server's actual wire byte,
|
||||
## not a convenient test value). See _echoed_granularity_matches()'s own doc
|
||||
## (atlas_window_request.gd) for why this value can NEVER equal a client's
|
||||
## stored `_granularity` (which stays pinned at DISTRICT_GRANULARITY=1 for
|
||||
## every rung a T-1152-aware client requests) — that mismatch is exactly
|
||||
## what silently dropped every Region response before the v2-authoritative
|
||||
## fix.
|
||||
const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors
|
||||
## test_atlas_window_viewer.gd's own _mock_window().
|
||||
static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: enter_orbital() — the canonical planetary frame, the ladder's TOP
|
||||
# REST STATE (Jeroen's HARD condition, D-226 T-1143-rulings amendment).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## enter_orbital() must center on district (0,0) — "district (0,0) sits at
|
||||
## lon 0 / the equator" (AtlasDescendGeometry's own doc).
|
||||
func test_enter_orbital_centers_on_the_canonical_origin() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
assert_that(v._held_center).is_equal(Vector2i.ZERO)
|
||||
|
||||
|
||||
## enter_orbital() must request at Region granularity — the orbital view IS
|
||||
## the Region rung at high n, not a separate screen/mode (the ticket's own
|
||||
## framing).
|
||||
func test_enter_orbital_requests_region_granularity() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
|
||||
## **Superseded by live round 3's tiling fix — retargeted, not deleted.**
|
||||
## GJ380c/Lendel (radius 6238.4 km) was the ORIGINAL single-window C1 repro
|
||||
## (raw cols ~19,139 vs. the 6,400 clamp ceiling) — but that SAME threshold
|
||||
## (`DISTRICT_WINDOW_MAX_N_REGION * DISTRICT_M` = the coverage ceiling
|
||||
## `compute_tile_grid()` tiles past) means any body needing the n-clamp ALSO
|
||||
## needs tiling: there is no real body where enter_orbital() takes the
|
||||
## single-window path with a raw `n` big enough to require clamping.
|
||||
## GJ380c now correctly enters TILE mode (test_enter_orbital_n_is_the_clamped_value_not_raw_circumference's
|
||||
## old assertion on a single clamped `_held_n` no longer applies — see
|
||||
## test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent below for
|
||||
## what `_held_n` means in tile mode instead). The single-window clamp-mirror
|
||||
## fix itself remains covered: `_enter_at_rung()`'s own doc/the clamp
|
||||
## mirror's unit tests (test_atlas_window_request.gd) pin the formula
|
||||
## directly, and test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement
|
||||
## exercises the SAME clamp-mirror lesson at the reselect (not entry)
|
||||
## boundary, which single-window mode still reaches on the way DOWN from a
|
||||
## tile-mode zoom-in.
|
||||
func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel)
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var raw_cols: int = int(extent["cols"])
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"GJ380c/Lendel needs tiling — enter_orbital() must have entered tile mode"
|
||||
).is_true()
|
||||
# In TILE mode, _held_n is the WHOLE body's extent (unclamped) — each
|
||||
# TILE clamps its own request independently inside AtlasWindowTileSet
|
||||
# (see that file's own tests), so _held_n here is NOT expected to equal
|
||||
# any single clamped value the way single-window mode's is.
|
||||
assert_int(v._held_n).is_equal(raw_cols)
|
||||
|
||||
|
||||
## **The live-round-3 regression, end to end for TILE mode:** enter_orbital()
|
||||
## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response
|
||||
## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old
|
||||
## granularity slot — exactly what a real server sends) must be ACCEPTED
|
||||
## into that tile's own slot — not silently dropped. This exercises BOTH
|
||||
## live-round fixes (the v2-authoritative precedence AND per-tile clamping)
|
||||
## through the tile-set path specifically, complementing
|
||||
## test_atlas_window_tile_set.gd's own more granular orchestration tests.
|
||||
func test_enter_orbital_tile_mode_accepts_a_wire_accurate_tile_response() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
|
||||
var tile_set = v.get_tile_set()
|
||||
var tiles: Array = tile_set.get_tiles()
|
||||
assert_int(tiles.size()).is_greater(1)
|
||||
var first_tile_center: Vector2i = tiles[0]["center"]
|
||||
|
||||
var tile_window: Dictionary = {
|
||||
"center": [first_tile_center.x, first_tile_center.y],
|
||||
"n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION,
|
||||
"granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window))
|
||||
|
||||
assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message(
|
||||
"a wire-accurate response (clamped n, Region granularity_v2, the legacy"
|
||||
+ " sentinel) for the first tile must be ACCEPTED into that tile's slot"
|
||||
).is_equal(tile_window)
|
||||
|
||||
|
||||
## A no-radius body (tiny test body) has no circumference concept —
|
||||
## enter_orbital() falls back to the District-rung default window rather
|
||||
## than crashing or deriving a degenerate n.
|
||||
func test_enter_orbital_no_radius_body_falls_back_to_district() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter_orbital({"body_id": "GJ380c"}, {})
|
||||
assert_str(v._held_granularity_v2).is_equal("District")
|
||||
assert_int(v._held_n).is_equal(AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: progressive refinement — the held composite survives until the
|
||||
# replacement arrives (§6 "no mode flip": never a blank frame, never a
|
||||
# clear-then-redraw).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The core acceptance test: once a window is held, a request for a
|
||||
## DIFFERENT rung being in-flight must NOT clear `_window` — the old
|
||||
## composite stays exactly what get_district_window() returns until the new
|
||||
## rung's response actually arrives and is adopted.
|
||||
func test_held_window_survives_while_a_different_rung_request_is_in_flight() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
assert_that(v.get_district_window()).is_equal(district_window)
|
||||
|
||||
# Simulate a rung-reselect firing a NEW (Region) request without the
|
||||
# response having arrived yet — direct call, mirroring what
|
||||
# _maybe_reselect_rung() does internally.
|
||||
v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region")
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"the OLD composite must survive while a different-rung request is in"
|
||||
+ " flight — no blank frame, no premature clear"
|
||||
).is_equal(district_window)
|
||||
|
||||
|
||||
## Once the new rung's response actually arrives (matching the CURRENTLY
|
||||
## in-flight request's granularity_v2), it swaps in — the composite reference
|
||||
## changes from the old rung's window to the new one.
|
||||
func test_new_rung_window_swaps_in_once_it_arrives() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
|
||||
v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region")
|
||||
var region_window: Dictionary = {
|
||||
"center": [10, 20], "n": 2, "granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([1, 2, 3, 4]),
|
||||
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
||||
"temp_dc": [0, 0, 0, 0],
|
||||
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
||||
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
||||
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", region_window))
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"once the new rung's matching response arrives, it must swap in"
|
||||
).is_equal(region_window)
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
|
||||
## refresh() clear()s via queue_free() (deferred, not synchronous) — a legend
|
||||
## that has refreshed more than once in the same frame (build-time refresh at
|
||||
## _ready(), then an entry-time refresh) can have STALE not-yet-freed
|
||||
## children still parented alongside the new ones. add_component() always
|
||||
## APPENDS, so the current ImplantHeader is the LAST one in the list, never
|
||||
## assumed to be [0].
|
||||
static func _current_legend_header(legend_panel) -> ImplantHeader:
|
||||
var children: Array = legend_panel.get_implant_children()
|
||||
for i in range(children.size() - 1, -1, -1):
|
||||
if children[i] is ImplantHeader:
|
||||
return children[i]
|
||||
return null
|
||||
|
||||
|
||||
## PR #192 review (Araminta, BLOCKING): the legend subtitle used to hardcode
|
||||
## District's own "2.048 km/cell" — a 100x lie whenever the viewer actually
|
||||
## holds Region (204.8 km/cell). While in the orbital tile-mode rest state
|
||||
## (Region granularity), the legend must read Region's real spacing, not the
|
||||
## stale District literal.
|
||||
func test_legend_subtitle_reflects_region_spacing_in_tile_mode() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling, so enters at Region
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
var header: ImplantHeader = _current_legend_header(v._legend_panel)
|
||||
assert_str(header._subtitle_label.text).override_failure_message(
|
||||
"legend subtitle must reflect Region's real 204.800 km/cell spacing while"
|
||||
+ " the viewer holds Region granularity, not a hardcoded District figure"
|
||||
).contains("204.800 km/cell")
|
||||
|
||||
|
||||
## Same bug, the other direction: after crossing INTO a single-window District
|
||||
## rung, the legend must re-render with District's own spacing — proving the
|
||||
## legend actually refreshes on a rung change rather than being stuck at
|
||||
## whatever it showed on the FIRST refresh() call (T-1153's _build_legend_panel()
|
||||
## fires one at _ready() time, before any real rung is held).
|
||||
func test_legend_subtitle_reflects_district_spacing_after_crossing_in() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
assert_str(v._held_granularity_v2).is_equal("District")
|
||||
|
||||
var header: ImplantHeader = _current_legend_header(v._legend_panel)
|
||||
assert_str(header._subtitle_label.text).override_failure_message(
|
||||
"legend subtitle must re-render at District's own 2.048 km/cell spacing"
|
||||
+ " once the viewer holds a District-rung window — proving refresh() is"
|
||||
+ " actually wired to the rung change, not just called once at build time"
|
||||
).contains("2.048 km/cell")
|
||||
|
||||
|
||||
## A response for a rung OTHER than what's currently requested (e.g. a
|
||||
## District response arriving after the viewer has already moved on to a
|
||||
## Region request — a rapid wheel-zoom race) must be discarded as stale, the
|
||||
## held composite untouched.
|
||||
func test_stale_rung_response_after_moving_on_is_discarded() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
|
||||
v._window_request.request_debounced("GJ380c", Vector2i(10, 20), 2, "Region")
|
||||
# A LATE district-rung response for the same (center, n) arrives after the
|
||||
# viewer has already moved on to requesting Region — must be dropped.
|
||||
var late_district_window: Dictionary = _mock_window(Vector2i(10, 20), 2)
|
||||
late_district_window["morphology"] = PackedByteArray([9, 9, 9, 9]) # distinguishable payload
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", late_district_window))
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"a stale response for a rung the viewer has since moved on from must be discarded"
|
||||
).is_equal(district_window)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: full-zoom-out reset (Jeroen's HARD condition).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Directly at the canonical frame already (center (0,0), Region granularity)
|
||||
## must be a no-op — never re-fights a player zooming back IN from the top.
|
||||
func test_reset_to_canonical_frame_is_noop_when_already_there() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
v._held_center = Vector2i.ZERO
|
||||
v._held_granularity_v2 = "Region"
|
||||
var fired: bool = v._maybe_reset_to_canonical_frame()
|
||||
assert_bool(fired).override_failure_message(
|
||||
"already at the canonical frame — the reset must not re-fire"
|
||||
).is_false()
|
||||
|
||||
|
||||
## A no-radius body must never trigger the reset (no circumference concept —
|
||||
## matches enter_orbital()'s own guard).
|
||||
func test_reset_to_canonical_frame_never_fires_for_no_radius_body() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(5, 5), 2)
|
||||
var fired: bool = v._maybe_reset_to_canonical_frame()
|
||||
assert_bool(fired).is_false()
|
||||
|
||||
|
||||
## Away from the canonical frame (a drifted District-rung pan/zoom state)
|
||||
## with a fully-zoomed-out world extent must reset — the direct wiring test
|
||||
## for Jeroen's HARD condition: enter() at a far-off center, then force the
|
||||
## view zoom low enough that the displayed extent covers the whole body.
|
||||
func test_reset_to_canonical_frame_fires_and_re_centers_when_fully_zoomed_out() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 50.0 # tiny synthetic body — small circumference, reachable by a modest zoom-out
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": radius_km}, {}, Vector2i(500, 10), 32)
|
||||
# Force a very low zoom — a huge displayed world extent, comfortably over
|
||||
# this tiny body's whole circumference.
|
||||
v._view_zoom = AtlasWindowViewer.MIN_ZOOM
|
||||
|
||||
var fired: bool = v._maybe_reset_to_canonical_frame()
|
||||
|
||||
assert_bool(fired).override_failure_message(
|
||||
"a fully-zoomed-out view on a real-radius body must trigger the reset"
|
||||
).is_true()
|
||||
assert_that(v._held_center).override_failure_message(
|
||||
"the reset must re-center on the canonical origin (0,0)"
|
||||
).is_equal(Vector2i.ZERO)
|
||||
assert_str(v._held_granularity_v2).override_failure_message(
|
||||
"the reset must land on the Region rung — the ladder's top rest state"
|
||||
).is_equal("Region")
|
||||
|
||||
|
||||
## Live round 5's OWN repro, end to end: enter a TILING body's canonical
|
||||
## frame, wheel-zoom IN far enough to cross out of tile mode (leaving
|
||||
## `_held_granularity_v2` STALE at "Region" — a real, expected lag per
|
||||
## `_maybe_reselect_rung()`'s own "does NOT touch _held_granularity_v2"
|
||||
## doc, not a bug in that function), then wheel-zoom back OUT past the
|
||||
## fully-zoomed-out threshold. The reset must fire and land EXACTLY on
|
||||
## enter_orbital()'s own fit zoom for this body/viewport — not merely
|
||||
## re-center while leaving `_view_zoom` wherever continued `_zoom_at()`
|
||||
## scaling left it. Before the fix, the stale "Region" granularity
|
||||
## satisfied the guard's OLD (center + granularity only) check forever,
|
||||
## so the reset never fired again and `_view_zoom` kept shrinking via
|
||||
## plain multiplication all the way to MIN_ZOOM.
|
||||
func test_reset_after_crossing_out_and_back_snaps_to_the_canonical_fit_zoom() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: Lendel must enter tile mode — this repro needs a TILING body,"
|
||||
+ " since that's where _held_granularity_v2 can lag is_tile_mode()"
|
||||
).is_true()
|
||||
|
||||
# Zoom IN far enough to cross out of tile mode (matching
|
||||
# test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen's
|
||||
# own gesture shape).
|
||||
var cursor_pos := Vector2(800.0, 450.0)
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: this test needs to actually leave tile mode before zooming back out"
|
||||
).is_false()
|
||||
assert_str(v._held_granularity_v2).override_failure_message(
|
||||
"sanity: _held_granularity_v2 must be STALE at Region here (no mock response"
|
||||
+ " ever adopted a new value) — this is the exact lagging-field condition"
|
||||
+ " the guard fix targets, not an artificial setup"
|
||||
).is_equal("Region")
|
||||
|
||||
# Zoom back OUT past the fully-zoomed-out threshold — the reset must fire
|
||||
# (possibly after a few more _zoom_at() ticks, matching a real wheel
|
||||
# gesture rather than asserting it fires on the very first step back).
|
||||
for _i in range(200):
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
if v.is_tile_mode():
|
||||
break
|
||||
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"zooming back out past the threshold must re-fire the reset and land back"
|
||||
+ " in tile mode — the stale-granularity guard bug left this permanently false"
|
||||
).is_true()
|
||||
assert_that(v._held_center).is_equal(Vector2i.ZERO)
|
||||
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM
|
||||
)
|
||||
assert_float(v._view_zoom).override_failure_message(
|
||||
(
|
||||
"post-reset _view_zoom (%.6f) must equal enter_orbital()'s own fit zoom"
|
||||
+ " (%.6f) for this body/viewport — Jeroen's condition is the ORIGINAL"
|
||||
+ " frame (center AND offset AND fit zoom), not merely re-centered at"
|
||||
+ " whatever zoom continued _zoom_at() scaling left behind"
|
||||
)
|
||||
% [v._view_zoom, expected_fit["zoom"]]
|
||||
).is_equal_approx(float(expected_fit["zoom"]), 0.000001)
|
||||
|
||||
|
||||
## Live round 5's OWN live-drive repro, exactly: a REAL wheel gesture does
|
||||
## NOT stop the instant the reset first fires — the coordinator's own
|
||||
## tmp_drive_ladder.gd keeps sending wheel-down ticks toward a fixed target
|
||||
## zoom (0.004, chosen below the fit zoom) regardless of the reset. This
|
||||
## test reproduces that shape directly: continue zooming out PAST the point
|
||||
## where the reset first re-enters tile mode, all the way to a target zoom
|
||||
## BELOW the fit value. Before the round-5 fix, `_view_zoom` drifted back
|
||||
## down from the fit value on every subsequent `_zoom_at()` tick while the
|
||||
## mode/center/granularity guard read "already canonical" and silently let
|
||||
## it drift, landing on whatever the LOOP's target zoom happened to be
|
||||
## instead of the fit value. **Live round 6 update:** the MECHANISM that
|
||||
## now holds this assertion changed — `_zoom_at()`'s own zoom FLOOR (not a
|
||||
## re-firing reset) is what keeps `_view_zoom` pinned at fit through
|
||||
## continued zoom-out ticks; see `_maybe_reset_to_canonical_frame()`'s own
|
||||
## doc for why re-firing on every tick caused a request storm. This test's
|
||||
## own assertions are unchanged — only the doc below was updated to match.
|
||||
func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
var expected_fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
v.size, n, AtlasWindowViewer.CELL_PIXEL_SIZE, AtlasWindowViewer.MIN_ZOOM, AtlasWindowViewer.MAX_ZOOM
|
||||
)
|
||||
var fit_zoom: float = float(expected_fit["zoom"])
|
||||
|
||||
# Zoom IN far enough to leave tile mode (same shape as the test above).
|
||||
var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).is_false()
|
||||
|
||||
# Zoom back OUT toward a target BELOW the fit zoom — matching
|
||||
# tmp_drive_ladder.gd's own `_zoom_until(wv, 0.004, false)` exactly
|
||||
# (Lendel's own fit zoom is ~0.00627, comfortably above this target),
|
||||
# WITHOUT stopping early the moment tile mode is first regained. A real
|
||||
# wheel gesture has no way to know when the reset internally fires.
|
||||
var target_zoom := 0.004
|
||||
for _i in range(200):
|
||||
if v._view_zoom <= target_zoom:
|
||||
break
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"after continued zoom-out past the reset point, the view must settle back"
|
||||
+ " into tile mode — a genuinely re-snapped canonical frame can't have zoomed"
|
||||
+ " OUT further than the fit value in the first place"
|
||||
).is_true()
|
||||
assert_float(v._view_zoom).override_failure_message(
|
||||
(
|
||||
"post-reset _view_zoom (%.6f) must equal the canonical fit zoom (%.6f) even"
|
||||
+ " though the wheel gesture continued past the point where the reset first"
|
||||
+ " fired (target was %.6f, BELOW the fit zoom) — _zoom_at()'s own zoom floor"
|
||||
+ " must keep pinning it at fit through every subsequent tick, not just once"
|
||||
)
|
||||
% [v._view_zoom, fit_zoom, target_zoom]
|
||||
).is_equal_approx(fit_zoom, 0.000001)
|
||||
|
||||
|
||||
## Live round 6's ANTI-STORM test — the exact repro the coordinator's live
|
||||
## drive caught: drive a REAL continued zoom-out gesture (via `_zoom_at()`,
|
||||
## the same call path the live drive uses — NOT calling
|
||||
## `_maybe_reset_to_canonical_frame()` directly with unchanged state, which
|
||||
## trivially can't reproduce the drift the storm depends on) many ticks past
|
||||
## the point where the reset first fires — asserts ZERO additional tile-set
|
||||
## entries occur across the WHOLE gesture. Spies on `AtlasWindowTileSet`'s
|
||||
## own child `AtlasWindowRequest` node INSTANCES (captured right after the
|
||||
## FIRST reset) — a fresh `enter_orbital()` call tears down (`queue_free()`s)
|
||||
## every one of them and creates BRAND NEW ones, so "the same node
|
||||
## instances are still alive and still the tile set's children after 100
|
||||
## more ticks" is a direct, non-invasive proxy for "the reset never fired
|
||||
## again" — no new production instrumentation needed. Before the round-6
|
||||
## fix, `_zoom_at()`'s continued multiplicative zoom-out drifted `_view_zoom`
|
||||
## below fit on every subsequent tick, the level-triggered guard read "not
|
||||
## already there" every time, and `enter_orbital()` fired repeatedly:
|
||||
## tearing down and recreating the tile set (and its 6 request nodes) every
|
||||
## tick — exactly the "889 of 897 wire responses arrived during one
|
||||
## zoom-out phase" storm.
|
||||
func test_reset_evaluated_repeatedly_at_canonical_frame_issues_zero_additional_requests() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
|
||||
# Zoom IN far enough to leave tile mode, then zoom back OUT past the
|
||||
# first reset — same shape as the round-5 continued-zoom-out test, but
|
||||
# this time spying on the tile set across the WHOLE remaining gesture
|
||||
# instead of only checking the final zoom value.
|
||||
var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).is_false()
|
||||
|
||||
for _i in range(200):
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
if v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: the first reset must have fired before spying on the tile set"
|
||||
).is_true()
|
||||
|
||||
var tile_set = v.get_tile_set()
|
||||
var original_requests: Array = tile_set.get_children()
|
||||
assert_int(original_requests.size()).override_failure_message(
|
||||
"sanity: the first reset must have created real tile-request child nodes to spy on"
|
||||
).is_greater(0)
|
||||
|
||||
# Continue the SAME zoom-out gesture 100 MORE ticks past the first
|
||||
# reset — a real wheel gesture has no way to stop exactly at the reset
|
||||
# point, and holding the wheel down (or residual scroll momentum) keeps
|
||||
# sending ticks. None of these must tear down/recreate the tile set.
|
||||
for _i in range(100):
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
|
||||
var current_requests: Array = tile_set.get_children()
|
||||
assert_int(current_requests.size()).override_failure_message(
|
||||
"the tile set's child count must be unchanged after 100 more continued"
|
||||
+ " zoom-out ticks — a changed count means teardown/recreate happened"
|
||||
).is_equal(original_requests.size())
|
||||
for i in range(original_requests.size()):
|
||||
assert_bool(is_instance_valid(original_requests[i])).override_failure_message(
|
||||
"original tile-request node #%d must still be alive — a storm would have"
|
||||
+ " queue_free()'d it and created a fresh one" % i
|
||||
).is_true()
|
||||
assert_bool(is_same(original_requests[i], current_requests[i])).override_failure_message(
|
||||
(
|
||||
"tile-request node #%d must be the SAME instance as right after the"
|
||||
+ " first reset — a different object at the same index means the tile"
|
||||
+ " set was torn down and recreated (a storm), even if the count"
|
||||
+ " coincidentally matches"
|
||||
)
|
||||
% i
|
||||
).is_true()
|
||||
|
||||
|
||||
## Live round 6's BLACK-ENTRY repro: enter_orbital(), then deliver the six
|
||||
## wire-accurate tile responses WHILE a REAL continued zoom-out gesture (via
|
||||
## `_zoom_at()`, matching the live drive's actual input shape — a held
|
||||
## wheel-down keeps sending ticks concurrently with responses streaming in
|
||||
## from the server) is in flight — asserts all six are accepted and HELD
|
||||
## (tile set stable throughout, no teardown between delivery and the final
|
||||
## assertion). Before the round-6 fix, the level-triggered guard fired on
|
||||
## every zoom-out tick once `_view_zoom` drifted below fit, tearing down the
|
||||
## tile set mid-delivery and orphaning responses addressed to now-freed
|
||||
## request nodes — nothing ever accumulated, and the mosaic stayed black
|
||||
## even though the server dutifully answered every request.
|
||||
func test_six_tile_responses_survive_concurrent_reset_evaluation_and_are_held() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — 6 tiles, the live-repro shape
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
|
||||
var tile_set = v.get_tile_set()
|
||||
var tiles: Array = tile_set.get_tiles()
|
||||
assert_int(tiles.size()).override_failure_message(
|
||||
"sanity: Lendel must produce Lendel's own real tile count (6) for this"
|
||||
+ " repro to be faithful, not a smaller synthetic count"
|
||||
).is_equal(6)
|
||||
|
||||
# Same continued zoom-out gesture as the anti-storm test above — leave
|
||||
# tile mode, cross back into it (the first reset), then KEEP sending
|
||||
# zoom-out ticks (a real held wheel has no way to stop exactly at the
|
||||
# reset point). Responses are delivered interleaved with these ticks,
|
||||
# exactly matching the live drive's concurrent shape.
|
||||
var cursor_pos := Vector2(1100.0, 300.0)
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).is_false()
|
||||
for _i in range(200):
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
if v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: the first reset must have fired before delivering responses"
|
||||
).is_true()
|
||||
|
||||
for i in range(tiles.size()):
|
||||
var center: Vector2i = tiles[i]["center"]
|
||||
var tile_window: Dictionary = {
|
||||
"center": [center.x, center.y],
|
||||
"n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION,
|
||||
"granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL,
|
||||
"granularity_v2": "Region",
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window))
|
||||
# Interleave several MORE continued zoom-out ticks, matching the live
|
||||
# drive's per-frame cadence — none of these must tear anything down.
|
||||
for _tick in range(5):
|
||||
v._zoom_at(cursor_pos, 1.0 / 1.05)
|
||||
|
||||
var final_tiles: Array = tile_set.get_tiles()
|
||||
assert_int(final_tiles.size()).override_failure_message(
|
||||
"the tile set must still have all 6 tile slots — a storm mid-delivery"
|
||||
+ " would have torn it down and rebuilt it with fresh (unfulfilled) slots"
|
||||
).is_equal(6)
|
||||
for i in range(final_tiles.size()):
|
||||
assert_that(final_tiles[i]["window"]).override_failure_message(
|
||||
(
|
||||
"tile #%d's window must be HELD (non-null) — all six wire-accurate"
|
||||
+ " responses delivered during a concurrent continued zoom-out gesture"
|
||||
+ " must survive to be accepted, not be silently dropped by an"
|
||||
+ " orphaning teardown"
|
||||
)
|
||||
% i
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Not fully zoomed out (a normal District-rung view) must NOT trigger the
|
||||
## reset — only reaching the top of the ladder resets, not every zoom step.
|
||||
func test_reset_to_canonical_frame_does_not_fire_when_not_fully_zoomed_out() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c", "body_radius_km": 6238.4}, {}, Vector2i(500, 10), 32)
|
||||
v._view_zoom = 1.0 # a normal, non-extreme zoom — nowhere near full planetary coverage
|
||||
|
||||
var fired: bool = v._maybe_reset_to_canonical_frame()
|
||||
|
||||
assert_bool(fired).override_failure_message(
|
||||
"an ordinary District-rung view must not trigger the top-rest-state reset"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: rung reselection — _zoom_at() crossing a rung threshold fires a
|
||||
# new request without touching the held composite.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Zooming OUT far enough from a District-rung window (small n, so a modest
|
||||
## zoom-out already covers a huge world extent) must fire a coarser-rung
|
||||
## request — the wheel-zoom-driven wiring test for _maybe_reselect_rung().
|
||||
func test_zoom_out_past_district_threshold_requests_a_coarser_rung() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 600.0)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 2) # n=2 — a tiny window, easy to overshoot
|
||||
var district_window: Dictionary = _mock_window(Vector2i(0, 0), 2)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
assert_str(v._window_request.get_granularity_v2()).is_equal("District")
|
||||
|
||||
# A big zoom-OUT factor (well under 1.0) from a tiny n=2 window blows the
|
||||
# displayed world extent WAY past District's threshold.
|
||||
v._zoom_at(Vector2(400.0, 300.0), 0.01)
|
||||
|
||||
assert_str(v._window_request.get_granularity_v2()).override_failure_message(
|
||||
"zooming out far enough from a small District window must re-request a coarser rung"
|
||||
).is_not_equal("District")
|
||||
# The OLD composite must still be what's held — progressive refinement,
|
||||
# not a block-on-derive clear.
|
||||
assert_that(v.get_district_window()).is_equal(district_window)
|
||||
|
||||
|
||||
## Zooming IN on a District-rung window (well within its own legal coverage
|
||||
## band, `(32,768 m, 131,072 m]` per select_rung()'s redesigned per-rung
|
||||
## ceiling model — viewport-independent since `canvas_px` no longer affects
|
||||
## selection) must NOT trigger a rung change — this is the "zoom is
|
||||
## client-side on the already-held composite" case, unchanged for in-rung
|
||||
## zoom. Sets _view_zoom DIRECTLY to a value inside District's band (rather
|
||||
## than relying on enter()'s COVER auto-fit, which for a small n can already
|
||||
## sit right at Quarter's own threshold — a fit's zoom level is a
|
||||
## display-density choice independent of what rung selection would pick from
|
||||
## scratch, and this test is specifically about a SINGLE zoom-in STEP not
|
||||
## crossing a boundary, not about where the auto-fit itself lands). The
|
||||
## small 100x80 viewport here is incidental (any size works under the new
|
||||
## viewport-independent model) — kept small only because that's what the
|
||||
## original version of this test used.
|
||||
func test_zoom_in_within_district_threshold_does_not_change_rung() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(100.0, 80.0)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(0, 0), 32)
|
||||
var district_window: Dictionary = _mock_window(Vector2i(0, 0), 32)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window))
|
||||
v._view_zoom = 0.10666666666666667 # E=120,000m at C=100px — inside District's legal band
|
||||
v._apply_transform()
|
||||
|
||||
v._zoom_at(Vector2(50.0, 40.0), 1.15) # a single ordinary zoom-in step
|
||||
|
||||
assert_str(v._window_request.get_granularity_v2()).override_failure_message(
|
||||
"a single ordinary zoom-in step must not cross a rung threshold"
|
||||
).is_equal("District")
|
||||
|
||||
|
||||
## **Live round 3 regression, the direct end-to-end fix target:** a real
|
||||
## wheel-zoom gesture (many `_zoom_at()` ticks, matching the shape a
|
||||
## continuous mouse-wheel scroll actually produces) crossing from the
|
||||
## Region rest state down through District into Quarter territory must (i)
|
||||
## fire a request at the NEW granularity — `_window_request.get_granularity_v2()`
|
||||
## must have changed by the end of the gesture — and (ii) accept a
|
||||
## WIRE-ACCURATE response for that request: echoing the REQUEST's own
|
||||
## (already re-centered, already re-clamped) center/n, which the live round
|
||||
## found DIFFERS from the ORIGINAL held center (screen-center-anchored
|
||||
## refinement re-centers on wherever the cursor currently maps to, not
|
||||
## wherever the player started) — this is the "second latent drop" the
|
||||
## coordinator specifically flagged: comparing the echo against a STALE
|
||||
## `_held_center` (frozen at the pre-crossing value) rather than the
|
||||
## request's own center would silently drop this response too.
|
||||
## **Live round 3 update:** GJ380c/Lendel now enters TILE mode via
|
||||
## enter_orbital() (bug B's fix), so this test starts from THERE — zooming
|
||||
## in far enough crosses Region's coverage ceiling and must LEAVE tile mode
|
||||
## for the single-window path at the new (finer) rung, exactly the
|
||||
## `_maybe_reselect_rung()` "leaving_tile_mode" branch this test exercises.
|
||||
func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"GJ380c/Lendel must enter tile mode at the orbital rest state (live round 3)"
|
||||
).is_true()
|
||||
|
||||
# A real wheel-zoom gesture: many ticks, cursor OFF-CENTER (so cursor-
|
||||
# anchored zoom genuinely drifts the screen-to-district mapping away from
|
||||
# the canonical origin, not just scaling in place) — matching the live
|
||||
# drive's actual input shape, not a single synthetic jump. Zooming in far
|
||||
# enough must cross OUT of Region's coverage ceiling, leaving tile mode.
|
||||
var cursor_pos := Vector2(1100.0, 300.0) # off-center, biased toward one quadrant
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
|
||||
# (i) Tile mode must have been LEFT, and a request must have gone out at
|
||||
# a NEW (finer) granularity via the single-window path.
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"zooming in far enough must leave tile mode for the single-window path"
|
||||
).is_false()
|
||||
var request_granularity: String = v._window_request.get_granularity_v2()
|
||||
assert_str(request_granularity).override_failure_message(
|
||||
"leaving tile mode must fire a request at a new (finer) granularity"
|
||||
).is_not_equal("Region")
|
||||
|
||||
# (ii) The request's own center/n — read AFTER leaving tile mode, so this
|
||||
# is whatever _maybe_reselect_rung() actually computed — is what a
|
||||
# wire-accurate response must echo to be accepted.
|
||||
var request_center: Vector2i = v._window_request._center
|
||||
var request_n: int = v._window_request._n
|
||||
|
||||
var refinement_window: Dictionary = {
|
||||
"center": [request_center.x, request_center.y],
|
||||
"n": request_n,
|
||||
"granularity_v2": request_granularity,
|
||||
"morphology": PackedByteArray([1, 2, 3, 4]),
|
||||
"elev_q": PackedByteArray([10, 20, 30, 40]),
|
||||
"temp_dc": [0, 0, 0, 0],
|
||||
"moisture_q": PackedByteArray([0, 0, 0, 0]),
|
||||
"vegetation": PackedByteArray([0, 0, 0, 0]),
|
||||
"glaciation": PackedByteArray([0, 0, 0, 0]),
|
||||
}
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", refinement_window))
|
||||
|
||||
assert_that(v.get_district_window()).override_failure_message(
|
||||
"a wire-accurate refinement response (echoing the REQUEST's own center/n/"
|
||||
+ " granularity after leaving tile mode) must be ACCEPTED — comparing"
|
||||
+ " against a stale/wrong reference instead of the request's own would"
|
||||
+ " silently drop this response forever"
|
||||
).is_equal(refinement_window)
|
||||
assert_str(v._held_granularity_v2).is_equal(request_granularity)
|
||||
|
||||
|
||||
## Live round 4's SECOND bug, pinned directly: `_maybe_reselect_rung()` must
|
||||
## recompute `_view_offset` (via AtlasWindowGeometry.
|
||||
## recompute_offset_for_held_n_change()) the instant `_held_n` changes across
|
||||
## a rung crossing — leaving it untouched (the round-4 bug) means the single-
|
||||
## window `Rect2(0,0,extent)` draw call renders at whatever screen position
|
||||
## the OLD (Region-scale) offset happened to put canvas-local (0,0), which
|
||||
## for a whole-body `held_n` vs. a 64-district District `held_n` is tens or
|
||||
## hundreds of thousands of px away from the viewport — the exact "pitch
|
||||
## black" repro. Asserts the NEW held window's own extent actually overlaps
|
||||
## the viewport after the crossing, the concrete on-screen consequence a
|
||||
## stale offset breaks.
|
||||
func test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(1600.0, 900.0)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
|
||||
var cursor_pos := Vector2(1100.0, 300.0)
|
||||
for _i in range(60):
|
||||
v._zoom_at(cursor_pos, 1.15)
|
||||
if not v.is_tile_mode():
|
||||
break
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: this test needs to actually cross out of tile mode to exercise"
|
||||
+ " the held_n change _maybe_reselect_rung() must react to"
|
||||
).is_false()
|
||||
|
||||
# The new (post-crossing) window's screen-space rect, using the SAME
|
||||
# formula the overlay's single-window _draw() itself uses
|
||||
# (Rect2(0,0,extent,extent) in canvas-local space, then _canvas's own
|
||||
# position/scale transform — _view_offset/_view_zoom here mirror that
|
||||
# exactly, since _apply_transform() is what sets _canvas.position/scale).
|
||||
var extent_screen: float = float(v._held_n) * v.CELL_PIXEL_SIZE * v._view_zoom
|
||||
var screen_top_left: Vector2 = v._view_offset
|
||||
var screen_bottom_right: Vector2 = screen_top_left + Vector2(extent_screen, extent_screen)
|
||||
var viewport_rect := Rect2(Vector2.ZERO, v.size)
|
||||
var window_rect := Rect2(screen_top_left, Vector2(extent_screen, extent_screen))
|
||||
|
||||
assert_bool(viewport_rect.intersects(window_rect)).override_failure_message(
|
||||
(
|
||||
"the new (post-crossing) held window's screen rect %s must overlap the"
|
||||
+ " viewport %s — a stale _view_offset (never recomputed for the new"
|
||||
+ " held_n=%d) is exactly live round 4's 'pitch black' bug: the composite"
|
||||
+ " renders somewhere entirely off-canvas despite request/response/data"
|
||||
+ " all being individually correct"
|
||||
)
|
||||
% [window_rect, viewport_rect, v._held_n]
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent-
|
||||
# relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless
|
||||
# of which rung's data is actually held), so they must keep working
|
||||
# unmodified at Region granularity, not just District/Quarter.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The pole wall, wired through the real _apply_pan_delta() path, must still
|
||||
## clamp at Region granularity — same mechanism as the existing District-rung
|
||||
## test (test_wasd_pan_is_clamped_by_the_pole_wall_when_wired), just entered
|
||||
## via enter_orbital() instead of enter().
|
||||
func test_pole_wall_clamps_at_region_granularity_too() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 800.0)
|
||||
var radius_km := 50.0 # tiny synthetic body — pole wall reachable by an ordinary tick
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom()
|
||||
v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held, an absurdly long tick
|
||||
|
||||
assert_float(absf(v.get_view_offset().y)).override_failure_message(
|
||||
"the pole wall must still clamp an extreme pan at Region granularity"
|
||||
).is_less(unclamped_magnitude * 0.5)
|
||||
|
||||
|
||||
## East-west wrap (canonicalize_district_center()) must still apply to the
|
||||
## pan-edge refloat's resulting center at Region granularity — a pan that
|
||||
## carries the screen-center column past the body's circumference must wrap
|
||||
## into [0, cols), never run away to an out-of-range column, exactly as the
|
||||
## District-rung wrap tests already pin (T-1142 item 6a). At Region's own
|
||||
## enormous held_n (a whole circumference), an ORDINARY pan tick's
|
||||
## canvas-space delta is negligible relative to the window's half-extent
|
||||
## (confirmed: ~0.08 districts per 5-second tick vs. a ~9,772-district
|
||||
## half-window) — so this drives _maybe_refloat_window() DIRECTLY off a
|
||||
## manually-set _view_offset large enough to genuinely cross the held
|
||||
## window's edge, the same "exercise the actual edge-crossing branch, not
|
||||
## just its no-op early-return" discipline _maybe_refloat_window()'s own
|
||||
## inside-check comment describes.
|
||||
func test_pan_edge_refloat_wraps_columns_at_region_granularity_too() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.size = Vector2(800.0, 800.0)
|
||||
var radius_km := 6371.0
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_str(v._held_granularity_v2).is_equal("Region")
|
||||
|
||||
# Force the held center to sit one column short of the wrap seam, then
|
||||
# shift the CANVAS offset by more than half the window's own on-screen
|
||||
# extent — enough to move the screen-center's mapped column past the
|
||||
# window's far edge (i.e. past `cols`, crossing the seam) regardless of
|
||||
# Region's huge held_n.
|
||||
v._held_center = Vector2i(cols - 1, 0)
|
||||
var half_window_screen_px: float = float(v._held_n) * v.get_cell_pixel_size() * v.get_view_zoom() * 0.5
|
||||
v._view_offset = v.get_view_offset() - Vector2(half_window_screen_px * 1.5, 0.0)
|
||||
v._maybe_refloat_window()
|
||||
|
||||
assert_int(v._held_center.x).override_failure_message(
|
||||
"a pan crossing the antimeridian at Region granularity must wrap the"
|
||||
+ " resulting center into [0, cols), never run past cols"
|
||||
).is_less(cols)
|
||||
assert_int(v._held_center.x).is_greater_equal(0)
|
||||
@@ -1,8 +1,20 @@
|
||||
class_name AtlasApp
|
||||
extends ImplantApp
|
||||
## Atlas implant app (#844, #836, D-191).
|
||||
## Reach map → system orbital → planet entry → regional heightmap viewer.
|
||||
## Reach map → system orbital → planet entry → regional zoom ladder.
|
||||
## Registered as "implant/map" in FULLSCREEN mode.
|
||||
##
|
||||
## T-1153 (D-226 T-1143-rulings amendment): the "district" nav-stack screen
|
||||
## (T-1138's windowed drill-down, a separate nav.push() hop from "regional")
|
||||
## RETIRES as a nav-stack level — the continuous cursor-anchored zoom ladder
|
||||
## means descent/ascent through every rung (Region -> District -> Quarter)
|
||||
## happens INSIDE the "regional" screen via zoom, not by pushing a new
|
||||
## screen. D-013 "the zoom gesture owns spatial descent" is restored for
|
||||
## this seam (Jeroen's ruling) — descent is a continuous gesture, not a
|
||||
## discrete nav hop. Esc from anywhere in the ladder is therefore a single
|
||||
## nav.pop() back to "system", exactly the same _handle_key() KEY_ESCAPE
|
||||
## branch every other non-"reach" screen already uses; there is no longer a
|
||||
## "district" screen entry in the match/registration table.
|
||||
|
||||
signal economics_link_requested(system_id: String)
|
||||
|
||||
@@ -12,8 +24,7 @@ var _system_lookup: Dictionary = {} # system_id → system dict
|
||||
var _reach_screen = null # ReachScreen
|
||||
var _system_screen = null # SystemScreen
|
||||
var _planet_screen = null # PlanetScreen
|
||||
var _regional_screen = null # RegionalScreen
|
||||
var _district_screen = null # DistrictScreen (T-1138)
|
||||
var _regional_screen = null # RegionalScreen — now the whole zoom ladder (T-1153)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -47,14 +58,8 @@ func on_install() -> void:
|
||||
|
||||
_regional_screen = RegionalScreen.new()
|
||||
_regional_screen.back_requested.connect(_on_regional_back)
|
||||
_regional_screen.economics_link_requested.connect(_forward_economics_link)
|
||||
_regional_screen.district_descend_requested.connect(_on_district_descend_requested)
|
||||
register_screen("regional", _regional_screen)
|
||||
|
||||
_district_screen = DistrictScreen.new()
|
||||
_district_screen.back_requested.connect(_on_district_back)
|
||||
register_screen("district", _district_screen)
|
||||
|
||||
nav.set_default("reach")
|
||||
|
||||
|
||||
@@ -71,12 +76,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
return
|
||||
if not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
# "regional" (the planetary heightmap, AtlasViewer) and "district" (the
|
||||
# windowed regional-window screen, AtlasWindowViewer, T-1138) both handle
|
||||
# their own Esc via _gui_input — same delegation shape for both, so a
|
||||
# stray M/other unhandled key on either screen doesn't ALSO fire this
|
||||
# app's own _handle_key underneath the viewer's own handling.
|
||||
if current_screen_id() == "regional" or current_screen_id() == "district":
|
||||
# "regional" (the whole zoom ladder, AtlasWindowViewer, T-1153) handles its
|
||||
# own Esc via _gui_input — a stray M/other unhandled key on that screen
|
||||
# must not ALSO fire this app's own _handle_key underneath the viewer's
|
||||
# own handling.
|
||||
if current_screen_id() == "regional":
|
||||
return
|
||||
_handle_key(event as InputEventKey)
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -148,29 +152,6 @@ func _on_regional_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
## T-1138: the planetary click-through descent (§5 entry revision) — pushes
|
||||
## the "district" screen centered on the click point's derived DistrictPos.
|
||||
## A real nav.push() (not a swap-in-place, unlike the superseded zoom-
|
||||
## threshold design) so Esc's existing nav.pop() path (DistrictScreen's own
|
||||
## back_requested -> _on_district_back below) returns to exactly the
|
||||
## planetary body the player descended from, at whatever crumb depth got
|
||||
## them there (reach -> system -> regional -> district).
|
||||
func _on_district_descend_requested(district_center: Vector2i) -> void:
|
||||
nav.push("district", {
|
||||
"body": nav.current_payload().get("body", {}),
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
"district_center": district_center,
|
||||
})
|
||||
|
||||
|
||||
func _on_district_back() -> void:
|
||||
nav.pop()
|
||||
|
||||
|
||||
func _forward_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
## T-949: the star map arrived — cache it, rebuild the local system
|
||||
## list/lookup, and push it into whichever screens already exist. set_systems()
|
||||
## is safe to call again after initial setup (ReachScreen/SystemScreen both
|
||||
|
||||
@@ -2,21 +2,27 @@ extends RefCounted
|
||||
|
||||
## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226
|
||||
## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the
|
||||
## granularity/min_wl axes).
|
||||
## granularity/min_wl axes; extended T-1152/T-1153 for the granularity_v2
|
||||
## string-tag axis that makes Region representable at all).
|
||||
##
|
||||
## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism
|
||||
## guarantee (same seed + body + position + derivation params -> same derived
|
||||
## output, always) means a previously-fetched window is valid FOREVER for
|
||||
## that body+seed. This is an LRU-evict-only cache: no freshness check, no
|
||||
## TTL, no invalidation path at all. The only reason an entry ever leaves is
|
||||
## capacity pressure.
|
||||
## Keyed on (body_id, center, n, granularity, min_wl_m, granularity_v2) —
|
||||
## D-227's determinism guarantee (same seed + body + position + derivation
|
||||
## params -> same derived output, always) means a previously-fetched window is
|
||||
## valid FOREVER for that body+seed. This is an LRU-evict-only cache: no
|
||||
## freshness check, no TTL, no invalidation path at all. The only reason an
|
||||
## entry ever leaves is capacity pressure.
|
||||
##
|
||||
## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing,
|
||||
## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps
|
||||
## its existing key shape and cache behavior unchanged. This is the client
|
||||
## half of the mandatory aliasing fix (T-1150 design doc §3): a
|
||||
## quarter-granularity request and a district-granularity request at the
|
||||
## identical (body, center, n) MUST NOT collide on the same cache slot.
|
||||
## granularity/min_wl_m/granularity_v2 default to
|
||||
## DISTRICT_GRANULARITY/0/DEFAULT_GRANULARITY_V2 ("District", district
|
||||
## spacing, no octave cutoff) — every pre-T-1150 caller that doesn't pass them
|
||||
## keeps its existing key shape and cache behavior unchanged. This is the
|
||||
## client half of the mandatory aliasing fix (T-1150 design doc §3, extended
|
||||
## T-1152): a quarter-granularity request, a district-granularity request,
|
||||
## and a REGION-granularity request all at the identical (body, center, n)
|
||||
## MUST NOT collide on the same cache slot — the legacy int alone cannot
|
||||
## distinguish Region (it has no legal legacy-int value, see
|
||||
## GRANULARITY_V2_REGION's doc), which is exactly why granularity_v2 is a
|
||||
## SEPARATE key component rather than a replacement for the legacy one.
|
||||
##
|
||||
## Godot's Dictionary preserves insertion order, so "move to the end on
|
||||
## touch, evict from the front on overflow" is the whole LRU implementation —
|
||||
@@ -33,6 +39,24 @@ const DEFAULT_MAX_ENTRIES: int = 24
|
||||
## default granularity every pre-T-1150 caller implicitly requests.
|
||||
const DISTRICT_GRANULARITY: int = 1
|
||||
|
||||
## Mirrors the server's WindowGranularity enum (T-1152, R5 redesign,
|
||||
## layer_proxy.rs) — the string-tag vocabulary rmp_serde encodes a bare
|
||||
## `#[derive(Serialize, Deserialize)]` enum's variant name as, verbatim (same
|
||||
## wire convention `RoadNodeKind` already established on this carrier). This
|
||||
## is the KEY-SPACE axis (T-1153): the legacy int `granularity` param below
|
||||
## stays wired for every existing District/Quarter caller (byte/behavior
|
||||
## compatible), but a cache slot is now ALSO qualified by this string so a
|
||||
## Region-rung window can never alias onto a District/Quarter slot at the
|
||||
## identical (body, center, n, legacy_granularity, min_wl_m) — the exact
|
||||
## aliasing risk the T-1150 design doc §3 flagged, extended to the new axis.
|
||||
const GRANULARITY_V2_QUARTER: String = "Quarter"
|
||||
const GRANULARITY_V2_DISTRICT: String = "District"
|
||||
const GRANULARITY_V2_REGION: String = "Region"
|
||||
## Default v2 tag for every caller that doesn't pass one — matches
|
||||
## DISTRICT_GRANULARITY's own "district is the implicit default" contract, so
|
||||
## an omitted v2 tag and an explicit "District" tag key identically.
|
||||
const DEFAULT_GRANULARITY_V2: String = GRANULARITY_V2_DISTRICT
|
||||
|
||||
var _max_entries: int = DEFAULT_MAX_ENTRIES
|
||||
var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary
|
||||
|
||||
@@ -41,34 +65,42 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
|
||||
_max_entries = maxi(1, max_entries)
|
||||
|
||||
|
||||
## Build the cache key from the five fields D-227 + T-1150 make sufficient:
|
||||
## body_id (which world+body), center (a [row, col] pair or Vector2i), n
|
||||
## (window extent in districts), granularity (district=1 / quarter=4), and
|
||||
## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested
|
||||
## Dictionary/Array key — Godot Dictionary keys compare by value for
|
||||
## primitives but a consistent stringification sidesteps any
|
||||
## Vector2i-vs-Array identity mismatch between what a caller happens to hand
|
||||
## in.
|
||||
## Build the cache key from the six fields D-227 + T-1150/T-1152 make
|
||||
## sufficient: body_id (which world+body), center (a [row, col] pair or
|
||||
## Vector2i), n (window extent in districts), granularity (the legacy int:
|
||||
## district=1 / quarter=4), min_wl_m (the octave cutoff, 0 = none), and
|
||||
## granularity_v2 (the T-1152 string tag: "Quarter"/"District"/"Region" — the
|
||||
## axis that actually distinguishes Region from every finer rung, since
|
||||
## Region has no legal legacy-int representation and the legacy slot alone
|
||||
## cannot tell a Region window's cache entry apart from a District one at the
|
||||
## same (center, n)). String-keyed rather than a nested Dictionary/Array key
|
||||
## — Godot Dictionary keys compare by value for primitives but a consistent
|
||||
## stringification sidesteps any Vector2i-vs-Array identity mismatch between
|
||||
## what a caller happens to hand in.
|
||||
static func make_key(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> String:
|
||||
return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m]
|
||||
return "%s:%d,%d:%d:%d:%d:%s" % [
|
||||
body_id, center.x, center.y, n, granularity, min_wl_m, granularity_v2
|
||||
]
|
||||
|
||||
|
||||
## True if a window is already cached for this exact (body, center, n,
|
||||
## granularity, min_wl_m).
|
||||
## granularity, min_wl_m, granularity_v2).
|
||||
func has(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> bool:
|
||||
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m))
|
||||
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m, granularity_v2))
|
||||
|
||||
|
||||
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
|
||||
@@ -81,9 +113,10 @@ func get_window(
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> Variant:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
|
||||
if not _entries.has(key):
|
||||
return null
|
||||
var value: Variant = _entries[key]
|
||||
@@ -101,9 +134,10 @@ func put(
|
||||
n: int,
|
||||
window: Dictionary,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
min_wl_m: int = 0,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
|
||||
if _entries.has(key):
|
||||
_entries.erase(key)
|
||||
_entries[key] = window
|
||||
|
||||
@@ -10,6 +10,102 @@ extends RefCounted
|
||||
## window size, what zoom/offset centers it" math is unit-testable in
|
||||
## isolation here — a caller does:
|
||||
## const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
##
|
||||
## T-1153: also carries the rung-selection rule (design doc
|
||||
## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed
|
||||
## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both
|
||||
## pure functions of (viewport, held state, body), same "geometry lives here,
|
||||
## side effects live on the viewer" split as the rest of this file. Live
|
||||
## round 3 also adds the orbital-rest-state TILE GRID computation
|
||||
## (compute_tile_grid(), near the bottom) — reuses
|
||||
## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for
|
||||
## the SAME wrap/clamp discipline every other piece of this cluster already
|
||||
## depends on, hence the preload below (no circular dependency:
|
||||
## atlas_descend_geometry.gd never references this file).
|
||||
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
## D-243 rung spacings, metres/cell — the SAME constants
|
||||
## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m
|
||||
## source from, mirrored here rather than re-derived so the client's rung
|
||||
## table can never silently drift from the wire contract it's choosing
|
||||
## between.
|
||||
const QUARTER_SPACING_M: float = 512.0
|
||||
const DISTRICT_SPACING_M: float = 2048.0
|
||||
const REGION_SPACING_M: float = 204_800.0
|
||||
|
||||
## Table form, coarsest-first — spacing_for_rung()'s inverse lookup walks
|
||||
## this. select_rung() (below) does NOT walk this table directly — see that
|
||||
## function's own doc for why the coarse (Region) and fine (District/
|
||||
## Quarter) ends are decided by two DIFFERENT tests, not a single ordered
|
||||
## table scan.
|
||||
const RUNG_TABLE: Array = [
|
||||
{"granularity_v2": "Region", "spacing_m": REGION_SPACING_M},
|
||||
{"granularity_v2": "District", "spacing_m": DISTRICT_SPACING_M},
|
||||
{"granularity_v2": "Quarter", "spacing_m": QUARTER_SPACING_M},
|
||||
]
|
||||
|
||||
## Server per-axis cap on a District/Quarter-granularity window's `n`
|
||||
## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side
|
||||
## mirror of the same constant, kept in sync there).
|
||||
const DISTRICT_WINDOW_MAX_N: int = 64
|
||||
|
||||
## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS /
|
||||
## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap
|
||||
## EVERY rung's single window is clamped against, per
|
||||
## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas.
|
||||
const WIRE_CAP_CELLS: int = 4_096
|
||||
|
||||
## Region's own per-axis ceiling (mirrors
|
||||
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION /
|
||||
## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION).
|
||||
const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
|
||||
|
||||
## Per-tile district extent for the orbital-rest-state tile grid
|
||||
## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a
|
||||
## single Region request uses at its own per-axis ceiling. Each tile
|
||||
## requests exactly this many districts on a side — the largest single
|
||||
## window the wire budget allows, so tiling uses the FEWEST tiles that can
|
||||
## cover a given body.
|
||||
const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION
|
||||
|
||||
## **Live round 3 finding (the actual root cause of "zoom-driven rung
|
||||
## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM
|
||||
## real-world coverage, derived from the SAME wire-size clamp
|
||||
## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District
|
||||
## and Quarter are NOT exempt from this the way the original (§5-literal)
|
||||
## design assumed. A rung whose own single-window coverage is smaller than
|
||||
## the CURRENTLY DISPLAYED world extent cannot legally be selected: the
|
||||
## server would clamp `n` down to fit its own wire budget, producing a
|
||||
## composite that covers only a FRACTION of the viewport — visually a tiny
|
||||
## box in the middle of the screen, and (the bug this constant's discovery
|
||||
## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n`
|
||||
## the viewer was still carrying from the PREVIOUS rung, permanently failing
|
||||
## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same
|
||||
## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per
|
||||
## rung inline — see MAX_COVERAGE_M below.
|
||||
##
|
||||
## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts ->
|
||||
## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`.
|
||||
## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts ->
|
||||
## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original
|
||||
## coverage-ceiling constant this replaces/generalizes).
|
||||
## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts ->
|
||||
## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's
|
||||
## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to
|
||||
## cover extents beyond this (see the viewer's tile-set model), so this
|
||||
## constant alone does NOT bound what the ORBITAL REST STATE can show —
|
||||
## only what one Region REQUEST's response covers.
|
||||
const MAX_COVERAGE_M: Dictionary = {
|
||||
"Quarter": 64.0 * QUARTER_SPACING_M,
|
||||
"District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M,
|
||||
"Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M,
|
||||
}
|
||||
|
||||
## Rungs ordered FINEST-first — select_rung() walks this to find the finest
|
||||
## rung whose own single-window coverage ceiling still covers the current
|
||||
## extent (never a rung that would silently under-cover the viewport).
|
||||
const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"]
|
||||
|
||||
|
||||
## Fit-and-center: given the viewport size and the window's side length in
|
||||
@@ -116,3 +212,464 @@ static func clamp_pan_offset_to_pole_wall(
|
||||
var min_y: float = minf(north_wall_screen_y, south_wall_screen_y)
|
||||
var max_y: float = maxf(north_wall_screen_y, south_wall_screen_y)
|
||||
return Vector2(offset.x, clampf(offset.y, min_y, max_y))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: rung selection (design doc §5) — the continuous zoom ladder's join
|
||||
# point between "what granularity is legal to request" (a rung, a discrete
|
||||
# set) and "what density the client actually wants" (world extent per canvas
|
||||
# px, a continuous quantity that tracks the live zoom level).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the
|
||||
## original §5-literal `2x`-visual-tolerance-only reading): select the
|
||||
## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M)
|
||||
## still covers the current world extent. Walks RUNGS_FINEST_FIRST
|
||||
## (Quarter, District, Region) and returns the first whose ceiling is `>=
|
||||
## world_extent_m` — the coarser rungs are tried only once the finer ones
|
||||
## genuinely cannot show the requested extent in a single window.
|
||||
##
|
||||
## **Why this replaces the original `2x`-visual-tolerance formula entirely**
|
||||
## (not just patches its Region case, as an earlier version of this function
|
||||
## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)")
|
||||
## implicitly assumes every rung's single window CAN cover any extent the
|
||||
## rule selects it for — true for an unbounded wire budget, false here.
|
||||
## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the
|
||||
## server itself enforces — caps every rung's single-window real-world
|
||||
## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter
|
||||
## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region
|
||||
## window — bug B's progressive TILING composes several to cover more, a
|
||||
## viewer-level concern this function doesn't need to know about). A rung
|
||||
## selected for an extent BEYOND its own ceiling would have its `n` silently
|
||||
## clamped server-side to something covering only a FRACTION of the
|
||||
## viewport — visually a tiny box, AND (the actual live-round bug this
|
||||
## redesign fixes) a clamped echo that no longer matches whatever `n` the
|
||||
## viewer was still carrying from the rung it's leaving, permanently failing
|
||||
## the staleness check in `_on_window_ready()`.
|
||||
##
|
||||
## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not
|
||||
## contradicted by it** — verified numerically: at the exact zoom where
|
||||
## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold
|
||||
## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m
|
||||
## spacing. This means by the time coverage RELEASES a rung, the visual
|
||||
## tolerance would ALREADY prefer something finer than that rung offers —
|
||||
## i.e. every rung this function selects is, by construction, at or past its
|
||||
## own "as fine as it can usefully be" point. The visual-tolerance rule's
|
||||
## fine-end guarantee (never show a coarser composite than the screen can
|
||||
## resolve) is automatically satisfied by "select the finest rung whose
|
||||
## coverage allows it" — there is no case where the coverage rule picks a
|
||||
## rung the visual rule would have rejected as too coarse, because Quarter
|
||||
## (the finest rung) is always the answer whenever ANY rung's visual
|
||||
## tolerance alone would have mattered.
|
||||
##
|
||||
## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held
|
||||
## window is always square, so either axis of the viewport/extent pair gives
|
||||
## the same answer — the caller picks one, consistently). `canvas_px` is
|
||||
## kept as a parameter (unused by the coverage rule itself) for signature
|
||||
## stability with existing callers and because a future finer-than-Quarter
|
||||
## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need
|
||||
## it again.
|
||||
##
|
||||
## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region").
|
||||
static func select_rung(world_extent_m: float, _canvas_px: float) -> String:
|
||||
for rung: String in RUNGS_FINEST_FIRST:
|
||||
if world_extent_m <= float(MAX_COVERAGE_M[rung]):
|
||||
return rung
|
||||
return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job)
|
||||
|
||||
|
||||
## The metre spacing a given granularity_v2 tag resolves to — the inverse
|
||||
## lookup select_rung() itself doesn't need but callers computing "what world
|
||||
## extent does holding N districts at this rung actually cover" do (the
|
||||
## viewer's own extent-in-real-units header line, and the full-zoom-out
|
||||
## predicate below).
|
||||
static func spacing_for_rung(granularity_v2: String) -> float:
|
||||
for rung: Dictionary in RUNG_TABLE:
|
||||
if rung["granularity_v2"] == granularity_v2:
|
||||
return float(rung["spacing_m"])
|
||||
return DISTRICT_SPACING_M # unknown tag -> district, matching the server's "unknown -> District" posture
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: full-zoom-out reset (Jeroen's D-226 T-1143-rulings HARD condition —
|
||||
# "a full zoom-out resets to the original canonical planetary frame and
|
||||
# location", the ladder's top rest state, not a drifted pan state).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## True once the requested world extent (at the CURRENT zoom, before any
|
||||
## further zoom-out) covers the full body — i.e. the player has zoomed out as
|
||||
## far as the ladder goes and is looking at (at least) the whole equatorial
|
||||
## circumference. This is the crisp "fully zoomed out" definition the ticket
|
||||
## asks for: `world_extent_m >= circumference_m` at the fit-zoom floor, rather
|
||||
## than a fuzzy "close to MIN_ZOOM" heuristic (MIN_ZOOM is a UI clamp
|
||||
## constant, not a planetary-coverage fact — a small body could hit MIN_ZOOM
|
||||
## while still showing less than the whole circumference, and a huge body
|
||||
## could show full coverage before MIN_ZOOM is reached, depending on
|
||||
## CELL_PIXEL_SIZE/held_n; extent-vs-circumference is the honest test either
|
||||
## way).
|
||||
##
|
||||
## **Distinct from select_rung()'s own coverage ceiling** (District's
|
||||
## `DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M = 131,072 m`, a fixed,
|
||||
## body-independent number) — this predicate's threshold is the actual
|
||||
## body's full circumference, always far larger than 131,072 m for any real
|
||||
## planet. The two compose in the expected order: extent crosses 131,072 m
|
||||
## first (select_rung() already reports Region well before this predicate
|
||||
## fires), and only once extent reaches the WHOLE circumference does the
|
||||
## top-rest-state reset itself trigger. There is no conflict between the two
|
||||
## thresholds, just two different questions ("which rung" vs. "are we at the
|
||||
## very top").
|
||||
static func is_fully_zoomed_out(world_extent_m: float, body_radius_km: float) -> bool:
|
||||
if body_radius_km <= 0.0:
|
||||
return false # no-radius (tiny test body) has no circumference concept — never auto-resets
|
||||
var circumference_m: float = TAU * body_radius_km * 1000.0
|
||||
return world_extent_m >= circumference_m
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: pure screen<->world math extracted from AtlasWindowViewer for
|
||||
# testability (the project's stated preference — static funcs over Control
|
||||
# instance methods wherever the math doesn't need the scene tree).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The world extent (metres) currently displayed across the LARGER viewport
|
||||
## dimension — the `E` half of the §5 rung-selection rule's `E/C`.
|
||||
## `cell_pixel_size` is the caller's district-at-zoom-1.0 constant
|
||||
## (AtlasWindowViewer.CELL_PIXEL_SIZE) — the composite's ON-SCREEN FOOTPRINT
|
||||
## is ALWAYS `held_n * cell_pixel_size * view_zoom` px for `held_n * DISTRICT_M`
|
||||
## metres of world, REGARDLESS of which rung is currently held (this is
|
||||
## exactly the invariant AtlasWindowOverlay.cell_grid_side_for_window()'s doc
|
||||
## establishes on the render side: `n` districts occupy a FIXED screen
|
||||
## footprint; only the DERIVED CELL RESOLUTION packed into that footprint
|
||||
## varies by rung). So the metres-per-screen-px sample density is a pure
|
||||
## function of `view_zoom` — DISTRICT_SPACING_M / (cell_pixel_size *
|
||||
## view_zoom) — with NO granularity_v2 parameter needed at all: the rung
|
||||
## itself is the OUTPUT of this calculation (via select_rung()), not an
|
||||
## input to it.
|
||||
static func world_extent_m(cell_pixel_size: float, view_zoom: float, viewport: Vector2) -> float:
|
||||
var canvas_px: float = cell_pixel_size * view_zoom
|
||||
if canvas_px <= 0.0:
|
||||
return 0.0
|
||||
var screen_px: float = maxf(viewport.x, viewport.y)
|
||||
return DISTRICT_SPACING_M / canvas_px * screen_px
|
||||
|
||||
|
||||
## The DistrictPos the viewport's screen center currently maps to, in RAW
|
||||
## absolute district space (un-wrapped, un-clamped — the caller canonicalizes
|
||||
## the final value it actually stores/sends, matching
|
||||
## canonicalize_district_center()'s own "canonicalize once, at the boundary"
|
||||
## discipline). Shared by AtlasWindowViewer's pan-edge refetch
|
||||
## (_maybe_refloat_window()) and rung-reselect refetch
|
||||
## (_maybe_reselect_rung()) so both read the SAME screen-to-district formula
|
||||
## rather than two copies that could drift.
|
||||
static func screen_center_to_district(
|
||||
viewport_size: Vector2,
|
||||
view_offset: Vector2,
|
||||
view_zoom: float,
|
||||
cell_pixel_size: float,
|
||||
held_center: Vector2i,
|
||||
held_n: int
|
||||
) -> Vector2i:
|
||||
var screen_center: Vector2 = viewport_size * 0.5
|
||||
var canvas_pt: Vector2 = (screen_center - view_offset) / view_zoom
|
||||
var cell: Vector2 = canvas_pt / cell_pixel_size
|
||||
var half: float = float(held_n) / 2.0
|
||||
var abs_col: float = float(held_center.x) - half + cell.x
|
||||
var abs_row: float = float(held_center.y) - half + cell.y
|
||||
return Vector2i(roundi(abs_col), roundi(abs_row))
|
||||
|
||||
|
||||
## Live round 4 fix: the exact INVERSE of screen_center_to_district()'s own
|
||||
## district-space math, in CANVAS-LOCAL space (i.e. _canvas's own child
|
||||
## coordinate system — BEFORE _view_offset/_view_zoom, which is what
|
||||
## AtlasWindowOverlay._draw()/_draw_tile_mosaic() draw into, since the
|
||||
## Node2D's position/scale already carries pan/zoom). Every held-window
|
||||
## convention in this file agrees canvas-local `(0,0)` is absolute district
|
||||
## `(held_center - held_n/2)` — single-window `_draw()`'s own
|
||||
## `Rect2(0,0,extent,extent)` relies on this being true for `held_center` ==
|
||||
## the window's own center. `_draw_tile_mosaic()`'s per-tile placement must
|
||||
## use this SAME formula (with the VIEWER's `held_center`/`held_n`, not a
|
||||
## tile's own center/TILE_N) to land in the same coordinate frame the
|
||||
## fit/pan/zoom machinery already assumes — drawing tiles relative to
|
||||
## absolute district (0,0) directly (the live-round-4 bug) silently
|
||||
## disagreed with fit_window_view()'s own `[0, held_n)`-from-origin
|
||||
## assumption whenever `held_n` (the WHOLE-BODY extent in tile mode) wasn't
|
||||
## itself anchored the same way, pushing the entire mosaic off-canvas.
|
||||
static func district_to_canvas_local(
|
||||
district: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float
|
||||
) -> Vector2:
|
||||
var half: float = float(held_n) / 2.0
|
||||
var local_col: float = (district.x - (float(held_center.x) - half)) * cell_pixel_size
|
||||
var local_row: float = (district.y - (float(held_center.y) - half)) * cell_pixel_size
|
||||
return Vector2(local_col, local_row)
|
||||
|
||||
|
||||
## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully
|
||||
## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into
|
||||
## `[0, cols)` — the correct, single-valued key for REQUESTS and cache
|
||||
## coalescing), but a canonical column has infinitely many EQUIVALENT
|
||||
## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since
|
||||
## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR
|
||||
## function with no wrap concept — fed a canonical column directly, it
|
||||
## places the tile at exactly ONE of those wrap-images, which is only ever
|
||||
## the visually-correct one by coincidence. Lendel's own repro: the tile
|
||||
## whose pre-canonicalization center was -6400 canonicalizes to 12739
|
||||
## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at
|
||||
## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT),
|
||||
## when the tile's actual visible position (immediately west of the
|
||||
## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT
|
||||
## third of the mosaic).
|
||||
##
|
||||
## The fix: before handing a tile's canonical column to
|
||||
## `district_to_canvas_local()`, re-express it as whichever wrap-image
|
||||
## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` —
|
||||
## the representative that's actually near the current view, matching how a
|
||||
## real, non-tiling single-window pan already resolves the "which
|
||||
## circumnavigation" question implicitly (screen_center_to_district()'s own
|
||||
## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile
|
||||
## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is
|
||||
## no periodicity to resolve.
|
||||
static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int:
|
||||
if cols <= 0:
|
||||
return canonical_col
|
||||
var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2
|
||||
return held_center_col + delta
|
||||
|
||||
|
||||
## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond
|
||||
## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a
|
||||
## PURE screen<->canvas-local transform, entirely independent of
|
||||
## `held_center`/`held_n` — cursor-anchored zoom (`_zoom_at()`) never
|
||||
## references them. But the single-window `_draw()` path draws the held
|
||||
## composite at canvas-local `Rect2(0,0,extent,extent)`, which is ONLY the
|
||||
## right place on screen if canvas-local (0,0) still equals
|
||||
## `held_center - held_n/2` for the NEW rung. `_maybe_reselect_rung()`
|
||||
## updates `held_center`/`held_n` to the new rung's values (a DIFFERENT
|
||||
## `held_n` — Region's ~thousands vs. District's 64 vs. Quarter's 16) but
|
||||
## never touched `_view_offset` to compensate — so canvas-local (0,0)
|
||||
## silently stopped meaning `held_center - held_n/2` the instant `held_n`
|
||||
## changed, and the composite (still drawn at local (0,0)) landed wherever
|
||||
## the STALE offset happened to put it — off-canvas by tens or hundreds of
|
||||
## thousands of px for a Region-to-District/Quarter crossing (round 4's
|
||||
## repro), same root shape as the tile-mosaic bug, just on the "one held
|
||||
## window" side of the split instead of the "many tiles" side.
|
||||
##
|
||||
## This is the exact INVERSE construction: given the SAME screen point that
|
||||
## used to map to `old_local` must now map to canvas-local
|
||||
## `new_held_n/2 * cell_pixel_size` (i.e. new_held_center's own position
|
||||
## under the NEW window's `[0, new_held_n)` span), solve for the
|
||||
## `view_offset` that makes `screen_point == new_local * view_zoom +
|
||||
## view_offset` true. Pan-edge refetch (`_maybe_refloat_window()`) never
|
||||
## needed this because it never changes `held_n` — only rung crossings do.
|
||||
static func recompute_offset_for_held_n_change(
|
||||
screen_point: Vector2, view_zoom: float, new_held_n: int, cell_pixel_size: float
|
||||
) -> Vector2:
|
||||
var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * cell_pixel_size)
|
||||
return screen_point - new_local * view_zoom
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/
|
||||
# arrow-key held-pan direction + edge-scroll suppression/direction — pure
|
||||
# functions of explicit inputs, no Control/scene-tree dependency.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action): W/S/A/D on this project's global InputMap are
|
||||
## already bound to move_north/move_south/move_east/move_west (gameplay
|
||||
## movement, D-054 mouse-relative facing) — reusing those actions here would
|
||||
## make holding W simultaneously pan this map AND queue a gameplay move
|
||||
## command server-side the moment this implant screen closes back to
|
||||
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
|
||||
## with no implant-occlusion guard — a genuine pre-existing gap outside this
|
||||
## ticket's scope, not introduced here). Reading the raw physical keycode
|
||||
## instead of the shared action name means this screen's WASD use is fully
|
||||
## independent of whatever the gameplay action happens to be bound to — same
|
||||
## key, two UNRELATED consumers, neither needs to know about the other.
|
||||
## Arrow keys have no InputMap action bound at all, so they're conflict-free
|
||||
## either way. Returns a raw (non-normalized) direction — the caller
|
||||
## normalizes once after adding the edge-scroll contribution, so N+E doesn't
|
||||
## move faster than N alone. Reads the global `Input` singleton directly (not
|
||||
## injected) — this is the one function in this file that isn't a pure
|
||||
## function of its arguments, kept here anyway to sit beside its two siblings
|
||||
## below rather than splitting the WASD/edge-scroll trio across two files.
|
||||
static func held_pan_direction() -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
|
||||
direction.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
|
||||
direction.y += 1.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
direction.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
direction.x += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
|
||||
## (`is_over_ui` — the caller's own _is_over_ui() result, passed in rather
|
||||
## than called from here since "what counts as UI" is viewer-specific) and
|
||||
## (b) while the application window itself lacks OS focus (`app_has_focus` —
|
||||
## otherwise a background window with the cursor left resting near its edge
|
||||
## from a previous session would silently pan while the player is doing
|
||||
## something else entirely; Godot's NOTIFICATION_APPLICATION_FOCUS_OUT/IN
|
||||
## make this directly detectable, the caller's own _notification() wires it).
|
||||
static func is_cursor_edge_scrolling(
|
||||
app_has_focus: bool,
|
||||
is_over_ui: bool,
|
||||
viewport_size: Vector2,
|
||||
mouse_pos: Vector2,
|
||||
edge_margin_px: float
|
||||
) -> bool:
|
||||
if not app_has_focus:
|
||||
return false
|
||||
if is_over_ui:
|
||||
return false
|
||||
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||
return false
|
||||
return (
|
||||
mouse_pos.x >= 0.0
|
||||
and mouse_pos.y >= 0.0
|
||||
and mouse_pos.x <= viewport_size.x
|
||||
and mouse_pos.y <= viewport_size.y
|
||||
and (
|
||||
mouse_pos.x < edge_margin_px
|
||||
or mouse_pos.y < edge_margin_px
|
||||
or mouse_pos.x > viewport_size.x - edge_margin_px
|
||||
or mouse_pos.y > viewport_size.y - edge_margin_px
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Direction toward whichever edge(s) the cursor is near — same shape as
|
||||
## held_pan_direction() (a raw, un-normalized Vector2 the caller combines and
|
||||
## normalizes once).
|
||||
static func edge_scroll_direction(
|
||||
viewport_size: Vector2, mouse_pos: Vector2, edge_margin_px: float
|
||||
) -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if mouse_pos.x < edge_margin_px:
|
||||
direction.x -= 1.0
|
||||
elif mouse_pos.x > viewport_size.x - edge_margin_px:
|
||||
direction.x += 1.0
|
||||
if mouse_pos.y < edge_margin_px:
|
||||
direction.y -= 1.0
|
||||
elif mouse_pos.y > viewport_size.y - edge_margin_px:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST
|
||||
# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"]
|
||||
# = 13,107,200 m) covers only a fraction of a real body's circumference
|
||||
# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top
|
||||
# rest state composes MULTIPLE Region windows ("progressive capped-density
|
||||
# TILING", design doc §4) into a mosaic under ONE view transform.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Compute the tile-set grid for the orbital rest state: the minimal set of
|
||||
## Region-granularity window CENTERS (each `TILE_N` districts wide) whose
|
||||
## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s
|
||||
## own east-west periodicity), rows clamp at the poles. Returns an Array of
|
||||
## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row
|
||||
## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny
|
||||
## body where multiple nominal tile rows clamp to the identical pole-adjacent
|
||||
## row, or multiple nominal tile columns wrap to the identical column, must
|
||||
## not request/draw the same tile twice).
|
||||
##
|
||||
## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full
|
||||
## circumference (evenly spaced, centered on column 0 — the canonical
|
||||
## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to
|
||||
## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is
|
||||
## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric
|
||||
## around the canonical origin, matching enter_orbital()'s own "canonical
|
||||
## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's
|
||||
## own center-of-mass lands exactly on the canonical frame, not offset from
|
||||
## it.
|
||||
##
|
||||
## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a
|
||||
## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback
|
||||
## disposition (no circumference/tiling concept for a body with no radius).
|
||||
static func compute_tile_grid(body_radius_km: float) -> Array:
|
||||
if body_radius_km <= 0.0:
|
||||
return [Vector2i.ZERO]
|
||||
|
||||
var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km)
|
||||
var cols: int = int(extent["cols"])
|
||||
var rows_half: int = int(extent["rows_half"])
|
||||
var rows_total: int = rows_half * 2
|
||||
|
||||
var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N)))
|
||||
var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N)))
|
||||
|
||||
var col_centers: Array = []
|
||||
for tx in range(cols_tiles):
|
||||
var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
col_centers.append(raw_col)
|
||||
|
||||
var row_centers: Array = []
|
||||
for ty in range(rows_tiles):
|
||||
var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N))
|
||||
row_centers.append(raw_row)
|
||||
|
||||
# Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair —
|
||||
# Godot Dictionary keys compare Vector2i by value, so this is a proper
|
||||
# set. Insertion order is preserved (Godot Dictionaries are
|
||||
# order-preserving), giving a deterministic tile ORDER too — the same
|
||||
# grid always requests/draws in the same sequence, useful for progressive
|
||||
# arrival to read as a stable left-to-right, top-to-bottom fill rather
|
||||
# than an unpredictable one.
|
||||
var seen: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for raw_col: int in col_centers:
|
||||
for raw_row: int in row_centers:
|
||||
var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center(
|
||||
Vector2i(raw_col, raw_row), body_radius_km
|
||||
)
|
||||
if not seen.has(canonical):
|
||||
seen[canonical] = true
|
||||
tiles.append(canonical)
|
||||
return tiles
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved
|
||||
# here from atlas_window_viewer.gd for file-length (the viewer's own
|
||||
# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since
|
||||
# both are directly tested by name).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Body name + coordinate label — T-1142: shows the body's proper name
|
||||
## (falling back to body_id) alongside the held district center, so the
|
||||
## header never reads as bare "district (col, row)" with no indication of
|
||||
## WHICH body the player is looking at.
|
||||
static func location_label(body_display_name: String, held_center: Vector2i) -> String:
|
||||
return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y]
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header.
|
||||
## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always
|
||||
## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s
|
||||
## doc), but the km/cell reading reflects the HELD rung's actual spacing
|
||||
## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous
|
||||
## metres-per-pixel/extent readout" design doc §6 calls for in place of a
|
||||
## discrete "you are now in Quarter Mode" label (Jeroen's "no mode
|
||||
## transition" ruling): the number itself communicates the rung.
|
||||
static func screen_header_content(
|
||||
body_display_name: String,
|
||||
held_center: Vector2i,
|
||||
held_n: int,
|
||||
held_granularity_v2: String,
|
||||
district_m: float
|
||||
) -> Dictionary:
|
||||
var label: String = location_label(body_display_name, held_center)
|
||||
var extent_km: float = float(held_n) * district_m / 1000.0
|
||||
var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0
|
||||
var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle}
|
||||
|
||||
@@ -16,6 +16,7 @@ const PANEL_MARGIN: float = 16.0
|
||||
const LEGEND_PANEL_WIDTH: float = 260.0
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## The morphology base layer folds its 17 zones into ~5 family rows (§5:
|
||||
## "mirroring T-1112's 'not everything earns permanent screen space'
|
||||
@@ -55,13 +56,28 @@ func reposition() -> void:
|
||||
## Always shows the base-layer key (morphology + elevation reading, always
|
||||
## on) plus glaciation (always-on modifier), then whichever toggle overlay is
|
||||
## currently active, if any.
|
||||
##
|
||||
## The subtitle's km/cell reading is NOT a fixed "district window" label —
|
||||
## it was until this fix hardcoded District's own 2.048 km/cell, which was a
|
||||
## 100x lie whenever the viewer actually holds Region (204.8 km/cell) or
|
||||
## Quarter (0.512 km/cell). Read through AtlasWindowGeometry.
|
||||
## spacing_for_rung() — the SAME pure lookup _refresh_screen_header() uses
|
||||
## for the main screen header's subtitle (screen_header_content()) — keyed
|
||||
## off the viewer's current get_held_granularity_v2(), so the legend and
|
||||
## header can never disagree. AtlasWindowViewer calls refresh() at every
|
||||
## point _held_granularity_v2 changes (_enter_tile_mode(), _enter_at_rung(),
|
||||
## _on_window_ready()'s rung-swap adoption) — see those call sites.
|
||||
func refresh() -> void:
|
||||
if _viewer == null:
|
||||
return
|
||||
clear()
|
||||
visible = true
|
||||
|
||||
add_component(ImplantHeader.new("REGIONAL LEGEND", "district window · 2.048 km/cell"))
|
||||
var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_viewer.get_held_granularity_v2()) / 1000.0
|
||||
var subtitle: String = "%s window · %.3f km/cell" % [
|
||||
_viewer.get_held_granularity_v2().to_lower(), spacing_km
|
||||
]
|
||||
add_component(ImplantHeader.new("REGIONAL LEGEND", subtitle))
|
||||
add_component(ImplantSeparator.new())
|
||||
|
||||
_add_morphology_section()
|
||||
|
||||
@@ -46,9 +46,25 @@ extends Node2D
|
||||
## window/overlay state, so the common case (panning within an already-held
|
||||
## window) is zero rebuild cost — draw_texture_rect() on an already-built
|
||||
## ImageTexture, same as any other texture draw.
|
||||
##
|
||||
## T-1152/T-1153: `n` (the response's echoed district extent) and the DERIVED
|
||||
## cell-grid side length are now DIFFERENT quantities at every rung except
|
||||
## District — cell_grid_side_for_window() computes the latter from `n` and
|
||||
## the response's own `granularity_v2` echo (mirroring the server's
|
||||
## `WindowGranularity::cell_grid_side` exactly), so a Region-rung response (a
|
||||
## FAR SPARSER cell grid than its district extent — see that Rust doc's
|
||||
## "inversion" note) renders through the exact same colorizer pipeline as
|
||||
## District/Quarter, satisfying the design doc §6 "one colorizer family, no
|
||||
## per-rung palettes" encoding-continuity requirement — no branch in
|
||||
## _cell_color()/_temp_cell_color()/etc. below needed any change at all.
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path.
|
||||
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix.
|
||||
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
|
||||
## T-1145 item 3: interim presentation toggle — true renders the smoothed
|
||||
## Image/ImageTexture composite; false keeps the original crisp per-cell
|
||||
@@ -78,10 +94,30 @@ var _cached_texture: ImageTexture = null
|
||||
var _cache_window_ref: Variant = null
|
||||
var _cache_active_toggle: String = ""
|
||||
|
||||
## Live round 4 fix: per-TILE texture cache, keyed by tile index — mirrors
|
||||
## the single-window cache above, but one slot per mosaic tile (a Dictionary
|
||||
## of `{window_ref, active_toggle, texture}`, since the mosaic doesn't have a
|
||||
## single fixed set of tiles the way the single-window path has a single
|
||||
## fixed field). Building a brand-new, UNSTORED `ImageTexture` every
|
||||
## `_draw()` call (the round-3 version) left it referenced only by a local
|
||||
## variable — nothing keeps the RID alive past the function returning, which
|
||||
## raced against the RenderingServer's deferred draw-command flush and
|
||||
## rendered as a blank/white tile (the round-4 "pitch black"/white-mosaic
|
||||
## repro's second half, beyond the coordinate fix above): the CPU-side pixel
|
||||
## data was provably correct (sampled directly), but the GPU-side texture
|
||||
## backing it could be gone by composite time. Caching each tile's texture
|
||||
## as a class-owned Dictionary entry (same reference-identity rebuild-only-
|
||||
## on-change discipline as `_cached_texture`) keeps it alive exactly as long
|
||||
## as the single-window composite's own texture already is.
|
||||
var _tile_texture_cache: Dictionary = {}
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if viewer == null:
|
||||
return
|
||||
if viewer.is_tile_mode():
|
||||
_draw_tile_mosaic()
|
||||
return
|
||||
var window: Variant = viewer.get_district_window()
|
||||
if not window is Dictionary:
|
||||
return
|
||||
@@ -94,25 +130,245 @@ func _draw() -> void:
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
return
|
||||
|
||||
# T-1152/T-1153: `n` (the response's echoed field) is ALWAYS window extent
|
||||
# in DISTRICTS at every rung (Dudley's wire contract, DistrictWindowLayer.n's
|
||||
# own doc) — the per-cell arrays (morphology/elev_q/etc.) are sized by the
|
||||
# DERIVED cell-grid side, `cell_grid_side_for_window()` below, which equals
|
||||
# `n` only at District granularity. Quarter packs MORE cells into the same
|
||||
# n-district extent (`n*4`); Region packs FEWER, since one region cell
|
||||
# spans 100 districts (`round(n/100).max(1)`). The on-screen EXTENT stays
|
||||
# `n * cell_px` regardless of rung (CELL_PIXEL_SIZE is defined as "one
|
||||
# DISTRICT at zoom=1.0" — see the viewer's own doc on that constant) so a
|
||||
# rung swap at a fixed pan/zoom never jumps the composite's screen footprint
|
||||
# (§6 "no layout jump") — only the TEXTURE RESOLUTION packed into that
|
||||
# footprint changes, exactly the "same colorizer family, different LoD"
|
||||
# picture the design doc describes.
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
return
|
||||
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
|
||||
if COMPOSITE_SMOOTH:
|
||||
_draw_smoothed_composite(w, n, cell_px, active_toggle)
|
||||
_draw_smoothed_composite(w, n, grid_side, cell_px, active_toggle)
|
||||
else:
|
||||
_draw_crisp_composite(w, n, cell_px, active_toggle)
|
||||
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
|
||||
|
||||
|
||||
## T-1145 item 3: the smoothed path — build/reuse an n x n ImageTexture (one
|
||||
## pixel per district) and draw it scaled to (n*cell_px) with LINEAR
|
||||
## filtering. texture_filter is set on `self` (a CanvasItem property) once
|
||||
## per draw — cheap (a property write, not a texture rebuild) and correct
|
||||
## even the first time this runs (Godot's engine default already IS linear,
|
||||
## but this makes the choice explicit rather than relying on an implicit
|
||||
## project-wide default that could change).
|
||||
func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
## T-1153, live round 3/4 (Jeroen's ruling, design doc §4): the orbital
|
||||
## rest-state MOSAIC draw path — one call to the EXISTING single-tile
|
||||
## composite-building logic (`_rebuild_texture_if_needed()`/
|
||||
## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE,
|
||||
## each positioned at its own LOCAL offset in the SAME canvas-local
|
||||
## coordinate frame the single-window path (and fit_window_view()/
|
||||
## screen_center_to_district()) already use.
|
||||
##
|
||||
## **Live round 4 fix:** the round-3 version placed tiles relative to
|
||||
## absolute district (0,0) directly (`(tile.center - TILE_N/2) * cell_px`),
|
||||
## which does NOT match `_fit_and_center()`'s own convention — canvas-local
|
||||
## (0,0) is `held_center - held_n/2` (AtlasWindowGeometry.
|
||||
## district_to_canvas_local()'s own doc), and in tile mode `held_n` is the
|
||||
## WHOLE BODY's extent, not TILE_N. That mismatch pushed the entire mosaic
|
||||
## off-canvas (round 4's "pitch black" repro) — silent, since nothing
|
||||
## errors, it just draws somewhere the viewport never shows. Fixed by
|
||||
## routing every tile's placement through `district_to_canvas_local()` with
|
||||
## the VIEWER's own `held_center`/`held_n`, the exact reference frame every
|
||||
## other canvas-local consumer (fit/pan/reselect) already agrees on. Tiles
|
||||
## that haven't arrived yet (`tile["window"] == null`) are simply SKIPPED —
|
||||
## no per-tile placeholder draw, letting COLOR_BG show through as the honest
|
||||
## "nothing here yet" read (the viewer's own `_draw()` already documents why
|
||||
## no separate whole-viewport fade is needed on top of this).
|
||||
##
|
||||
## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into
|
||||
## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys,
|
||||
## since longitude is periodic and a canonical column is the single-valued
|
||||
## key both sides of the wire agree on). But `district_to_canvas_local()`
|
||||
## is a pure LINEAR function with no wrap concept — handed a canonical
|
||||
## column directly, it places the tile at exactly ONE of its infinitely
|
||||
## many equivalent on-screen positions (`col + k*cols`), which is only the
|
||||
## visually-correct one by coincidence. Lendel's own repro: the tile whose
|
||||
## true position is immediately WEST of the canonical origin canonicalizes
|
||||
## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands
|
||||
## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by
|
||||
## re-expressing each tile's column via `nearest_wrap_image()` — whichever
|
||||
## wrap-image is closest to `held_center`, i.e. the one actually near the
|
||||
## current view — BEFORE handing it to `district_to_canvas_local()`.
|
||||
func _draw_tile_mosaic() -> void:
|
||||
var tile_set = viewer.get_tile_set()
|
||||
if tile_set == null:
|
||||
return
|
||||
var cell_px: float = viewer.get_cell_pixel_size()
|
||||
var active_toggle: String = _active_toggle_overlay()
|
||||
var held_center: Vector2i = viewer.get_held_center()
|
||||
var held_n: int = viewer.get_held_n()
|
||||
var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
|
||||
var tiles: Array = tile_set.get_tiles()
|
||||
var cols: int = int(
|
||||
AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0)
|
||||
)
|
||||
|
||||
for i in range(tiles.size()):
|
||||
var tile: Dictionary = tiles[i]
|
||||
var window: Variant = tile["window"]
|
||||
if not window is Dictionary:
|
||||
continue
|
||||
var w: Dictionary = window
|
||||
var morphology: Variant = w.get("morphology")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
continue
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
continue
|
||||
|
||||
var center: Vector2i = tile["center"]
|
||||
var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols)
|
||||
var tile_top_left: Vector2 = Vector2(
|
||||
float(draw_col) - half_tile, float(center.y) - half_tile
|
||||
)
|
||||
var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local(
|
||||
tile_top_left, held_center, held_n, cell_px
|
||||
)
|
||||
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
|
||||
_draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle)
|
||||
|
||||
|
||||
## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the
|
||||
## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED),
|
||||
## just drawn at `local_origin` instead of always at (0,0). Each tile gets
|
||||
## its OWN texture-rebuild cache slot in `_tile_texture_cache`, keyed by
|
||||
## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the
|
||||
## single-window field) would thrash on every draw call as different tiles'
|
||||
## windows compete for it.
|
||||
func _draw_one_tile(
|
||||
tile_index: int,
|
||||
w: Dictionary,
|
||||
grid_side: int,
|
||||
local_origin: Vector2,
|
||||
extent: float,
|
||||
active_toggle: String
|
||||
) -> void:
|
||||
if not COMPOSITE_SMOOTH:
|
||||
_draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle)
|
||||
return
|
||||
var tile_texture: ImageTexture = _rebuild_tile_texture_if_needed(
|
||||
tile_index, w, grid_side, active_toggle
|
||||
)
|
||||
if tile_texture == null:
|
||||
return
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_rebuild_texture_if_needed(w, n, active_toggle)
|
||||
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
|
||||
|
||||
|
||||
## Live round 4 fix: rebuilds (and, critically, KEEPS — see
|
||||
## `_tile_texture_cache`'s own doc for why an unstored local `ImageTexture`
|
||||
## silently rendered blank/white) `_tile_texture_cache[tile_index]`'s texture
|
||||
## ONLY when that tile's window object or the active toggle overlay has
|
||||
## changed since the last build — the SAME reference-identity discipline
|
||||
## `_rebuild_texture_if_needed()` uses for the single-window composite, one
|
||||
## cache entry per tile index instead of one shared field.
|
||||
func _rebuild_tile_texture_if_needed(
|
||||
tile_index: int, w: Dictionary, grid_side: int, active_toggle: String
|
||||
) -> ImageTexture:
|
||||
var entry: Dictionary = _tile_texture_cache.get(tile_index, {})
|
||||
if (
|
||||
is_same(entry.get("window_ref"), w)
|
||||
and entry.get("active_toggle") == active_toggle
|
||||
and entry.get("texture") != null
|
||||
):
|
||||
return entry["texture"]
|
||||
var texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle)
|
||||
_tile_texture_cache[tile_index] = {
|
||||
"window_ref": w, "active_toggle": active_toggle, "texture": texture
|
||||
}
|
||||
return texture
|
||||
|
||||
|
||||
## Builds a tile's own Image/ImageTexture from its per-cell colors —
|
||||
## identical pipeline to `_rebuild_texture_if_needed()`, just returning the
|
||||
## texture directly instead of writing to the single-window cache fields
|
||||
## (the CALLER, `_rebuild_tile_texture_if_needed()`, owns persisting it).
|
||||
func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
img.set_pixel(col, row, Color.TRANSPARENT)
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
img.set_pixel(col, row, cell_color)
|
||||
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()`
|
||||
## exactly, just positioned at `local_origin` instead of always at (0,0).
|
||||
func _draw_crisp_tile(
|
||||
w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String
|
||||
) -> void:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
var screen_cell_px: float = extent / float(grid_side)
|
||||
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
if cell_color.a <= 0.0:
|
||||
continue
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px)
|
||||
draw_rect(
|
||||
Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color
|
||||
)
|
||||
|
||||
|
||||
## The derived cell-grid side length (in CELLS) for a window dict `w` —
|
||||
## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side`
|
||||
## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than
|
||||
## trusting a caller's separately-tracked rung (the response is the source of
|
||||
## truth for what it actually contains). Falls back to `n` unchanged
|
||||
## (District's own identity mapping) for an old-shape response with no
|
||||
## `granularity_v2` key — matches the server's own "unknown -> District"
|
||||
## posture and AtlasWindowRequest.on_response()'s own default-to-District
|
||||
## disposition for the same field.
|
||||
static func cell_grid_side_for_window(w: Dictionary) -> int:
|
||||
var n: int = int(w.get("n", 0))
|
||||
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
|
||||
match granularity_v2:
|
||||
AtlasWindowRequest.GRANULARITY_V2_QUARTER:
|
||||
return n * 4 # WINDOW_GRANULARITY_QUARTER multiplier — D-243 QUARTER_M
|
||||
AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return maxi(roundi(float(n) / 100.0), 1) # D-243 DISTRICTS_PER_REGION
|
||||
_:
|
||||
return n # District — 1:1
|
||||
|
||||
|
||||
## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side`
|
||||
## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see
|
||||
## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n
|
||||
## being the window's DISTRICT extent, with LINEAR filtering. texture_filter
|
||||
## is set on `self` (a CanvasItem property) once per draw — cheap (a property
|
||||
## write, not a texture rebuild) and correct even the first time this runs
|
||||
## (Godot's engine default already IS linear, but this makes the choice
|
||||
## explicit rather than relying on an implicit project-wide default that
|
||||
## could change).
|
||||
func _draw_smoothed_composite(
|
||||
w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String
|
||||
) -> void:
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_rebuild_texture_if_needed(w, grid_side, active_toggle)
|
||||
if _cached_texture == null:
|
||||
return
|
||||
var extent: float = float(n) * cell_px
|
||||
@@ -125,8 +381,9 @@ func _draw_smoothed_composite(w: Dictionary, n: int, cell_px: float, active_togg
|
||||
## read directly from `w` here (rather than threaded through as params, the
|
||||
## way the crisp path's _cell_color()/_apply_glaciation() calls already
|
||||
## receive them) since this function owns the whole per-cell loop, not just
|
||||
## one cell.
|
||||
func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) -> void:
|
||||
## one cell. `grid_side` (T-1152) is the DERIVED cell-grid side (see
|
||||
## cell_grid_side_for_window()), not the window's district extent `n`.
|
||||
func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: String) -> void:
|
||||
if (
|
||||
is_same(_cache_window_ref, w)
|
||||
and _cache_active_toggle == active_toggle
|
||||
@@ -139,10 +396,10 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) ->
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
|
||||
var img := Image.create(n, n, false, Image.FORMAT_RGBA8)
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
img.set_pixel(col, row, Color.TRANSPARENT)
|
||||
continue
|
||||
@@ -157,16 +414,22 @@ func _rebuild_texture_if_needed(w: Dictionary, n: int, active_toggle: String) ->
|
||||
|
||||
## The ORIGINAL crisp per-cell path — kept byte-for-byte behind
|
||||
## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both
|
||||
## renderings directly (see the class doc).
|
||||
func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle: String) -> void:
|
||||
## renderings directly (see the class doc). `grid_side` (T-1152) is the
|
||||
## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the
|
||||
## window's district extent) sizes the on-screen cell pitch so the total
|
||||
## drawn footprint stays `n * cell_px` regardless of rung.
|
||||
func _draw_crisp_composite(
|
||||
w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String
|
||||
) -> void:
|
||||
var elev_q: Variant = w.get("elev_q")
|
||||
var glaciation: Variant = w.get("glaciation")
|
||||
var morphology: Variant = w.get("morphology")
|
||||
var n_cells: int = morphology.size()
|
||||
var screen_cell_px: float = float(n) * cell_px / float(grid_side)
|
||||
|
||||
for row in range(n):
|
||||
for col in range(n):
|
||||
var i: int = row * n + col
|
||||
for row in range(grid_side):
|
||||
for col in range(grid_side):
|
||||
var i: int = row * grid_side + col
|
||||
if i >= n_cells:
|
||||
continue
|
||||
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
|
||||
@@ -175,7 +438,10 @@ func _draw_crisp_composite(w: Dictionary, n: int, cell_px: float, active_toggle:
|
||||
cell_color = _apply_glaciation(cell_color, glaciation, i)
|
||||
# +0.5 overdraw avoids hairline seams between adjacent cells —
|
||||
# same idiom as _draw_gen_district/_draw_gen_region_grid.
|
||||
draw_rect(Rect2(col * cell_px, row * cell_px, cell_px + 0.5, cell_px + 0.5), cell_color)
|
||||
draw_rect(
|
||||
Rect2(col * screen_cell_px, row * screen_cell_px, screen_cell_px + 0.5, screen_cell_px + 0.5),
|
||||
cell_color
|
||||
)
|
||||
|
||||
|
||||
## Which of the three mutually-exclusive toggle overlays (if any) is active.
|
||||
|
||||
@@ -41,13 +41,23 @@ const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
|
||||
const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY
|
||||
const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES
|
||||
|
||||
## T-1150 struct/key plumbing: this viewer only ever REQUESTS district
|
||||
## granularity today (requesting quarter is T-1153's job) — these constants
|
||||
## exist so the cache key / staleness guard below are granularity-aware from
|
||||
## day one, not bolted on later.
|
||||
## T-1150 struct/key plumbing: legacy int granularity — district is the
|
||||
## default for every caller that doesn't request quarter/Region explicitly.
|
||||
const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY
|
||||
const DEFAULT_MIN_WL_M: int = 0
|
||||
|
||||
## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" |
|
||||
## "District" | "Region". This is the axis request_now()/request_debounced()'s
|
||||
## `granularity_v2` parameter actually varies; the legacy int
|
||||
## (DEFAULT_GRANULARITY) stays pinned at district for every call this object
|
||||
## makes, since v2 always wins server-side once present
|
||||
## (resolve_window_granularity_v2()'s documented precedence) and the legacy
|
||||
## int cannot express Region at all.
|
||||
const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER
|
||||
const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT
|
||||
const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION
|
||||
const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2
|
||||
|
||||
## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N /
|
||||
## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()`
|
||||
## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror
|
||||
@@ -57,12 +67,24 @@ const DEFAULT_MIN_WL_M: int = 0
|
||||
const SERVER_DISTRICT_WINDOW_MAX_N: int = 64
|
||||
const SERVER_WIRE_CAP_CELLS: int = 4_096
|
||||
|
||||
## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's
|
||||
## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n`
|
||||
## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's
|
||||
## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in
|
||||
## sync with the server constant of the same name.
|
||||
const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
|
||||
## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region
|
||||
## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs
|
||||
## to reproduce `WindowGranularity::cell_grid_side`'s Region branch.
|
||||
const SERVER_DISTRICTS_PER_REGION: int = 100
|
||||
|
||||
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
var _cache = null # AtlasWindowCache
|
||||
var _body_id: String = ""
|
||||
var _center: Vector2i = Vector2i.ZERO
|
||||
var _n: int = DISTRICT_WINDOW_DEFAULT_N
|
||||
var _granularity: int = DEFAULT_GRANULARITY
|
||||
var _granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
var _min_wl_m: int = DEFAULT_MIN_WL_M
|
||||
var _pending: bool = false
|
||||
var _retries: int = 0
|
||||
@@ -127,23 +149,76 @@ static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int:
|
||||
return mini(n, maxi(cap_n, 1))
|
||||
|
||||
|
||||
## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/
|
||||
## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2`
|
||||
## EXACTLY, including its Region branch, per the ticket's explicit
|
||||
## instruction ("replicate the loop exactly, there is NO closed form"). For
|
||||
## District/Quarter this delegates straight to `_clamp_window_n_mirror()`
|
||||
## (byte-identical clamped `n`, matching the server's own
|
||||
## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs`
|
||||
## guarantee). For Region: per-axis clamp to
|
||||
## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded
|
||||
## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and
|
||||
## `n > 1` — there is no closed-form inverse of the rounding division
|
||||
## `cell_grid_side` uses at Region granularity, so this loop is the correct
|
||||
## (and only) mirror, not an approximation of one.
|
||||
static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int:
|
||||
if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION:
|
||||
var legacy_granularity: int = (
|
||||
DEFAULT_GRANULARITY
|
||||
if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT
|
||||
else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER
|
||||
)
|
||||
return _clamp_window_n_mirror(raw_n, legacy_granularity)
|
||||
|
||||
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION)
|
||||
while (
|
||||
_cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS
|
||||
and n > 1
|
||||
):
|
||||
n = int(n / 2.0)
|
||||
return maxi(n, 1)
|
||||
|
||||
|
||||
## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY:
|
||||
## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid
|
||||
## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's
|
||||
## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches
|
||||
## that for non-negative inputs (the only domain `n` — always >= 1 here —
|
||||
## can produce), so this is a faithful mirror, not an approximation.
|
||||
static func _cell_grid_side_region_mirror(n: int) -> int:
|
||||
var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION))
|
||||
return maxi(side, 1)
|
||||
|
||||
|
||||
## Entry point + pan re-request: request the window centered on `center`
|
||||
## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate
|
||||
## synchronous window_ready emit, no network traffic at all. Cache miss ->
|
||||
## fire the request now (the caller — either the initial entry or a
|
||||
## debounce-fired pan — has already decided this call SHOULD fire; the 150ms
|
||||
## debounce itself lives in request_debounced() below, not here, so this
|
||||
## function is also the one entry-mechanic click-through uses directly with
|
||||
## no debounce at all, matching §5's "first window" contract).
|
||||
func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2`
|
||||
## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the
|
||||
## default for every caller that doesn't ask for a different rung explicitly,
|
||||
## matching the legacy behavior byte-for-byte when omitted). Cache hit ->
|
||||
## immediate synchronous window_ready emit, no network traffic at all. Cache
|
||||
## miss -> fire the request now (the caller — either the initial entry or a
|
||||
## debounce-fired pan/zoom — has already decided this call SHOULD fire; the
|
||||
## 150ms debounce itself lives in request_debounced() below, not here, so
|
||||
## this function is also the one entry-mechanic click-through uses directly
|
||||
## with no debounce at all, matching §5's "first window" contract).
|
||||
func request_now(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int = DISTRICT_WINDOW_DEFAULT_N,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side
|
||||
_granularity_v2 = granularity_v2
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
|
||||
_debounce_timer.stop() # a direct request supersedes any pending debounced one
|
||||
|
||||
var cached: Variant = _cache.get_window(body_id, center, _n, _granularity, _min_wl_m)
|
||||
var cached: Variant = _cache.get_window(
|
||||
body_id, center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
if cached != null:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
@@ -152,7 +227,9 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
|
||||
_pending = true
|
||||
_retries = 0
|
||||
SimBridge.request_atlas_layers(body_id, "Topography", center, _n, _granularity, _min_wl_m)
|
||||
SimBridge.request_atlas_layers(
|
||||
body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
|
||||
|
||||
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
|
||||
@@ -160,27 +237,57 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
## candidate; only the LAST call within the debounce window actually fires
|
||||
## (Timer.start() on an already-running one-shot timer restarts it — Godot's
|
||||
## documented behavior — so a flick-and-resettle collapses to one request).
|
||||
func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
func request_debounced(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int = DISTRICT_WINDOW_DEFAULT_N,
|
||||
granularity_v2: String = DEFAULT_GRANULARITY_V2
|
||||
) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_granularity_v2 = granularity_v2
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
|
||||
_debounce_timer.start()
|
||||
|
||||
|
||||
func _on_debounce_timeout() -> void:
|
||||
request_now(_body_id, _center, _n)
|
||||
request_now(_body_id, _center, _n, _granularity_v2)
|
||||
|
||||
|
||||
## Handle an AtlasLayerResponse (routed by the owning viewer from its own
|
||||
## SimBridge.atlas_layers_received subscription — this object has no signal
|
||||
## connection of its own, matching atlas_generation_proxy.gd's on_response()
|
||||
## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m
|
||||
## (the player panned or navigated away while a request was in flight, or a
|
||||
## different rung's derive answers a request for a different rung, T-1150) —
|
||||
## the echoed fields ARE the staleness guard (§2, extended T-1150), compared
|
||||
## here against what THIS object most recently asked for.
|
||||
## shape). Ignores responses for a stale body/center/n/min_wl_m/granularity
|
||||
## (legacy OR v2, see below) — the player panned, zoomed across a rung
|
||||
## boundary, or navigated away while a request was in flight, or a different
|
||||
## rung's derive answers a request for a different rung, T-1150/T-1152 — the
|
||||
## echoed fields ARE the staleness guard (§2, extended T-1150/T-1152),
|
||||
## compared here against what THIS object most recently asked for.
|
||||
##
|
||||
## **Live-round finding (the second C1-shaped bug): v2 is AUTHORITATIVE over
|
||||
## the legacy field whenever v2 is present — the legacy comparison is
|
||||
## SKIPPED entirely, not run alongside it.** A T-1152-aware server (this
|
||||
## codebase's) ALWAYS populates `granularity_v2` on the wire (Dudley's
|
||||
## contract, `DistrictWindowLayer.granularity_v2`'s own doc: "Always
|
||||
## populated (never `None`)"), and for `Region` responses specifically the
|
||||
## LEGACY `granularity` slot carries `WINDOW_GRANULARITY_REGION_KEY`
|
||||
## (`u32::MAX` = 4294967295) — a reserved KEY-SPACE TAG, not a real
|
||||
## multiplier, that can never equal this object's own stored `_granularity`
|
||||
## (which stays pinned at `DEFAULT_GRANULARITY`=1 for every rung this object
|
||||
## requests, per that field's own doc — the legacy slot has no concept of
|
||||
## Region at all). Comparing the legacy field UNCONDITIONALLY alongside v2
|
||||
## therefore drops EVERY Region response as stale forever, even though the
|
||||
## v2 comparison alone would have correctly accepted it — exactly the live
|
||||
## bug (`_held_n` fixed; this is the same "old comparison still active
|
||||
## alongside the new one" class of bug, one layer up in the staleness
|
||||
## checks). Fix: branch on whether `granularity_v2` is actually PRESENT in
|
||||
## the response dict (`w.has(...)`, not `w.get(..., default)` — the
|
||||
## presence/absence distinction is the whole point here) — present (every
|
||||
## real server, always) -> v2 is the ONLY granularity comparison; absent (a
|
||||
## hypothetically old, pre-T-1152 server) -> fall back to the legacy
|
||||
## comparison alone, matching this object's own pre-T-1152 behavior exactly.
|
||||
func on_response(response: Dictionary) -> void:
|
||||
if str(response.get("body_id", "")) != _body_id:
|
||||
return
|
||||
@@ -205,29 +312,44 @@ func on_response(response: Dictionary) -> void:
|
||||
var w: Dictionary = window
|
||||
var echoed_center := _vec_from_center(w.get("center", [0, 0]))
|
||||
var echoed_n := int(w.get("n", 0))
|
||||
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
|
||||
var echoed_min_wl_m := int(w.get("min_wl_m", 0))
|
||||
var granularity_matches: bool = _echoed_granularity_matches(w)
|
||||
if (
|
||||
echoed_center != _center
|
||||
or echoed_n != _n
|
||||
or echoed_granularity != _granularity
|
||||
or echoed_min_wl_m != _min_wl_m
|
||||
or not granularity_matches
|
||||
):
|
||||
return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150)
|
||||
return # stale — answers a window we've since panned/zoomed away from, or a different rung
|
||||
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m)
|
||||
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2)
|
||||
window_ready.emit(w)
|
||||
|
||||
|
||||
## The granularity half of on_response()'s staleness check, split out for the
|
||||
## v2-authoritative-when-present precedence rule (see on_response()'s own
|
||||
## doc for the full live-round rationale). Presence, not value, is the
|
||||
## branch: `w.has("granularity_v2")` — a real server ALWAYS sets this key
|
||||
## (even if its value happened to coincidentally equal a default), so
|
||||
## checking presence rather than "is it the default value" is the only
|
||||
## correct way to distinguish "an old server that never heard of this field"
|
||||
## from "a new server whose value happens to match."
|
||||
func _echoed_granularity_matches(w: Dictionary) -> bool:
|
||||
if w.has("granularity_v2"):
|
||||
return str(w.get("granularity_v2")) == _granularity_v2
|
||||
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
|
||||
return echoed_granularity == _granularity
|
||||
|
||||
|
||||
func _schedule_retry() -> void:
|
||||
var timer := get_tree().create_timer(RETRY_DELAY)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
if _pending:
|
||||
SimBridge.request_atlas_layers(
|
||||
_body_id, "Topography", _center, _n, _granularity, _min_wl_m
|
||||
_body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2
|
||||
)
|
||||
)
|
||||
|
||||
@@ -236,6 +358,21 @@ func is_pending() -> bool:
|
||||
return _pending
|
||||
|
||||
|
||||
## The v2 granularity ("Quarter" | "District" | "Region") this object most
|
||||
## recently asked for — the viewer reads this to know which rung the HELD
|
||||
## window (once it arrives) actually is, without threading a second copy of
|
||||
## the state through window_ready's payload.
|
||||
func get_granularity_v2() -> String:
|
||||
return _granularity_v2
|
||||
|
||||
|
||||
## Current window extent in districts, as CLAMPED — the viewer's rung-
|
||||
## selection math needs this to compute the held composite's real-world
|
||||
## extent regardless of which rung last resolved it.
|
||||
func get_n() -> int:
|
||||
return _n
|
||||
|
||||
|
||||
func get_cache() -> Variant:
|
||||
return _cache
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
extends Node
|
||||
|
||||
## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's
|
||||
## ruling, design doc §4: "the top rest state is the WHOLE body, served as
|
||||
## progressive capped-density TILING"). A single wire-capped Region window
|
||||
## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a
|
||||
## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single
|
||||
## window is ~a third of the body, the exact live-round finding: shot 01's
|
||||
## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference).
|
||||
##
|
||||
## Owns N independent `AtlasWindowRequest` child instances — one per tile —
|
||||
## reusing 100% of the EXISTING, already-tested single-window request/cache/
|
||||
## debounce/retry machinery (atlas_window_request.gd) rather than
|
||||
## reinventing multi-window orchestration from scratch. Each tile is just a
|
||||
## Region-granularity window request at its own canonicalized center
|
||||
## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already
|
||||
## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline),
|
||||
## so nothing about the request/cache LAYER needed to change for tiling to
|
||||
## work — only the ORCHESTRATION (issue N requests instead of one) and the
|
||||
## DRAWING (a mosaic instead of one composite) are new.
|
||||
##
|
||||
## No `class_name` on purpose, matching every other viewer-owned helper in
|
||||
## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/
|
||||
## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer)
|
||||
## passes itself to `_init()`.
|
||||
##
|
||||
## Progressive arrival (design doc §4's own "with visible refinement as
|
||||
## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects
|
||||
## independently — a tile's own `_tiles[i]["window"]` updates the moment
|
||||
## THAT tile's response lands, with no dependency on any other tile's
|
||||
## arrival. The viewer/overlay reads `get_tiles()` every draw and renders
|
||||
## whichever tiles have arrived so far — an empty/border-fade gap for the
|
||||
## rest, exactly the same "hold what's there, sharpen in place" contract
|
||||
## single-window progressive refinement already has (§6 "no mode flip"),
|
||||
## just per-tile instead of per-composite.
|
||||
|
||||
signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
|
||||
var _body_id: String = ""
|
||||
var _tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
|
||||
## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest,
|
||||
## "window": Variant (null until arrived)} — one entry per tile, in the SAME
|
||||
## deterministic order compute_tile_grid() produces (stable fill order, see
|
||||
## that function's own doc).
|
||||
var _tiles: Array = []
|
||||
|
||||
|
||||
func _init(owner_ref = null) -> void:
|
||||
_owner = owner_ref
|
||||
|
||||
|
||||
## Unlike an individual AtlasWindowRequest (which has no signal connection of
|
||||
## its own — the OWNING viewer forwards responses to it, per that class'
|
||||
## own doc), the tile set DOES connect directly to
|
||||
## SimBridge.atlas_layers_received itself and fans a single response out to
|
||||
## EVERY tile's own `on_response()` — each tile's OWN staleness guard
|
||||
## (center/n/granularity_v2) decides whether that particular response is
|
||||
## the one IT was waiting for; only the matching tile ever adopts it. This
|
||||
## is the same "one shared inbound signal, N independent consumers filtering
|
||||
## by their own criteria" shape the design already uses elsewhere (every
|
||||
## AtlasWindowRequest instance filters on its own state from a common
|
||||
## broadcast — tiling just means N instances share the broadcast instead of
|
||||
## one).
|
||||
func _ready() -> void:
|
||||
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.on_response(response)
|
||||
|
||||
|
||||
## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid,
|
||||
## tears down any PREVIOUS tile set's child request nodes (a fresh
|
||||
## enter_orbital() on a DIFFERENT body must not leave stale tile requests
|
||||
## from the old body wired up), and issues one request per tile immediately
|
||||
## (no debounce — matching AtlasWindowRequest.request_now()'s own "first
|
||||
## window" contract, §5: entry is never debounced, only pan/rung-reselect
|
||||
## refetches are).
|
||||
func enter(body_id: String, body_radius_km: float) -> void:
|
||||
_teardown()
|
||||
_body_id = body_id
|
||||
var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km)
|
||||
for i in range(centers.size()):
|
||||
var center: Vector2i = centers[i]
|
||||
var request = AtlasWindowRequest.new(self)
|
||||
request.name = "Tile%d" % i
|
||||
add_child(request)
|
||||
var tile_index := i # capture by value for the lambda below
|
||||
request.window_ready.connect(
|
||||
func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window)
|
||||
)
|
||||
_tiles.append({"center": center, "request": request, "window": null})
|
||||
request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
func _on_tile_window_ready(index: int, window: Dictionary) -> void:
|
||||
if index < 0 or index >= _tiles.size():
|
||||
return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown)
|
||||
_tiles[index]["window"] = window
|
||||
tile_ready.emit(index)
|
||||
|
||||
|
||||
## Tear down every tile's request node — disconnects nothing explicitly
|
||||
## (queue_free() on a Node disconnects all its own signal connections
|
||||
## automatically, Godot's documented behavior) but DOES clear `_tiles` so a
|
||||
## stale index from an in-flight-but-now-orphaned request's eventual
|
||||
## response can never reach `_on_tile_window_ready()` with a now-meaningless
|
||||
## index (guarded there too, belt-and-suspenders).
|
||||
func _teardown() -> void:
|
||||
for tile: Dictionary in _tiles:
|
||||
var request = tile["request"]
|
||||
if is_instance_valid(request):
|
||||
request.queue_free()
|
||||
_tiles.clear()
|
||||
|
||||
|
||||
## The current tile set, for the viewer/overlay to draw — an Array of
|
||||
## {"center": Vector2i, "window": Variant} (the "request" key is internal,
|
||||
## not exposed here; callers only need center + arrived-or-null window).
|
||||
func get_tiles() -> Array:
|
||||
var result: Array = []
|
||||
for tile: Dictionary in _tiles:
|
||||
result.append({"center": tile["center"], "window": tile["window"]})
|
||||
return result
|
||||
|
||||
|
||||
## True once tiling is active for the current body — a body whose whole
|
||||
## circumference fits in ONE Region window's own coverage ceiling produces
|
||||
## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so
|
||||
## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling
|
||||
## machinery, technically) from "genuinely multiple tiles" — the viewer uses
|
||||
## this to decide whether the tile-set draw path or the ORIGINAL
|
||||
## single-window draw path is simpler/preferred for a small body (both are
|
||||
## correct; single-window avoids the extra Node/signal overhead when there's
|
||||
## only ever going to be one tile).
|
||||
func is_multi_tile() -> bool:
|
||||
return _tiles.size() > 1
|
||||
|
||||
|
||||
func get_tile_count() -> int:
|
||||
return _tiles.size()
|
||||
|
||||
|
||||
## True if every tile currently has an arrived window — the viewer/legend
|
||||
## chrome can use this to know when the mosaic is "complete" vs. still
|
||||
## progressively filling in.
|
||||
func is_fully_arrived() -> bool:
|
||||
if _tiles.is_empty():
|
||||
return false
|
||||
for tile: Dictionary in _tiles:
|
||||
if tile["window"] == null:
|
||||
return false
|
||||
return true
|
||||
@@ -1,62 +1,72 @@
|
||||
class_name AtlasWindowViewer
|
||||
extends Control
|
||||
|
||||
## Regional district-resolution window viewer (T-1138, D-226 T-1124
|
||||
## amendment). Entered via a click-through from the planetary AtlasViewer
|
||||
## (the 2026-07-21 §5 entry revision — NOT a zoom-threshold LOD swap).
|
||||
## Renders a DistrictWindowLayer composite: morphology base layer lightness-
|
||||
## modulated by elev_q, three switchable climate/vegetation overlays, and an
|
||||
## always-on glaciation ice-tint modifier (drawing itself is
|
||||
## AtlasWindowOverlay's job — this Control owns input, request orchestration,
|
||||
## chrome, and the pan/zoom transform).
|
||||
## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's
|
||||
## click-through-only entry per the D-226 T-1143-rulings amendment — see
|
||||
## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152
|
||||
## client half): the whole ladder from the canonical orbital frame (Region
|
||||
## rung) down to District/Quarter lives in ONE screen/Control, not a separate
|
||||
## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer
|
||||
## composite (or, at the orbital rest state on a large body, a MOSAIC of
|
||||
## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever
|
||||
## rung is currently held: morphology base layer lightness-modulated by
|
||||
## elev_q, three switchable climate/vegetation overlays, an always-on
|
||||
## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this
|
||||
## Control owns input, request orchestration, chrome, pan/zoom). One
|
||||
## colorizer family renders every rung unchanged (design doc §6).
|
||||
##
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5):
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153):
|
||||
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
|
||||
## = _canvas.scale — the SAME transform idiom as the planetary viewer.
|
||||
## - Zoom is ALWAYS client-side on the already-held composite (§5: "the
|
||||
## composite is a texture the client zooms client-side... from already-
|
||||
## held data") — it NEVER triggers a re-request. Only a pan past the held
|
||||
## window's edge does (§4/§5).
|
||||
## - _window_request (atlas_window_request.gd) owns the cache/debounce/
|
||||
## retry — this Control decides WHEN to call it (pan-edge detection,
|
||||
## entry), never talks to SimBridge directly itself.
|
||||
## = _canvas.scale.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but
|
||||
## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013): crossing a rung's
|
||||
## coverage ceiling (§5, AtlasWindowGeometry.select_rung()) fires a
|
||||
## background request for the new granularity while the OLD composite
|
||||
## keeps drawing — progressive refinement, no blank frame (§6). A pan
|
||||
## past the held window's edge re-requests the SAME rung at a new center.
|
||||
## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD
|
||||
## condition, see _maybe_reset_to_canonical_frame()) — on a body needing
|
||||
## tiling, re-enters `_tile_mode` (live round 3, design doc §4).
|
||||
## - _window_request (atlas_window_request.gd) owns the single-window
|
||||
## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of
|
||||
## those for the tiled rest state — this Control decides WHICH is active.
|
||||
##
|
||||
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
|
||||
## BREAKS click semantics with map objects, so it is removed entirely; clicks
|
||||
## are reserved for map objects, which will land in this window later, e.g.
|
||||
## settlements):
|
||||
## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click
|
||||
## semantics with future map objects, so it's removed entirely):
|
||||
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
|
||||
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
|
||||
## edge pans toward it (suppressed over UI / unfocused)
|
||||
## Mouse wheel zoom the held composite (client-side only, never refetches)
|
||||
## Esc back to the planetary view
|
||||
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX pans toward it (suppressed over UI / unfocused)
|
||||
## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153)
|
||||
## Esc back (nav.pop())
|
||||
|
||||
signal back_pressed
|
||||
|
||||
const PANEL_MARGIN: float = 16.0
|
||||
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
|
||||
|
||||
const MIN_ZOOM: float = 0.5
|
||||
const MAX_ZOOM: float = 8.0
|
||||
## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display
|
||||
## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED
|
||||
## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling
|
||||
## (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT granularity at
|
||||
## the SAME apparent screen extent, never clamping _view_zoom itself.
|
||||
## set_view() (T-1120 capture API) clamps to this same range independently.
|
||||
## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for
|
||||
## enter_orbital()'s largest legal `n` is never itself clamped (would
|
||||
## silently show LESS than the whole body). 0.0005 covers a ~120,000 km-
|
||||
## radius body at a 3840px 4K viewport.
|
||||
const MIN_ZOOM: float = 0.0005
|
||||
const MAX_ZOOM: float = 64.0
|
||||
const ZOOM_STEP: float = 1.15
|
||||
|
||||
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
|
||||
## zoom=1.0 — the ACTUAL screen-space pan rate is this value times the
|
||||
## CURRENT _view_zoom (see _process()'s pan tick), so panning covers the
|
||||
## same amount of TERRAIN per second regardless of zoom level. A fixed
|
||||
## SCREEN-px/s rate (no zoom scaling) would feel painfully slow zoomed in
|
||||
## (each screen pixel is a fraction of a district) and uncontrollably fast
|
||||
## zoomed out — scaling by zoom keeps the "how much world passes per
|
||||
## second" feel constant, matching the ticket's "speed in screen px/s
|
||||
## scaled by zoom" wording. ~6 districts/s at zoom=1.0 (96/16) — brisk
|
||||
## enough to cross a default n=32 window in ~5s, not a crawl.
|
||||
## zoom=1.0 — actual screen-space rate is this times CURRENT _view_zoom, so
|
||||
## panning covers the same TERRAIN per second regardless of zoom level.
|
||||
## ~6 districts/s at zoom=1.0 (96/16) — brisk, not a crawl.
|
||||
const PAN_SPEED_CANVAS_PX_S: float = 96.0
|
||||
|
||||
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll —
|
||||
## Jeroen's own number ("~24px").
|
||||
## Jeroen's own number ("~24px"). Uses the SAME speed as WASD (one pan feel,
|
||||
## two triggers) — no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both.
|
||||
const EDGE_SCROLL_MARGIN_PX: float = 24.0
|
||||
## Edge-scroll uses the SAME speed as WASD (one pan feel, two triggers) —
|
||||
## no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both.
|
||||
|
||||
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
|
||||
## (unlike AtlasViewer's heightmap, there is no source texture dictating a
|
||||
@@ -69,11 +79,16 @@ const COLOR_BG: Color = Color("#0d1117")
|
||||
## Border-fade target (§5 "what renders during the wait"): the underlying
|
||||
## whole-body heightmap's own background tint, so the newly-exposed edge
|
||||
## reads as "real data seen through", not a placeholder block. Reuses
|
||||
## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value rather than
|
||||
## inventing a new one — this IS a dimmer/less-certain read of the same
|
||||
## planetary data, not a different visual language.
|
||||
## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value — a dimmer/
|
||||
## less-certain read of the same planetary data, not a different visual language.
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
|
||||
## T-1153/R6: pending-refinement wash — border-fade's referent repointed to
|
||||
## "the previous derived composite at this position" for a rung-crossing zoom
|
||||
## (real data stays on screen, unlike the no-composite-yet case above). Same
|
||||
## hue, lighter alpha — hints something sharper is arriving, not that the view is wrong.
|
||||
const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
|
||||
|
||||
## D-243: 2,048 m per district side.
|
||||
const DISTRICT_M: float = 2048.0
|
||||
|
||||
@@ -82,6 +97,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re
|
||||
# atlas_descend_geometry.gd instead — it already owns district_extent()).
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
|
||||
# T-1153: orbital rest-state mosaic orchestration.
|
||||
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
|
||||
|
||||
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
|
||||
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
|
||||
@@ -119,6 +136,11 @@ var _implant_theme = null
|
||||
var _window: Variant = null # current DistrictWindowLayer Dictionary, or null while waiting
|
||||
var _held_center: Vector2i = Vector2i.ZERO
|
||||
var _held_n: int = 32
|
||||
## T-1153: the granularity_v2 tag this viewer is currently HOLDING (the
|
||||
## last-adopted _window's own rung) — distinct from
|
||||
## _window_request.get_granularity_v2() (most recently REQUESTED, may be a
|
||||
## different rung already in flight). Defaults to District (see enter()'s doc).
|
||||
var _held_granularity_v2: String = "District"
|
||||
|
||||
# ── Pan/zoom state ─────────────────────────────────────────────────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
@@ -160,6 +182,13 @@ var _screen_header: ImplantHeader = null
|
||||
var _overlay_bar = null
|
||||
var _legend_panel = null
|
||||
var _window_request = null # AtlasWindowRequest
|
||||
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
|
||||
|
||||
## T-1153 (design doc §4): true while showing the orbital rest state as a
|
||||
## MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the single held
|
||||
## composite (`_window`). Set by `_enter_tile_mode()`; cleared the moment
|
||||
## `_maybe_reselect_rung()` crosses OUT of Region.
|
||||
var _tile_mode: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -189,6 +218,11 @@ func _ready() -> void:
|
||||
add_child(_window_request)
|
||||
_window_request.window_ready.connect(_on_window_ready)
|
||||
|
||||
_tile_set = AtlasWindowTileSet.new(self)
|
||||
_tile_set.name = "TileSet"
|
||||
add_child(_tile_set)
|
||||
_tile_set.tile_ready.connect(_on_tile_ready)
|
||||
|
||||
_build_screen_header()
|
||||
_build_overlay_bar()
|
||||
_build_legend_panel()
|
||||
@@ -201,50 +235,122 @@ func _exit_tree() -> void:
|
||||
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` (a DistrictPos-
|
||||
## equivalent Vector2i, from the planetary click-through's derived position —
|
||||
## §5's "pan center read as click point"). n defaults to the client's
|
||||
## interactive default (32), half the server's hard cap.
|
||||
## Enter the window screen centered on `district_center` at District
|
||||
## granularity. n defaults to 32. Thin wrapper over _enter_at_rung()
|
||||
## (T-1153); survives as a direct-call test entry.
|
||||
##
|
||||
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
|
||||
## BEFORE it becomes `_held_center` or reaches the request — matching the
|
||||
## server's own normalize_window_center() exactly, so the request the client
|
||||
## sends and the echo the server sends back describe the SAME canonical
|
||||
## point from the first round-trip (never a raw-vs-normalized mismatch that
|
||||
## would fail the §2 staleness echo check). Also fits-and-centers the view
|
||||
## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an
|
||||
## n=32 composite is 512px native, a postage stamp unfitted in a real
|
||||
## viewport).
|
||||
## T-1142: `district_center` is canonicalized BEFORE it becomes
|
||||
## `_held_center` — matching the server's normalize_window_center().
|
||||
func enter(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
district_center: Vector2i,
|
||||
n: int = AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N
|
||||
) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
var canonical_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
|
||||
district_center, radius_km
|
||||
)
|
||||
_enter_at_rung(body, system, canonical_center, n, AtlasWindowRequest.GRANULARITY_V2_DISTRICT)
|
||||
|
||||
|
||||
## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital
|
||||
## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at
|
||||
## the canonical origin). The "regional" nav entry point (T-1152 client
|
||||
## half): the player lands on a fully-derived Region-rung view of the whole
|
||||
## body, then wheel-zoom descends CONTINUOUSLY from there. Canonical origin
|
||||
## = district (0,0), same quantity is_fully_zoomed_out()/
|
||||
## _maybe_reset_to_canonical_frame() test against. No-radius bodies fall
|
||||
## back to the District-rung default window.
|
||||
##
|
||||
## **Live round 3 (design doc §4): the rest state must TILE.** A single
|
||||
## wire-capped Region window covers only a fraction of a real body's
|
||||
## circumference. Once `compute_tile_grid()` returns MORE than one tile,
|
||||
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`.
|
||||
func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
_enter_at_rung(
|
||||
body, system, Vector2i.ZERO,
|
||||
AtlasWindowRequest.DISTRICT_WINDOW_DEFAULT_N,
|
||||
AtlasWindowRequest.GRANULARITY_V2_DISTRICT
|
||||
)
|
||||
return
|
||||
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
|
||||
if tiles.size() > 1:
|
||||
_enter_tile_mode(body, system, radius_km)
|
||||
return
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION)
|
||||
|
||||
|
||||
## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as
|
||||
## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`.
|
||||
## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps
|
||||
## its own TILE_N-sized request independently).
|
||||
func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void:
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var n: int = int(extent["cols"])
|
||||
_body = body
|
||||
_system = system
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
_held_center = AtlasDescendGeometry.canonicalize_district_center(district_center, radius_km)
|
||||
_held_center = Vector2i.ZERO
|
||||
_held_n = n
|
||||
_held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
_tile_mode = true
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n)
|
||||
_tile_set.enter(_dict_str(_body, "body_id", ""), radius_km)
|
||||
_refresh_screen_header()
|
||||
_legend_panel.refresh()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center`
|
||||
## must already be canonicalized by the caller. Resets every piece of
|
||||
## held/request state for a fresh descent, plus _held_granularity_v2.
|
||||
##
|
||||
## **C1 clamp-mirror, one layer up:** `n` MUST be clamped via
|
||||
## `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` — mirroring
|
||||
## AtlasWindowRequest.request_now()'s own clamp (PR #191 Tyre C1).
|
||||
func _enter_at_rung(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
district_center: Vector2i,
|
||||
n: int,
|
||||
granularity_v2: String
|
||||
) -> void:
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(n, granularity_v2)
|
||||
_body = body
|
||||
_system = system
|
||||
_held_center = district_center
|
||||
_held_n = clamped_n
|
||||
_held_granularity_v2 = granularity_v2
|
||||
_tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode
|
||||
_window = null
|
||||
_user_adjusted = false
|
||||
_awaiting_first_window = true
|
||||
_fit_and_center()
|
||||
_window_request.reset()
|
||||
_window_request.request_now(
|
||||
_dict_str(_body, "body_id", ""), _held_center, clamped_n, granularity_v2
|
||||
)
|
||||
_refresh_screen_header()
|
||||
_legend_panel.refresh()
|
||||
grab_focus()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s
|
||||
## zoom/offset, then re-clamps the offset to the pole wall (a freshly-fitted
|
||||
## view can still need the wall on a tiny body whose row span is shorter than
|
||||
## the window itself — see atlas_window_geometry.gd's clamp function doc).
|
||||
## Called from enter(), the FIRST _on_window_ready() after entry, and
|
||||
## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted at
|
||||
## each call site, not here, since the three callers gate slightly differently).
|
||||
## zoom/offset, then re-clamps the offset to the pole wall. Called from
|
||||
## enter(), the FIRST _on_window_ready() after entry, and
|
||||
## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted).
|
||||
func _fit_and_center() -> void:
|
||||
var viewport: Vector2 = get_rect().size
|
||||
if viewport == Vector2.ZERO:
|
||||
@@ -257,11 +363,8 @@ func _fit_and_center() -> void:
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts
|
||||
## — a no-radius body (tiny test body) has no periodicity/pole concept at the
|
||||
## DistrictPos level (matching canonicalize_district_center()'s own no-radius
|
||||
## identity disposition), so the wall is a no-op there (rows_half=0, and
|
||||
## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall").
|
||||
## T-1142: needs the body's rows_half — a no-radius body has no pole
|
||||
## concept, so the wall is a no-op there (rows_half=0).
|
||||
func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -277,12 +380,48 @@ func leave() -> void:
|
||||
|
||||
|
||||
## Named get_district_window(), NOT get_window() — Node already defines
|
||||
## get_window() -> Window (the containing OS window); shadowing it with an
|
||||
## incompatible return type is a Godot parse error (confirmed the hard way).
|
||||
## get_window() -> Window; shadowing it with an incompatible type errors.
|
||||
func get_district_window() -> Variant:
|
||||
return _window
|
||||
|
||||
|
||||
## T-1153: true while showing the orbital rest state as a multi-window
|
||||
## mosaic instead of the single held composite — AtlasWindowOverlay reads
|
||||
## this to pick a draw path.
|
||||
func is_tile_mode() -> bool:
|
||||
return _tile_mode
|
||||
|
||||
|
||||
## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw
|
||||
## path — only meaningful while is_tile_mode() is true.
|
||||
func get_tile_set() -> Variant:
|
||||
return _tile_set
|
||||
|
||||
|
||||
## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's
|
||||
## mosaic draw path converts tile centers into canvas-local space via these
|
||||
## (see AtlasWindowGeometry.district_to_canvas_local()'s own doc).
|
||||
func get_held_center() -> Vector2i:
|
||||
return _held_center
|
||||
|
||||
|
||||
func get_held_n() -> int:
|
||||
return _held_n
|
||||
|
||||
|
||||
## Currently-HELD rung tag — legend reads this via spacing_for_rung(),
|
||||
## mirroring _refresh_screen_header()'s own use of the field.
|
||||
func get_held_granularity_v2() -> String:
|
||||
return _held_granularity_v2
|
||||
|
||||
|
||||
## Live round 5: current body's radius — mosaic draw needs `cols` for
|
||||
## nearest_wrap_image()'s wrap resolution. Mirrors the
|
||||
## `_body.get("body_radius_km", 0.0)` pattern used throughout this file.
|
||||
func get_body_radius_km() -> float:
|
||||
return float(_body.get("body_radius_km", 0.0))
|
||||
|
||||
|
||||
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
|
||||
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
|
||||
## single source of geometry truth (same "viewer owns the transform, overlay
|
||||
@@ -317,34 +456,68 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
_window_request.on_response(response)
|
||||
|
||||
|
||||
## T-1153: progressive refinement — the ONE place a new rung's window gets
|
||||
## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite
|
||||
## stays drawn until this call — no blank frame, no mode flip (§6) —
|
||||
## because `_window` only ever gets REPLACED, never nulled, once adopted
|
||||
## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap).
|
||||
func _on_window_ready(window: Dictionary) -> void:
|
||||
# Only adopt the window if it still matches what THIS viewer is currently
|
||||
# showing — AtlasWindowRequest already filtered by its own last-asked
|
||||
# (center, n) via the echo (§2), but a cache-hit path can fire
|
||||
# synchronously from enter() before _held_center is what the signal
|
||||
# handler expects in a re-entrant call; comparing again here is cheap and
|
||||
# removes any ordering assumption between enter()'s two calls.
|
||||
# (center, n, granularity_v2) via the echo (§2/T-1150/T-1152), but a
|
||||
# cache-hit path can fire synchronously from enter() before _held_center
|
||||
# is what the signal handler expects in a re-entrant call; comparing
|
||||
# again here is cheap and removes any ordering assumption between
|
||||
# enter()'s two calls. granularity_v2 (T-1153) is compared too — a
|
||||
# district-rung response answering a request that's SINCE moved on to a
|
||||
# region-rung request (rapid wheel-zoom) must not be adopted just because
|
||||
# center/n happen to still match.
|
||||
var w_center := _vec_from_center(window.get("center", [0, 0]))
|
||||
var w_n := int(window.get("n", 0))
|
||||
if w_center != _held_center or w_n != _held_n:
|
||||
var w_granularity_v2 := str(
|
||||
window.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT)
|
||||
)
|
||||
if (
|
||||
w_center != _held_center
|
||||
or w_n != _held_n
|
||||
or w_granularity_v2 != _window_request.get_granularity_v2()
|
||||
):
|
||||
return
|
||||
_window = window
|
||||
_held_granularity_v2 = w_granularity_v2
|
||||
# T-1142: re-fit on the FIRST composite arrival only (the entry-time fit
|
||||
# may have used a viewport size the layout hadn't settled into yet — this
|
||||
# corrects it once) — never on a later pan-triggered arrival, and never
|
||||
# once the user has manually zoomed/panned (same _user_adjusted guard
|
||||
# enter()/NOTIFICATION_RESIZED use).
|
||||
# enter()/NOTIFICATION_RESIZED use). A later rung-swap arrival is
|
||||
# EXACTLY a "later pan/zoom-triggered arrival" in this sense — it must
|
||||
# never re-fit either, or a wheel-zoom-triggered rung swap would yank the
|
||||
# player's view back to a fitted framing mid-gesture.
|
||||
if _awaiting_first_window and not _user_adjusted:
|
||||
_fit_and_center()
|
||||
_awaiting_first_window = false
|
||||
_refresh_screen_header()
|
||||
_legend_panel.refresh()
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-1153 (design doc §4 "progressive... with visible refinement as tiles
|
||||
## complete"): a SINGLE tile's window arrived — redraw so the overlay's
|
||||
## mosaic loop picks it up. No acceptance/staleness logic needed here (each
|
||||
## tile's OWN AtlasWindowRequest already filtered before this signal fired).
|
||||
func _on_tile_ready(_index: int) -> void:
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real, zoom is client-side
|
||||
# only and NEVER triggers a re-request per §5)
|
||||
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
|
||||
# amendment): the held composite is always drawn client-side-zoomed with NO
|
||||
# re-request, but crossing a rung's spacing threshold fires a NEW request at
|
||||
# the new granularity in the background (progressive refinement — see
|
||||
# _maybe_reselect_rung()'s own doc) while the OLD composite stays on screen.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -355,20 +528,166 @@ func _apply_transform() -> void:
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays
|
||||
## fixed on screen. Unclamped across rungs (only the wide MIN_ZOOM/MAX_ZOOM
|
||||
## safety clamp applies); after applying, checks the top rest state.
|
||||
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
|
||||
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
||||
# Live round 6: once settled at the canonical frame, a continued zoom-OUT
|
||||
# tick must not drift `_view_zoom` below fit (see the reset's own doc for
|
||||
# why that caused a request storm). Clamping the ZOOM here — not the
|
||||
# reset guard — keeps the reset edge-triggered. Zoom-IN is never clamped.
|
||||
if new_zoom < _view_zoom and _is_at_canonical_frame():
|
||||
var fit_zoom: float = _canonical_fit_zoom()
|
||||
new_zoom = maxf(new_zoom, fit_zoom)
|
||||
if is_equal_approx(new_zoom, _view_zoom):
|
||||
return
|
||||
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
|
||||
_view_zoom = new_zoom
|
||||
_view_offset = mouse_pos - local_before * _view_zoom
|
||||
_apply_transform()
|
||||
if _maybe_reset_to_canonical_frame():
|
||||
return # the reset already re-fit + re-requested at the top rung
|
||||
_maybe_reselect_rung()
|
||||
|
||||
|
||||
## Programmatic view control (T-1120 capture-API parity — must survive on
|
||||
## every viewer this app exposes, per the ticket's explicit note, even one
|
||||
## that never got user pan/zoom to begin with on the OTHER seam this ticket
|
||||
## removes it from).
|
||||
## The world extent (metres) currently displayed across the LARGER viewport
|
||||
## dimension — the `E` half of the §5 rung-selection rule. Thin wrapper over
|
||||
## AtlasWindowGeometry.world_extent_m() (a pure function of `_view_zoom`).
|
||||
func _current_world_extent_m() -> float:
|
||||
return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size)
|
||||
|
||||
|
||||
## §5 rung-selection rule + progressive refinement (T-1153): after a zoom
|
||||
## step, recompute the legal rung for the NOW-displayed world extent. If it
|
||||
## differs from what's HELD, request the new granularity centered on the
|
||||
## CURRENT screen-center (same formula as _maybe_refloat_window()).
|
||||
##
|
||||
## **C1 clamp-mirror, a THIRD layer up:** `_held_n` MUST be re-clamped via
|
||||
## `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale large
|
||||
## `_held_n` desyncs `_on_window_ready()`'s staleness check.
|
||||
##
|
||||
## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` —
|
||||
## the OLD composite keeps drawing until _on_window_ready() adopts the new
|
||||
## one (§6 "no mode flip"). Live round 5: this lag is what made
|
||||
## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire.
|
||||
func _maybe_reselect_rung() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var world_extent_m: float = _current_world_extent_m()
|
||||
var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y)
|
||||
var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px)
|
||||
|
||||
# T-1153, live round 3: tile mode is TOP-of-the-ladder only. Staying at
|
||||
# Region means staying tiled; crossing OUT of Region falls through to
|
||||
# the single-window path below, flipping `_tile_mode` off.
|
||||
var leaving_tile_mode := false
|
||||
if _tile_mode:
|
||||
if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION:
|
||||
return
|
||||
_tile_mode = false
|
||||
leaving_tile_mode = true
|
||||
|
||||
# `leaving_tile_mode` FORCES the request through even if
|
||||
# `_window_request`'s own STALE granularity_v2 (untouched while tiled)
|
||||
# happens to already equal `target_rung` by coincidence.
|
||||
if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2():
|
||||
return # already requesting (or holding) the rung this extent calls for
|
||||
var new_center: Vector2i = _screen_center_district()
|
||||
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung)
|
||||
_held_center = new_center
|
||||
_held_n = clamped_n
|
||||
# Live round 4: `_view_offset` must be recomputed the instant `held_n`
|
||||
# changes — see recompute_offset_for_held_n_change()'s own doc for why.
|
||||
# Anchors on the SAME screen center just used above, preserving §6 "no
|
||||
# layout jump" across the crossing.
|
||||
_view_offset = AtlasWindowGeometry.recompute_offset_for_held_n_change(
|
||||
size * 0.5, _view_zoom, clamped_n, CELL_PIXEL_SIZE
|
||||
)
|
||||
_apply_transform()
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung
|
||||
)
|
||||
|
||||
|
||||
## The DistrictPos the current screen center maps to, in RAW absolute
|
||||
## district space. Thin wrapper over screen_center_to_district() so
|
||||
## pan-edge/rung-reselect refetch share one formula.
|
||||
func _screen_center_district() -> Vector2i:
|
||||
var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km)
|
||||
|
||||
|
||||
## Live round 6: whether the body needs mosaic tiling — mirrors
|
||||
## enter_orbital()'s own dispatch condition so entry/predicate/guard agree.
|
||||
func _canonical_tile_mode(radius_km: float) -> bool:
|
||||
return AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1
|
||||
|
||||
|
||||
## Live round 6: the fit zoom enter_orbital() lands on for the CURRENT
|
||||
## body/viewport — read by both the canonical predicate and _zoom_at()'s floor.
|
||||
func _canonical_fit_zoom() -> float:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
return _view_zoom # no-radius body — no canonical frame concept, floor is a no-op
|
||||
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
|
||||
var canonical_n: int = int(extent["cols"])
|
||||
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
|
||||
get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
|
||||
)
|
||||
return float(fit["zoom"])
|
||||
|
||||
|
||||
## Live round 6: true when the CURRENT view EXACTLY matches the canonical
|
||||
## frame — center/mode/granularity plus `_view_zoom` at fit (float epsilon).
|
||||
func _is_at_canonical_frame() -> bool:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
return false
|
||||
return (
|
||||
_held_center == Vector2i.ZERO
|
||||
and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION
|
||||
and _tile_mode == _canonical_tile_mode(radius_km)
|
||||
and is_equal_approx(_view_zoom, _canonical_fit_zoom())
|
||||
)
|
||||
|
||||
|
||||
## Jeroen's HARD condition: "a full zoom-out resets to the original
|
||||
## canonical planetary frame and location." EDGE-triggered (live round 6):
|
||||
## fires only on the transition INTO fully-zoomed-out from non-canonical.
|
||||
##
|
||||
## **Live round 5 fix:** the old guard checked only
|
||||
## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on
|
||||
## response adoption). A TILING body's granularity stays stale "Region"
|
||||
## after zooming IN leaves tile mode, misreading "already canonical" and
|
||||
## never resetting. Fixed by also requiring `is_tile_mode()` to match.
|
||||
##
|
||||
## **Live round 6 fix (round 5's SECOND fix overshot into a storm):** a
|
||||
## per-tick zoom-equality check on THIS guard made it LEVEL-triggered —
|
||||
## continued zoom-out kept nudging `_view_zoom` below fit, so the guard
|
||||
## read "not already there" every tick and `enter_orbital()` fired
|
||||
## repeatedly: tile set torn down/recreated each time, orphaning in-flight
|
||||
## responses (nothing held → black), flooding the server (889/897 wire
|
||||
## responses in one zoom-out phase). Fixed by moving the zoom-drift concern
|
||||
## to `_zoom_at()`'s own zoom floor instead — this guard's
|
||||
## mode/center/granularity check alone stays edge-triggered.
|
||||
func _maybe_reset_to_canonical_frame() -> bool:
|
||||
var radius_km: float = float(_body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
return false # no-radius body — no canonical frame concept (matches enter_orbital()'s own guard)
|
||||
var world_extent_m: float = _current_world_extent_m()
|
||||
if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km):
|
||||
return false
|
||||
if _is_at_canonical_frame():
|
||||
return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture
|
||||
enter_orbital(_body, _system)
|
||||
return true
|
||||
|
||||
|
||||
## Programmatic view control (T-1120 capture-API parity).
|
||||
func get_view_zoom() -> float:
|
||||
return _view_zoom
|
||||
|
||||
@@ -389,38 +708,24 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
|
||||
## tick every frame the player is actively panning), check whether the
|
||||
## After a pan delta (T-1145: WASD/edge-scroll), check whether the
|
||||
## screen-center now maps to a DistrictPos outside the held window's extent
|
||||
## — if so, float a NEW window centered on that point (§5 "windows float on
|
||||
## the pan center... not grid-snapped") via the debounced request path.
|
||||
## — if so, float a NEW window centered on that point via the debounced path.
|
||||
##
|
||||
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
|
||||
## absolute district space (un-wrapped, un-clamped) — that is the correct
|
||||
## space for "has the pan carried the view past the held window's edge",
|
||||
## since the held window's own local bounds are relative to _held_center as
|
||||
## it was BEFORE this pan. Only the FINAL new_center that becomes the next
|
||||
## _held_center / the next request is canonicalized (wrap column, clamp
|
||||
## row) — matching the server's own normalize_window_center() and keeping
|
||||
## the client's echo-comparison and cache key on the same canonical form the
|
||||
## server uses (see canonicalize_district_center()'s doc for why this must
|
||||
## match bit-for-bit). A pan that straddles the antimeridian therefore still
|
||||
## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or
|
||||
## district_cols+5, the edge-crossing math treats that as a normal delta from
|
||||
## the old center, and only the resulting new_center gets wrapped into range
|
||||
## before it's requested/cached.
|
||||
## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute
|
||||
## district space — only the FINAL new_center is canonicalized, matching the
|
||||
## server's normalize_window_center().
|
||||
func _maybe_refloat_window() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
var screen_center: Vector2 = size * 0.5
|
||||
var canvas_pt: Vector2 = (screen_center - _view_offset) / _view_zoom
|
||||
var cell: Vector2 = canvas_pt / CELL_PIXEL_SIZE
|
||||
# cell is in [0, _held_n) local window space when centered — half-window
|
||||
# offset from _held_center converts back to absolute district space.
|
||||
var half: float = float(_held_n) / 2.0
|
||||
var abs_col: float = float(_held_center.x) - half + cell.x
|
||||
var abs_row: float = float(_held_center.y) - half + cell.y
|
||||
var raw_new_center := Vector2i(roundi(abs_col), roundi(abs_row))
|
||||
if _tile_mode:
|
||||
# T-1153: the tile set already covers the WHOLE body — no "edge" to
|
||||
# cross while tiled. _apply_pan_delta() still moves _view_offset;
|
||||
# this only skips the single-window re-float below.
|
||||
return
|
||||
var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district(
|
||||
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
|
||||
)
|
||||
if raw_new_center == _held_center:
|
||||
return
|
||||
# Edge-crossing check: only re-request if the screen-center point has
|
||||
@@ -429,8 +734,9 @@ func _maybe_refloat_window() -> void:
|
||||
# ticked over by one cell near a boundary) must not spam a request every
|
||||
# frame. §4: "re-requests only when a pan carries the view past the held
|
||||
# window's edge."
|
||||
var local_col: float = abs_col - float(_held_center.x) + half
|
||||
var local_row: float = abs_row - float(_held_center.y) + half
|
||||
var half: float = float(_held_n) / 2.0
|
||||
var local_col: float = float(raw_new_center.x - _held_center.x) + half
|
||||
var local_row: float = float(raw_new_center.y - _held_center.y) + half
|
||||
var inside: bool = (
|
||||
local_col >= 0.0
|
||||
and local_col < float(_held_n)
|
||||
@@ -444,7 +750,13 @@ func _maybe_refloat_window() -> void:
|
||||
raw_new_center, radius_km
|
||||
)
|
||||
_held_center = new_center
|
||||
_window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n)
|
||||
# T-1153: pass the CURRENTLY HELD rung — panning must re-request at the
|
||||
# SAME granularity it's already showing, never silently reset to the
|
||||
# request object's District default (that default exists for callers with
|
||||
# no rung concept of their own; this viewer always has one).
|
||||
_window_request.request_debounced(
|
||||
_dict_str(_body, "body_id", ""), new_center, _held_n, _held_granularity_v2
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -454,25 +766,45 @@ func _maybe_refloat_window() -> void:
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _tile_mode:
|
||||
# T-1153: single-window border-fade/pending-wash don't apply to a
|
||||
# mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles;
|
||||
# an unarrived one is an honest gap over COLOR_BG, no separate fade.
|
||||
return
|
||||
if _window == null:
|
||||
# §5 "what renders during the wait": a border-fade to the underlying
|
||||
# whole-body context rather than black/a spinner. This viewer has no
|
||||
# resident whole-body texture of its own (that lives on AtlasViewer,
|
||||
# which this screen has navigated away from) — the honest available
|
||||
# resident whole-body texture of its own — the honest available
|
||||
# substitute is a dim fade wash over the held composite's last-known
|
||||
# extent, reusing _gen_pending_indicator (via the request object's own
|
||||
# is_pending()) for the "still working" cue rather than new dressing.
|
||||
# extent, reusing the request object's own is_pending() for the
|
||||
# "still working" cue rather than new dressing.
|
||||
_draw_border_fade()
|
||||
elif _window_request and _window_request.is_pending():
|
||||
# T-1153/R6: the border-fade's REFERENT repointed — a rung-crossing
|
||||
# zoom (progressive refinement) leaves `_window` non-null (the OLD
|
||||
# composite is still the thing on screen, drawn by AtlasWindowOverlay
|
||||
# as always) while a NEW rung's request is in flight underneath it.
|
||||
# R6's ruling: "the mechanism survives; its target must be repointed
|
||||
# to 'the previous derived composite at this position'" — exactly
|
||||
# this case. A lighter pending wash (not the full opaque fade the
|
||||
# no-composite-at-all case uses, since there IS real data showing
|
||||
# through here, not emptiness) signals "sharper detail incoming"
|
||||
# without implying the current view is stale or wrong.
|
||||
_draw_pending_refinement_wash()
|
||||
|
||||
|
||||
func _draw_border_fade() -> void:
|
||||
if not _window_request or not _window_request.is_pending():
|
||||
return
|
||||
var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom
|
||||
var top_left: Vector2 = _view_offset
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_BORDER_FADE)
|
||||
|
||||
|
||||
func _draw_pending_refinement_wash() -> void:
|
||||
var extent: float = float(_held_n) * CELL_PIXEL_SIZE * _view_zoom
|
||||
var top_left: Vector2 = _view_offset
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH)
|
||||
|
||||
|
||||
func _build_screen_header() -> void:
|
||||
_screen_header = ImplantHeader.new()
|
||||
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
|
||||
@@ -483,32 +815,26 @@ func _build_screen_header() -> void:
|
||||
_screen_header.apply_implant_theme(_implant_theme)
|
||||
|
||||
|
||||
## D-169/D-170 implant chrome (§5): location label (body name + coordinate,
|
||||
## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g.
|
||||
## "4.1 x 4.1 km . 2.0 km/cell".
|
||||
## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure
|
||||
## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for
|
||||
## file-length; the "continuous metres-per-pixel readout, never a discrete
|
||||
## mode label" rationale lives on that function's own doc now).
|
||||
func _refresh_screen_header() -> void:
|
||||
if _screen_header == null:
|
||||
return
|
||||
var location_label: String = _location_label()
|
||||
var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0
|
||||
var extent_line: String = "%.1f x %.1f km · %.1f km/cell" % [
|
||||
extent_km, extent_km, DISTRICT_M / 1000.0
|
||||
]
|
||||
var title: String = "REGIONAL — %s" % location_label.to_upper()
|
||||
_screen_header.set_content(title, extent_line)
|
||||
var content: Dictionary = AtlasWindowGeometry.screen_header_content(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")),
|
||||
_held_center, _held_n, _held_granularity_v2, DISTRICT_M
|
||||
)
|
||||
_screen_header.set_content(content["title"], content["subtitle"])
|
||||
|
||||
|
||||
## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141
|
||||
## forward — the body's proper name was already sitting unused on _body,
|
||||
## passed through the whole descend chain since T-1138, but this header never
|
||||
## read it, showing bare "district (col, row)" with no indication of WHICH
|
||||
## body the player is looking at. T-1141 keeps only the harder half: nearest-
|
||||
## settlement proximity join (the window carries no settlement data of its
|
||||
## own — that lives on the planetary gen_l3_settlements overlay, a different
|
||||
## screen/dataset — a real follow-up, not a silently-guessed one).
|
||||
## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved
|
||||
## there for file-length) — kept as a method since it's directly tested.
|
||||
func _location_label() -> String:
|
||||
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
|
||||
return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y]
|
||||
return AtlasWindowGeometry.location_label(
|
||||
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -516,27 +842,18 @@ func _location_label() -> String:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## No city panel / sidebar in this mode (yet) — the window carries no
|
||||
## settlement join of its own (see _location_label's doc), so there is
|
||||
## nothing to hit-test against and this always reads false. Wired into
|
||||
## _gui_input exactly where AtlasViewer's own _is_over_ui is (same guard
|
||||
## shape) so a future sidebar addition only needs to change THIS function's
|
||||
## body, not every call site.
|
||||
## No city panel / sidebar in this mode (yet) — nothing to hit-test against,
|
||||
## so this always reads false. Wired into _gui_input exactly where
|
||||
## AtlasViewer's own _is_over_ui is, so a future sidebar addition only
|
||||
## needs to change THIS function's body.
|
||||
func _is_over_ui(_pos: Vector2) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
|
||||
## semantics with map objects; clicks are reserved for future map objects,
|
||||
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
|
||||
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
|
||||
## it has no InputEvent of its own to read a live position from). WASD/arrow
|
||||
## panning does NOT go through _gui_input at all — it is a HELD-key,
|
||||
## continuous, frame-rate-independent pan polled every frame in _process()
|
||||
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
|
||||
## since WASD has no project-level Input Map action of its own in this
|
||||
## screen's remit — see _process()'s own doc for why raw physical-keycode
|
||||
## polling is deliberate here, not a new InputMap action).
|
||||
## T-1145 item 2: LMB-drag panning is GONE (drag broke click semantics with
|
||||
## map objects). What remains: wheel zoom and tracking mouse position for
|
||||
## edge-scroll. WASD/arrow panning does NOT go through _gui_input — it's a
|
||||
## HELD-key pan polled every frame in _process().
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.is_echo():
|
||||
_handle_key(event as InputEventKey)
|
||||
@@ -565,19 +882,9 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
back_pressed.emit()
|
||||
|
||||
|
||||
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
|
||||
## here (not _gui_input) because both are HELD-state effects (keys held down,
|
||||
## cursor lingering near an edge), not discrete input events — _process()
|
||||
## polls held state every frame and hands the resulting direction + this
|
||||
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
|
||||
## test drives _apply_pan_delta(direction, delta) directly with a
|
||||
## deterministic direction/delta instead of needing to fake Godot's global
|
||||
## Input singleton reporting a key held, which is what testing THIS
|
||||
## function's own Input.is_key_pressed() polling would require). Skips
|
||||
## entirely while this Control is hidden (the screen is not the active
|
||||
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
|
||||
## no phantom panning if some other code path leaves this node in the tree
|
||||
## but not shown.
|
||||
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both HELD-state
|
||||
## effects polled every frame, handed to _apply_pan_delta() (split out for
|
||||
## testability). Skips while hidden (screen not the active nav-stack entry).
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -589,15 +896,11 @@ func _process(delta: float) -> void:
|
||||
_apply_pan_delta(direction, delta)
|
||||
|
||||
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
|
||||
## normalized) direction and this frame's delta — frame-rate independent
|
||||
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
|
||||
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
|
||||
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
|
||||
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
|
||||
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
|
||||
## (§4) drag used to. Split from _process() specifically so a test can call
|
||||
## this directly with a synthetic direction/delta — see _process()'s own doc.
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and
|
||||
## this frame's delta — frame-rate independent, zoom-scaled
|
||||
## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets
|
||||
## _user_adjusted and triggers the pan-edge refetch (§4). Split from
|
||||
## _process() so a test can call it directly with a synthetic direction/delta.
|
||||
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
|
||||
_user_adjusted = true
|
||||
@@ -608,84 +911,25 @@ func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action): W/S/A/D on this project's global InputMap are
|
||||
## already bound to move_north/move_south/move_east/move_west (gameplay
|
||||
## movement, D-054 mouse-relative facing) — reusing those actions here would
|
||||
## make holding W simultaneously pan this map AND queue a gameplay move
|
||||
## command server-side the moment this implant screen closes back to
|
||||
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
|
||||
## with no implant-occlusion guard — confirmed by reading input_mapper.gd
|
||||
## directly, a genuine pre-existing gap outside this ticket's scope, not
|
||||
## introduced here). Reading the raw physical keycode instead of the shared
|
||||
## action name means this screen's WASD use is fully independent of
|
||||
## whatever the gameplay action happens to be bound to — same key, two
|
||||
## UNRELATED consumers, neither needs to know about the other. Arrow keys
|
||||
## have no InputMap action bound at all (confirmed by grep across
|
||||
## project.godot's [input] section), so they're conflict-free either way.
|
||||
## Returns a raw (non-normalized) direction — the caller normalizes once
|
||||
## after adding the edge-scroll contribution, so N+E doesn't move faster
|
||||
## than N alone.
|
||||
## (not an InputMap action) — see AtlasWindowGeometry.held_pan_direction()'s
|
||||
## doc for the full W/S/A/D-vs-gameplay-movement rationale (moved there
|
||||
## T-1153 for file-length/testability, unchanged behavior).
|
||||
func _held_pan_direction() -> Vector2:
|
||||
var direction := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
|
||||
direction.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
|
||||
direction.y += 1.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
direction.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
direction.x += 1.0
|
||||
return direction
|
||||
return AtlasWindowGeometry.held_pan_direction()
|
||||
|
||||
|
||||
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
|
||||
## (_is_over_ui() — the SAME helper the click-era _gui_input guard used, per
|
||||
## the ticket's explicit "reuse _is_over_ui" instruction) and (b) while the
|
||||
## application window itself lacks OS focus (_app_has_focus — otherwise a
|
||||
## background window with the cursor left resting near its edge from a
|
||||
## previous session would silently pan while the player is doing something
|
||||
## else entirely; "if detectable" per the ticket, and Godot's
|
||||
## NOTIFICATION_APPLICATION_FOCUS_OUT/IN make it directly detectable, see
|
||||
## _notification()).
|
||||
## T-1145 item 2: edge-scroll suppression — see
|
||||
## AtlasWindowGeometry.is_cursor_edge_scrolling()'s doc (moved there T-1153).
|
||||
func _is_cursor_edge_scrolling() -> bool:
|
||||
if not _app_has_focus:
|
||||
return false
|
||||
if _is_over_ui(_last_mouse_pos):
|
||||
return false
|
||||
var sz: Vector2 = size
|
||||
if sz.x <= 0.0 or sz.y <= 0.0:
|
||||
return false
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
return (
|
||||
pos.x >= 0.0
|
||||
and pos.y >= 0.0
|
||||
and pos.x <= sz.x
|
||||
and pos.y <= sz.y
|
||||
and (
|
||||
pos.x < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y < EDGE_SCROLL_MARGIN_PX
|
||||
or pos.x > sz.x - EDGE_SCROLL_MARGIN_PX
|
||||
or pos.y > sz.y - EDGE_SCROLL_MARGIN_PX
|
||||
)
|
||||
return AtlasWindowGeometry.is_cursor_edge_scrolling(
|
||||
_app_has_focus, _is_over_ui(_last_mouse_pos), size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX
|
||||
)
|
||||
|
||||
|
||||
## Direction toward whichever edge(s) the cursor is near — same shape as
|
||||
## _held_pan_direction() (a raw, un-normalized Vector2 the caller combines
|
||||
## and normalizes once).
|
||||
## Direction toward whichever edge(s) the cursor is near — see
|
||||
## AtlasWindowGeometry.edge_scroll_direction()'s doc (moved there T-1153).
|
||||
func _edge_scroll_direction() -> Vector2:
|
||||
var sz: Vector2 = size
|
||||
var pos: Vector2 = _last_mouse_pos
|
||||
var direction := Vector2.ZERO
|
||||
if pos.x < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x -= 1.0
|
||||
elif pos.x > sz.x - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.x += 1.0
|
||||
if pos.y < EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y -= 1.0
|
||||
elif pos.y > sz.y - EDGE_SCROLL_MARGIN_PX:
|
||||
direction.y += 1.0
|
||||
return direction
|
||||
return AtlasWindowGeometry.edge_scroll_direction(size, _last_mouse_pos, EDGE_SCROLL_MARGIN_PX)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -727,12 +971,9 @@ func _notification(what: int) -> void:
|
||||
_position_overlay_bar()
|
||||
if _legend_panel:
|
||||
_legend_panel.reposition()
|
||||
# T-1142: re-fit on resize too, same _user_adjusted guard as the other
|
||||
# two auto-fit events (enter, first window arrival) — never fights a
|
||||
# manually-adjusted view. _canvas guard matches _overlay_bar/
|
||||
# _legend_panel above: NOTIFICATION_RESIZED can fire mid-_ready()
|
||||
# (anchor_right/anchor_bottom assignment triggers it) BEFORE _canvas
|
||||
# is constructed — confirmed the hard way (gdUnit add_child() crash).
|
||||
# T-1142: re-fit on resize (_user_adjusted guard, as other auto-fit
|
||||
# events). _canvas guard: NOTIFICATION_RESIZED can fire mid-_ready()
|
||||
# before _canvas exists (gdUnit add_child() crash, confirmed).
|
||||
if _canvas and not _user_adjusted:
|
||||
_fit_and_center()
|
||||
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
class_name DistrictScreen
|
||||
extends Control
|
||||
## Regional district-window viewer screen for AtlasApp (T-1138, D-226 T-1124
|
||||
## amendment). Thin wrapper around AtlasWindowViewer, mirroring
|
||||
## RegionalScreen's own shape exactly — enter/leave are the nav interface.
|
||||
##
|
||||
## Entered via a click-through from AtlasViewer (the "regional" screen),
|
||||
## carrying the derived DistrictPos the player clicked (§5's entry-revision:
|
||||
## "pan center read as click point"). Esc goes back to "regional" (the
|
||||
## planetary heightmap for the same body) — a nav.pop(), not a fresh push, so
|
||||
## the planetary view's own pan/zoom-removed FIXED state is exactly where the
|
||||
## player left it.
|
||||
|
||||
signal back_requested
|
||||
|
||||
var _viewer: AtlasWindowViewer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_viewer = AtlasWindowViewer.new()
|
||||
_viewer.name = "AtlasWindowViewer"
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
var center: Vector2i = payload.get("district_center", Vector2i.ZERO)
|
||||
_viewer.enter(body, system, center)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
@@ -1,30 +1,48 @@
|
||||
class_name RegionalScreen
|
||||
extends Control
|
||||
## Regional heightmap viewer screen for AtlasApp (#844, D-191).
|
||||
## Thin wrapper around AtlasViewer; enter/leave are the nav interface.
|
||||
## Regional zoom-ladder screen for AtlasApp (#844, D-191; superseded T-1153 —
|
||||
## D-226 T-1143-rulings amendment). Thin wrapper around AtlasWindowViewer,
|
||||
## entering at the CANONICAL ORBITAL FRAME (Region rung) via enter_orbital()
|
||||
## instead of AtlasViewer's retired heightmap-texture show_body() path —
|
||||
## enter/leave are still the nav interface, unchanged shape.
|
||||
##
|
||||
## T-1152 client half: this is the ONE screen for the whole ladder now —
|
||||
## there is no separate "district" nav hop for the windowed drill-down
|
||||
## (D-013 "the zoom gesture owns spatial descent" restored for this seam
|
||||
## means descent is a CONTINUOUS in-screen zoom, not a nav-stack push). Esc
|
||||
## from anywhere in the ladder is a single nav.pop() back to whatever pushed
|
||||
## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged
|
||||
## from before this ticket (it already routed Esc through nav.pop() for any
|
||||
## screen that isn't "reach"/"system"-with-a-panel-open).
|
||||
##
|
||||
## `district_descend_requested`/`economics_link_requested` signals retire
|
||||
## with AtlasViewer's click-through (the reticle/hover-to-descend affordance
|
||||
## — Jeroen's ruling: retired as the SOLE entry, and no cheap click-target
|
||||
## exists on the orbital Region-rung view to wire a shortcut onto yet, unlike
|
||||
## a future settlement-marker click which WOULD have a natural landing
|
||||
## point — see AtlasWindowViewer's own doc on why enter() still exists as a
|
||||
## District-rung entry point for exactly that future wiring).
|
||||
## economics_link_requested is deferred with AtlasViewer's city-click sidebar
|
||||
## (see the batch report for the full list of what's deferred vs. carried).
|
||||
|
||||
signal back_requested
|
||||
signal economics_link_requested(system_id: String)
|
||||
signal district_descend_requested(district_center: Vector2i) # T-1138, forwarded from AtlasViewer
|
||||
|
||||
var _viewer: AtlasViewer = null
|
||||
var _viewer: AtlasWindowViewer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_viewer = AtlasViewer.new()
|
||||
_viewer.name = "AtlasViewer"
|
||||
_viewer = AtlasWindowViewer.new()
|
||||
_viewer.name = "AtlasWindowViewer"
|
||||
add_child(_viewer)
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
_viewer.economics_link_requested.connect(_on_viewer_economics_link)
|
||||
_viewer.district_descend_requested.connect(_on_viewer_district_descend)
|
||||
|
||||
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
_viewer.show_body(body, system)
|
||||
_viewer.enter_orbital(body, system)
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
@@ -33,11 +51,3 @@ func leave() -> void:
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func _on_viewer_economics_link(system_id: String) -> void:
|
||||
economics_link_requested.emit(system_id)
|
||||
|
||||
|
||||
func _on_viewer_district_descend(district_center: Vector2i) -> void:
|
||||
district_descend_requested.emit(district_center)
|
||||
|
||||
@@ -352,6 +352,32 @@ Key components:
|
||||
- **CauseChain** (production ECS component) — Tracks causal attribution for testable observation sequences (D-030).
|
||||
- **Deterministic replay** — Server simulation is deterministic given the same seed + input sequence. Replay logs enable regression testing (#201, critical).
|
||||
|
||||
### Real-rendering test exception: `test_atlas_window_overlay_draw_smoke.gd`
|
||||
|
||||
`tests/run-godot` hardcodes `--headless`, whose dummy driver produces no usable GPU
|
||||
texture output (`SubViewport.get_texture().get_image()` returns unusable data). One
|
||||
file needs real pixels — `client/tests/test_atlas_window_overlay_draw_smoke.gd` (T-1153
|
||||
live round 4) renders `AtlasWindowOverlay` into a `SubViewport` and asserts real terrain
|
||||
pixels were composited, closing a "did anything draw at all" gap that bit twice
|
||||
(a tile-mosaic coordinate bug and an unstored-texture GPU-lifetime bug, both invisible
|
||||
to cache-state-only assertions). It self-detects the dummy driver
|
||||
(`DisplayServer.get_name() == "headless"`) and **skips** under the standard suite —
|
||||
`tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports green-with-skips,
|
||||
never a false failure that would bounce the push gate. To exercise its real assertions:
|
||||
|
||||
```bash
|
||||
godot4 --display-driver x11 --rendering-driver opengl3 \
|
||||
-s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \
|
||||
-a res://tests/test_atlas_window_overlay_draw_smoke.gd
|
||||
```
|
||||
|
||||
Same underlying constraint as `tests/visual_capture.gd` (`tests/run-visual`), which is
|
||||
the project's other real-driver exception — currently broken on this branch by the
|
||||
retired `AtlasViewer` API (T-1157, capture-harness redesign). This file's scenarios are
|
||||
slated to migrate into that redesigned harness once T-1157 lands, folding it into
|
||||
`tests/visual.json` for consistency; until then it stays a standalone gdUnit file with
|
||||
its own skip guard.
|
||||
|
||||
## Planning store (pql)
|
||||
|
||||
Tickets and decisions live in **pql**, not in the old SQLite wrapper scripts. Decisions
|
||||
|
||||
@@ -108,6 +108,10 @@ Confirmed by direct code inspection (not aspirational): `value_noise`, `terrain_
|
||||
|
||||
**Split:** rungs govern *what classification granularity is available and legal to request* (a policy/cost question, §2/§10); canvas-resolution sampling governs *what density the client actually asks for*, tracking the viewport continuously. The rung-selection rule (§2) is the join: pick the coarsest legal rung whose spacing is ≤2× the current screen sample spacing.
|
||||
|
||||
> **Erratum (2026-07-22, T-1153 implementation — Stig; superseded same day by the round-3 model below):** the single-table-scan reading of the rule above is unsatisfiable for the District rung at any real viewport: with `CELL_PIXEL_SIZE = 16` and the server's `DISTRICT_WINDOW_MAX_N = 64` per-axis cap, the ≤2× visual-tolerance band and the window-coverage ceiling never overlap. An interim two-gate split (coverage ceiling → 2× tolerance) shipped briefly but left District structurally unreachable and Region held indefinitely during pure zoom (caught in the live eyeball rounds, never merged).
|
||||
>
|
||||
> **Final model (round 3, as merged):** `AtlasWindowGeometry.select_rung()` is a **unified per-rung coverage-ceiling walk** — `MAX_COVERAGE_M` maps each rung to the maximum world extent its wire-capped window can span (Quarter ≈ 32.8 km, District ≈ 131 km, Region ≈ 13,107 km); selection walks finest-first and returns the first rung whose ceiling covers the current extent. The 2× visual-tolerance clause is retired: coverage, not tolerance, is the selector, which restores District as a real rung (the 33–131 km extent band) and makes the ladder Quarter → District → Region → **tile mode** (extents beyond Region's single-window ceiling compose a whole-body mosaic of capped Region tiles — the ruling's progressive capped-density tiling; `compute_tile_grid()`, Lendel = 3×2). The one-tier-finer-than-a-pixel guarantee is preserved by construction at Quarter/District scales and bounded at Region scale by the wire cap — the felt-pacing knob is `CELL_PIXEL_SIZE`; widening a rung's band is a `WIRE_CAP_CELLS`/`MAX_N` budget change requiring re-measurement.
|
||||
|
||||
**Bounded key space, not unbounded.** A continuous field sampled at arbitrary density would give the cache an unbounded key space (client obstacle flagged in the grounding: the 24-entry exact-match LRU would thrash to ~0% hit rate). This is avoided because **requests snap to quantized rung tiers** (granularity ∈ {1, 4, …}) and integer district-aligned centers — only *display* is continuous; every *request* the wire actually carries is one of a small number of discrete shapes. **Gap flagged by critique:** as specified, `window_min_wl_m` is viewport-continuous (`E/C`) while the cache key/echo tuple is `(body, center, n, granularity)` — same key, different `min_wl`, would silently collide. Fix: **quantize `min_wl_m` to a small fixed set of bands per rung** (matching the rung's own octave bands) rather than passing a raw continuous value, and add the quantized band to both the echo and the cache key. This closes the gap without reopening the unbounded-key problem.
|
||||
|
||||
**Region/orbital granularity is unrepresentable in the current sketch.** `window_granularity: u32` with `spacing = DISTRICT_M / granularity` can express district (1) and quarter (4) but not region (coarser than district, i.e. granularity < 1). If the planetary rung's chosen carrier (§4) reuses this same field, the type needs to change to something that can express both directions (e.g. a signed log-scale or an explicit rung enum) — noted for the implementer, not resolved here.
|
||||
|
||||
@@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
**Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it.
|
||||
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row.
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed).
|
||||
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
|
||||
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
|
||||
- **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.
|
||||
|
||||
@@ -1569,6 +1569,128 @@ pub fn derive_at_metres(
|
||||
)
|
||||
}
|
||||
|
||||
/// The orbital-rung derivation (T-1152, zoom ladder design doc §2/§4): the
|
||||
/// coarse-granularity twin of [`derive_at_metres`] that skips [`invent_primitives`]
|
||||
/// entirely — **no coastline warp, no detail-scatter octave sum, no classification
|
||||
/// noise call of any kind**. Per the design doc's orbital row: "`region_baseline_at_district`
|
||||
/// only — bilinear blend of 4 region baselines, no `invent_primitives`, no
|
||||
/// classification [driver]." Orbital sample spacing (≥205 km, D-243's region rung
|
||||
/// and coarser) sits below `detail_scatter`'s own octave floor
|
||||
/// (`OCTAVE_WAVELENGTHS_M`'s coarsest entry is 32,768 m ≈ 32.8 km — an order of
|
||||
/// magnitude finer than a region), so the invented terrain has nothing left to
|
||||
/// contribute at this spacing; calling it would burn cycles synthesizing detail
|
||||
/// no orbital pixel can resolve. What DOES vary at orbital spacing is the
|
||||
/// **envelope** the heightmap itself carries (the `TerrainAnalysis` continental
|
||||
/// shape) and the **region climate baseline** — this function samples exactly
|
||||
/// those two, nothing else.
|
||||
///
|
||||
/// **Cost model (design doc R1 — measure first):** one `bilinear` (elevation),
|
||||
/// one `bilinear_bool` (ocean mask), one `region_baseline_at_district` call (its
|
||||
/// own cost is 4×`derive_region_baseline_c` on a cache miss, O(1) on a cache hit)
|
||||
/// — no octave sum, no coast-warp trig, no character/envelope computation. See
|
||||
/// `server/tests/zoom_ladder_bench.rs`'s `bench_derive_orbital_at_metres` for the
|
||||
/// measured per-cell figure this claim rests on.
|
||||
///
|
||||
/// Produces the SAME six-field tail every other rung produces (`morphology_zone`,
|
||||
/// `elev_q`, `temperature_c`, `moisture_q`, `vegetation_class`, `glaciation_grade`)
|
||||
/// by routing the bilinear-only primitives through the same
|
||||
/// [`build_district_profile`] classification tail every other rung uses — one
|
||||
/// classification pipeline, never a second orbital-only decision tree (D-227:
|
||||
/// classification thresholds don't get a coarse-rung variant any more than the
|
||||
/// quarter rung got its own "quarter mode" thresholds, design doc §6).
|
||||
///
|
||||
/// **R2 (stepped fields):** `moisture_q`/`temperature_c`/`morphology_zone`/etc.
|
||||
/// are exactly as stepped here as at every other rung — `region_baseline_at_district`
|
||||
/// floor-divides to the containing `DistrictPos` regardless of caller spacing (see
|
||||
/// [`derive_at_metres`]'s own doc on this), so this function does not make
|
||||
/// temperature MORE continuous at orbital scale; it inherits the same
|
||||
/// district-tier step the design doc documents as permanent, by construction.
|
||||
///
|
||||
/// **`slope_q` is fixed at 0`** — the bilinear-only envelope carries no
|
||||
/// per-cell slope signal at orbital spacing (`ta.slope_deg` is a district-scale
|
||||
/// proxy; sampling it here would imply a precision the coarse envelope doesn't
|
||||
/// have). `slope_q` only affects morphology gates 3–6 (FjordWall/CliffCoast/
|
||||
/// BraidedDelta/DuneStrand) and the invented-primitives `carve` term this
|
||||
/// function never runs — passing 0 means those gates fall through to their
|
||||
/// low-slope alternatives, which is the correct behavior for a coastline sampled
|
||||
/// at coarser-than-detail-scatter resolution (no invented ruggedness to report).
|
||||
pub fn derive_orbital_at_metres(
|
||||
seed: SeedChain,
|
||||
body_id: &str,
|
||||
body_params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
climate: &ClimateConstants,
|
||||
) -> DistrictProfile {
|
||||
// Same world-metres -> fractional heightmap pixel + latitude mapping
|
||||
// derive_at_metres uses — the envelope is the SAME TerrainAnalysis grid at
|
||||
// every rung, only the sampling density differs.
|
||||
let (px, py, _world_x_m, _world_y_m, lat_deg) = match body_params.body_radius_km {
|
||||
Some(r_km) if r_km > 0.0 => {
|
||||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||||
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
|
||||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5);
|
||||
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
|
||||
(px, py, wx, wy, -lat_frac * 180.0)
|
||||
}
|
||||
_ => {
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64);
|
||||
let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64);
|
||||
let lat_deg = if ta.h > 1 {
|
||||
90.0 - (py / (ta.h - 1) as f64) * 180.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(px, py, px * dm, py * dm, lat_deg)
|
||||
}
|
||||
};
|
||||
|
||||
let params = BodyParams {
|
||||
latitude_deg: lat_deg,
|
||||
..body_params.clone()
|
||||
};
|
||||
|
||||
// The envelope only — no coast-warp, no detail-scatter. This is exactly
|
||||
// `invent_primitives`' step-1 "driver tier" raw bilinear reads, promoted to
|
||||
// be the FINAL primitives instead of a one-step-stale input to invention.
|
||||
let elev_q =
|
||||
((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100);
|
||||
let ocean_fraction_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0)
|
||||
.round() as i32)
|
||||
.clamp(0, 100);
|
||||
// No invented ruggedness at orbital spacing (see the function doc's note
|
||||
// on slope_q) — the envelope carries no per-cell slope signal this coarse.
|
||||
let slope_q = 0;
|
||||
|
||||
let district_pos: DistrictPos = (
|
||||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||||
);
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
district_pos,
|
||||
¶ms,
|
||||
climate,
|
||||
seed,
|
||||
None, // no pre-built cache; derive on-the-fly, same posture as derive_at_metres
|
||||
);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
climate,
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
BasinDirection::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
|
||||
/// Columns wrap (equirectangular); rows clamp at the poles.
|
||||
fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
@@ -2066,6 +2188,153 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// derive_orbital_at_metres (T-1152, design doc §2/§4 orbital row)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Determinism (D-010/D-227): two independent orbital derives at the same
|
||||
/// position produce a bit-identical `DistrictProfile`, mirroring
|
||||
/// `derive_district_is_deterministic`'s pattern for the finer rungs.
|
||||
#[test]
|
||||
fn derive_orbital_at_metres_is_deterministic() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::REGION_M as f64;
|
||||
|
||||
let a = derive_orbital_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
3.0 * dm,
|
||||
2.0 * dm,
|
||||
&climate,
|
||||
);
|
||||
let b = derive_orbital_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
3.0 * dm,
|
||||
2.0 * dm,
|
||||
&climate,
|
||||
);
|
||||
assert_district_profiles_eq(&a, &b);
|
||||
}
|
||||
|
||||
/// The orbital path must NOT run `invent_primitives` — the design doc's
|
||||
/// central constraint (§2: "no invent_primitives at orbital wavelengths").
|
||||
/// Direct proof: `slope_q` is always exactly 0 (invention is the only
|
||||
/// source of nonzero slope_q at this call depth — see
|
||||
/// `derive_orbital_at_metres`'s doc on why slope_q is fixed), sampled
|
||||
/// across enough distinct positions that a nonzero value appearing even
|
||||
/// once would falsify the claim.
|
||||
#[test]
|
||||
fn derive_orbital_at_metres_never_invents_slope() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::REGION_M as f64;
|
||||
|
||||
for i in 0..25 {
|
||||
let wx = (i * 7) as f64 * dm * 0.37;
|
||||
let wy = (i * 11) as f64 * dm * 0.29;
|
||||
let prof =
|
||||
derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate);
|
||||
assert_eq!(
|
||||
prof.slope_q, 0,
|
||||
"orbital derive must never report invented slope (position {i})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The orbital derive's `elev_q`/`temperature_c` must come from the SAME
|
||||
/// envelope + region-baseline sources `derive_at_metres` reads — not an
|
||||
/// independent/divergent computation. At a position where the invented
|
||||
/// scatter happens to contribute exactly zero (impossible to guarantee by
|
||||
/// construction, so this test instead checks the WEAKER, always-true
|
||||
/// property: both paths' `elev_q` derive from the same underlying
|
||||
/// bilinear envelope, so they must be close — within the invented
|
||||
/// scatter's own bounded contribution range, not arbitrarily different).
|
||||
/// This guards against the orbital path silently reading a different
|
||||
/// terrain field entirely (a copy-paste bug this refactor is exactly the
|
||||
/// kind of change that could introduce).
|
||||
#[test]
|
||||
fn derive_orbital_at_metres_elevation_tracks_the_same_envelope() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
// Sample at a DISTRICT-aligned position (within the orbital function's
|
||||
// legal domain — it accepts any world position, this just makes the
|
||||
// district-mode comparison call meaningful) so both paths read the
|
||||
// exact same fractional heightmap pixel.
|
||||
let wx = 40.0 * dm;
|
||||
let wy = 20.0 * dm;
|
||||
let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate);
|
||||
let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0);
|
||||
|
||||
// The invented scatter is a bounded perturbation on top of the raw
|
||||
// envelope (detail_scatter's amplitude is capped well under 100 elev_q
|
||||
// points) — the two must be in the same ballpark, not exactly equal
|
||||
// (that would defeat the point of invention existing at all at the
|
||||
// finer rung) and not wildly different (that would mean the orbital
|
||||
// path is reading a different field).
|
||||
let elev_diff = (orbital.elev_q - full.elev_q).abs();
|
||||
assert!(
|
||||
elev_diff <= 50,
|
||||
"orbital elev_q ({}) and full-derive elev_q ({}) must come from the \
|
||||
same envelope, not diverge arbitrarily",
|
||||
orbital.elev_q,
|
||||
full.elev_q
|
||||
);
|
||||
}
|
||||
|
||||
/// Orbital-scale windows must still fill all six dense wire arrays the
|
||||
/// client's colorizer family reads (T-1152: "the orbital cells must fill
|
||||
/// the same six dense arrays the DistrictWindowLayer carries") — this is
|
||||
/// checked at the `DistrictProfile` level (the pre-packing source of
|
||||
/// those six fields): every field the packer reads
|
||||
/// (`morphology_zone`/`elev_q`/`temperature_c`/`moisture_q`/
|
||||
/// `vegetation_class`/`glaciation_grade`) must be populated the same way
|
||||
/// regardless of rung — this test asserts the orbital output is a
|
||||
/// legitimate `DistrictProfile`, not a partially-filled stand-in.
|
||||
#[test]
|
||||
fn derive_orbital_at_metres_populates_all_six_wire_fields() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::REGION_M as f64;
|
||||
|
||||
let prof = derive_orbital_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
5.0 * dm,
|
||||
3.0 * dm,
|
||||
&climate,
|
||||
);
|
||||
assert!((0..=100).contains(&prof.elev_q));
|
||||
assert!((0..=100).contains(&prof.moisture_q));
|
||||
// temperature_c is Some for a breathable-atmosphere body (earth_params).
|
||||
assert!(prof.temperature_c.is_some());
|
||||
// morphology_zone/vegetation_class/glaciation_grade are enums with no
|
||||
// "unset" state — successfully constructing the DistrictProfile at
|
||||
// all (no panic) is the actual assertion; the field reads below just
|
||||
// confirm they're reachable typed values, matching the discipline
|
||||
// `derive_district_is_deterministic` and neighbours already use.
|
||||
let _ = prof.morphology_zone;
|
||||
let _ = prof.vegetation_class;
|
||||
let _ = prof.glaciation_grade;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_district_profile_is_deterministic() {
|
||||
let hm = test_hm();
|
||||
|
||||
+100
-42
@@ -34,7 +34,9 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
||||
use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos};
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||
use crate::atlas::layer_proxy::{build_district_window_layer, DistrictWindowLayer};
|
||||
use crate::atlas::layer_proxy::{
|
||||
build_district_window_layer, DistrictWindowLayer, WindowGranularity,
|
||||
};
|
||||
use crate::atlas::shell::{fill_chunk, FilledChunk};
|
||||
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
|
||||
use crate::atlas::trait_catalog_reader::ExteriorCatalog;
|
||||
@@ -208,11 +210,18 @@ pub enum GenWorkItem {
|
||||
/// before this item is built — never trusted from the wire again here.
|
||||
center: DistrictPos,
|
||||
n: u32,
|
||||
/// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1)
|
||||
/// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via
|
||||
/// `resolve_window_granularity` by the caller.
|
||||
granularity: u32,
|
||||
/// Derivation granularity (T-1150, widened T-1152 to the full
|
||||
/// [`WindowGranularity`] vocabulary — `District`/`Quarter` (finer)
|
||||
/// plus `Region` (coarser, T-1152)). Already resolved via
|
||||
/// `resolve_window_granularity_v2` by the caller — this is a
|
||||
/// concrete rung, never a raw wire value.
|
||||
granularity: WindowGranularity,
|
||||
/// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff.
|
||||
/// Meaningless for `granularity: Region` (`derive_orbital_at_metres`
|
||||
/// never calls the octave-scatter path this cuts off) but still
|
||||
/// carried and echoed uniformly — see `derive_orbital_at_metres`'s
|
||||
/// doc for why the field is harmless-but-unused there, not
|
||||
/// special-cased away.
|
||||
min_wl_m: u32,
|
||||
},
|
||||
}
|
||||
@@ -227,13 +236,17 @@ impl GenWorkItem {
|
||||
|
||||
/// Coalescing key for `DeriveWindow` items only — `(connection, body,
|
||||
/// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending
|
||||
/// T-1137's `(connection, body)`). `granularity` is part of the key so an
|
||||
/// in-flight district-spacing (granularity 1) pan-burst is never
|
||||
/// superseded by an unrelated quarter-spacing (granularity 4) request for
|
||||
/// the same connection+body, and vice versa — the two rungs are separate
|
||||
/// in-flight derives, not competing updates to the same one.
|
||||
/// T-1137's `(connection, body)`; widened T-1152 to carry the full
|
||||
/// [`WindowGranularity`] enum rather than the legacy `u32`, so `Region`
|
||||
/// occupies its own coalescing slot exactly like `District`/`Quarter` do
|
||||
/// — this is one of the five T-1150 touch points the R5 redesign must
|
||||
/// carry the new representation through). `granularity` is part of the
|
||||
/// key so an in-flight district-spacing pan-burst is never superseded by
|
||||
/// an unrelated quarter- or region-spacing request for the same
|
||||
/// connection+body, and vice versa — every rung is a separate in-flight
|
||||
/// derive, not a competing update to the same one.
|
||||
/// `None` for every other variant (they don't coalesce this way).
|
||||
pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> {
|
||||
pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, WindowGranularity)> {
|
||||
if let GenWorkItem::DeriveWindow {
|
||||
body_id,
|
||||
conn_id,
|
||||
@@ -1150,19 +1163,14 @@ mod tests {
|
||||
/// coalescing tests can exercise the granularity axis of
|
||||
/// `window_supersede_key()` without a second near-duplicate helper.
|
||||
fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem {
|
||||
derive_window_at(
|
||||
body_id,
|
||||
conn_id,
|
||||
center,
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT,
|
||||
)
|
||||
derive_window_at(body_id, conn_id, center, WindowGranularity::District)
|
||||
}
|
||||
|
||||
fn derive_window_at(
|
||||
body_id: &str,
|
||||
conn_id: ConnectionId,
|
||||
center: DistrictPos,
|
||||
granularity: u32,
|
||||
granularity: WindowGranularity,
|
||||
) -> GenWorkItem {
|
||||
GenWorkItem::DeriveWindow {
|
||||
body_id: body_id.to_string(),
|
||||
@@ -1316,21 +1324,11 @@ mod tests {
|
||||
|
||||
let conn = ConnectionId(9);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"GranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT,
|
||||
),
|
||||
derive_window_at("GranBody", conn, (0, 0), WindowGranularity::District),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"GranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
derive_window_at("GranBody", conn, (0, 0), WindowGranularity::Quarter),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1352,21 +1350,11 @@ mod tests {
|
||||
|
||||
let conn = ConnectionId(11);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameGranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
derive_window_at("SameGranBody", conn, (0, 0), WindowGranularity::Quarter),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameGranBody",
|
||||
conn,
|
||||
(5, 5),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
derive_window_at("SameGranBody", conn, (5, 5), WindowGranularity::Quarter),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1376,6 +1364,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **PR #192 review — Hoshe 1: zero coalescing coverage for
|
||||
/// `WindowGranularity::Region` before this test**, despite Region being
|
||||
/// the highest-fan-out path (progressive capped-density tiling fires
|
||||
/// multiple concurrent Region `DeriveWindow` items per pan/zoom). Mirrors
|
||||
/// `submit_window_does_not_coalesce_different_granularity`'s pattern
|
||||
/// exactly, substituting Region for Quarter: a Region request and a
|
||||
/// District request for the SAME `(connection, body)` are separate
|
||||
/// in-flight slots (the coalescing key is `(conn_id, body_id,
|
||||
/// granularity)`) and must NOT coalesce — both survive as independent
|
||||
/// pending items.
|
||||
#[test]
|
||||
fn submit_window_does_not_coalesce_region_and_district() {
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
// See `submit_window_coalesces_same_connection_and_body`'s comment on
|
||||
// why the occupier must be `analyze()`, not `FillChunk`.
|
||||
q.submit(analyze("Occupier5"), GenPriority::Low);
|
||||
|
||||
let conn = ConnectionId(13);
|
||||
q.submit_window(
|
||||
derive_window_at("OrbitalGranBody", conn, (0, 0), WindowGranularity::Region),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at("OrbitalGranBody", conn, (0, 0), WindowGranularity::District),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
q.pending_count(),
|
||||
2,
|
||||
"same (connection, body) but Region vs. District must NOT coalesce — \
|
||||
separate in-flight slots, same as the existing District/Quarter pair"
|
||||
);
|
||||
}
|
||||
|
||||
/// The coalescing-DOES-happen counterpart to the test above, for Region
|
||||
/// specifically: two submissions for the SAME `(connection, body,
|
||||
/// Region)` still collapse to one pending item — confirms Region's
|
||||
/// coalescing key behaves identically to District/Quarter's, not just
|
||||
/// that it avoids cross-granularity aliasing.
|
||||
#[test]
|
||||
fn submit_window_coalesces_same_connection_body_and_region_granularity() {
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
q.submit(analyze("Occupier6"), GenPriority::Low);
|
||||
|
||||
let conn = ConnectionId(15);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameOrbitalGranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
WindowGranularity::Region,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameOrbitalGranBody",
|
||||
conn,
|
||||
(5, 5),
|
||||
WindowGranularity::Region,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
q.pending_count(),
|
||||
1,
|
||||
"same (connection, body, Region) must still coalesce to one pending item"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+768
-88
File diff suppressed because it is too large
Load Diff
@@ -418,7 +418,7 @@ fn drain_generation_completions(
|
||||
body_id,
|
||||
layer.center,
|
||||
layer.n,
|
||||
layer.granularity,
|
||||
layer.granularity_v2,
|
||||
layer.min_wl_m,
|
||||
),
|
||||
*layer,
|
||||
@@ -914,6 +914,7 @@ mod tests {
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_granularity_v2: None,
|
||||
window_min_wl_m: 0,
|
||||
},
|
||||
)]));
|
||||
|
||||
@@ -1202,6 +1202,7 @@ mod inbound_tests {
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_granularity_v2: None,
|
||||
window_min_wl_m: 0,
|
||||
};
|
||||
let frame = rmp_serde::to_vec_named(&req).unwrap();
|
||||
@@ -1249,6 +1250,7 @@ mod inbound_tests {
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_granularity_v2: None,
|
||||
window_min_wl_m: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -372,6 +372,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_granularity_v2: None,
|
||||
window_min_wl_m: 0,
|
||||
};
|
||||
let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize");
|
||||
|
||||
@@ -6,7 +6,7 @@ use settled_reach_server::atlas::layer1::Layer1Output;
|
||||
use settled_reach_server::atlas::layer_proxy::{
|
||||
AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry,
|
||||
QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode,
|
||||
SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC,
|
||||
SettlementEntry, SettlementLayer, SettlementSizeClass, WindowGranularity, REGION_TEMP_NONE_DC,
|
||||
WINDOW_GRANULARITY_DISTRICT,
|
||||
};
|
||||
use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState};
|
||||
@@ -727,6 +727,7 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
center: (10, -5),
|
||||
n: 2,
|
||||
granularity: WINDOW_GRANULARITY_DISTRICT,
|
||||
granularity_v2: WindowGranularity::District,
|
||||
min_wl_m: 0,
|
||||
morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland
|
||||
elev_q: vec![0, 45, 98, 60],
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_at_metres, BodyParams, ClimateConstants,
|
||||
derive_at_metres, derive_orbital_at_metres, BodyParams, ClimateConstants,
|
||||
};
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
use settled_reach_server::atlas::layer_proxy::{
|
||||
build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS,
|
||||
};
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||||
|
||||
@@ -151,3 +154,187 @@ fn bench_derive_at_metres_district_and_quarter_spacing() {
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Time `n_cells` sequential `derive_orbital_at_metres` calls — the
|
||||
/// region-baseline-blend-only path (T-1152, design doc §2/§4/§9 R1), no
|
||||
/// `invent_primitives` call at any point. Mirrors `time_derive_sweep`'s shape
|
||||
/// exactly so the two numbers are directly comparable.
|
||||
fn time_orbital_sweep(
|
||||
seed: SeedChain,
|
||||
body_id: &str,
|
||||
params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
climate: &ClimateConstants,
|
||||
grid_side: u32,
|
||||
step_m: f64,
|
||||
) -> (std::time::Duration, f64) {
|
||||
let n_cells = (grid_side * grid_side) as u64;
|
||||
let t0 = Instant::now();
|
||||
for row in 0..grid_side {
|
||||
for col in 0..grid_side {
|
||||
let wx = col as f64 * step_m;
|
||||
let wy = row as f64 * step_m;
|
||||
let prof = derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate);
|
||||
std::hint::black_box(prof.elev_q);
|
||||
}
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
let per_cell_ns = elapsed.as_secs_f64() * 1e9 / n_cells as f64;
|
||||
(elapsed, per_cell_ns)
|
||||
}
|
||||
|
||||
/// T-1152 / design doc §9 R1: "MEASURE FIRST" — per-cell cost of the
|
||||
/// orbital-mode region-baseline-blend-only path (no `invent_primitives`) at
|
||||
/// coarse (region-scale, ≥205 km) spacings, plus a realistic full-orbital-frame
|
||||
/// extrapolation (1600×900 canvas). This is the number the design doc's §4/§7
|
||||
/// planetary-rung cost story rested on as an UNMEASURED extrapolation —
|
||||
/// this test replaces "extrapolated from the uncut per-cell rate" with an
|
||||
/// actually-measured orbital-path rate.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_derive_orbital_at_metres_region_spacing() {
|
||||
let hm = bench_hm();
|
||||
let ta = bench_ta(&hm);
|
||||
let params = bench_params();
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
let grid_side = 64u32; // 4,096 cells/sweep, same shape as the district/quarter sweeps above
|
||||
|
||||
println!("\n=== T-1152 orbital-rung derive_orbital_at_metres benchmark ===");
|
||||
println!(
|
||||
"grid: {grid_side}x{grid_side} = {} cells/sweep\n",
|
||||
grid_side * grid_side
|
||||
);
|
||||
|
||||
let region_m = scale::REGION_M as f64;
|
||||
|
||||
// Region spacing (204,800 m) — the coarsest named rung short of the
|
||||
// planet-wide elastic seam (D-243).
|
||||
let (elapsed, per_cell_ns) =
|
||||
time_orbital_sweep(seed, "bench", ¶ms, &ta, &climate, grid_side, region_m);
|
||||
println!(
|
||||
"orbital, region spacing (204.8km): {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
per_cell_ns,
|
||||
per_cell_ns / 1000.0
|
||||
);
|
||||
|
||||
// Same spacing, for direct comparison: the FULL derive_at_metres path
|
||||
// (invent_primitives included) at the SAME region spacing — quantifies
|
||||
// exactly what skipping invention buys, at the spacing where it matters.
|
||||
let (elapsed_full, per_cell_ns_full) = time_derive_sweep(
|
||||
seed, "bench", ¶ms, &ta, &climate, grid_side, region_m, 0.0,
|
||||
);
|
||||
println!(
|
||||
"district-mode (full derive_at_metres) at region spacing: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)",
|
||||
elapsed_full.as_secs_f64() * 1000.0,
|
||||
per_cell_ns_full,
|
||||
per_cell_ns_full / 1000.0
|
||||
);
|
||||
println!(
|
||||
"orbital speedup vs. full derive at the same spacing: {:.2}x\n",
|
||||
per_cell_ns_full / per_cell_ns
|
||||
);
|
||||
|
||||
// Realistic full-orbital-frame estimate: a 1600x900 canvas at
|
||||
// ~1-2 px/cell equivalents (design doc §4's worked example resolution
|
||||
// class). Single-thread extrapolation from the MEASURED per-cell rate —
|
||||
// labelled as an extrapolation, not claimed as independently measured at
|
||||
// full canvas size (the parallel/chunked throughput is a SEPARATE
|
||||
// measurement, T-1151's row-chunked par_iter, already landed and reused
|
||||
// unchanged by the orbital rung's serving path — see the ticket report).
|
||||
for (label, px_per_cell) in [("1 px/cell", 1u32), ("2 px/cell", 2u32)] {
|
||||
let cols = 1600 / px_per_cell;
|
||||
let rows = 900 / px_per_cell;
|
||||
let cells = (cols as u64) * (rows as u64);
|
||||
let est_ms = cells as f64 * per_cell_ns / 1e6;
|
||||
println!(
|
||||
"full-canvas 1600x900 @ {label} ({cols}x{rows} = {cells} cells): \
|
||||
{est_ms:.1} ms single-thread (EXTRAPOLATED from the measured per-cell rate above)"
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
/// **T-1152 R1 — the number that actually governs interactive latency**, as
|
||||
/// opposed to the full-canvas single-shot extrapolation above (which the
|
||||
/// design doc's own carrier ruling makes moot — Jeroen's ruling is
|
||||
/// progressive capped-density TILING, never a whole-canvas one-shot derive).
|
||||
/// This measures a single served Region-granularity window tile through the
|
||||
/// REAL production path (`build_district_window_layer`, including its
|
||||
/// row-chunked `par_iter`, T-1151) at the wire-size cap — the same function
|
||||
/// `serve_district_window`/`run_work_item`'s `DeriveWindow` arm calls, not a
|
||||
/// hand-rolled sweep. This is the measured (not extrapolated) parallel
|
||||
/// number the design doc's §7 flagged as missing ("no chunked-par_iter
|
||||
/// benchmark has been run").
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_served_region_window_tile_at_wire_cap() {
|
||||
let hm = bench_hm();
|
||||
let ta = bench_ta(&hm);
|
||||
let params = bench_params();
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
|
||||
println!("\n=== T-1152 served Region-window-tile benchmark (real production path) ===");
|
||||
|
||||
// The largest n the server will ever actually derive at Region
|
||||
// granularity is DISTRICT_WINDOW_MAX_N_REGION, clamped further by
|
||||
// clamp_window_n_v2 to the WIRE_CAP_CELLS ceiling — use the SAME
|
||||
// capped n a real client's oversized request would resolve to.
|
||||
let n = DISTRICT_WINDOW_MAX_N_REGION;
|
||||
|
||||
// Warm-up call (first call on a body pays no extra cost here since ta is
|
||||
// already built — this just avoids counting one-time allocator warm-up
|
||||
// noise in the timed sample).
|
||||
let _ = build_district_window_layer(
|
||||
seed,
|
||||
"bench",
|
||||
¶ms,
|
||||
&ta,
|
||||
(0, 0),
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::Region,
|
||||
0,
|
||||
);
|
||||
|
||||
let iterations = 20;
|
||||
let t0 = Instant::now();
|
||||
let mut last_side = 0usize;
|
||||
for _ in 0..iterations {
|
||||
let layer = build_district_window_layer(
|
||||
seed,
|
||||
"bench",
|
||||
¶ms,
|
||||
&ta,
|
||||
(0, 0),
|
||||
n,
|
||||
&climate,
|
||||
WindowGranularity::Region,
|
||||
0,
|
||||
);
|
||||
last_side = (layer.morphology.len() as f64).sqrt().round() as usize;
|
||||
std::hint::black_box(layer.elev_q.len());
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
let per_call_ms = elapsed.as_secs_f64() * 1000.0 / iterations as f64;
|
||||
|
||||
println!(
|
||||
"n={n} (DISTRICT_WINDOW_MAX_N_REGION), derived {last_side}x{last_side} region cells \
|
||||
({} cells, WIRE_CAP_CELLS={WIRE_CAP_CELLS}):",
|
||||
last_side * last_side
|
||||
);
|
||||
println!(
|
||||
" {iterations} calls, {:.2} ms total, {per_call_ms:.3} ms/call \
|
||||
(row-chunked par_iter, {} Rayon threads available)",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
println!(
|
||||
" compare: shipped district n=64 cap measures ~5 ms/call (design doc §7, MEASURED)\n"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user