diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 3a706fbfe..f48038082 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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 diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index 8935f8b49..43077a933 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -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 diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 9a089ee9f..b84d8f7ae 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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 ) diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd index 1fa7aef11..e1dd4fb15 100644 --- a/client/tests/test_atlas_data_delivery.gd +++ b/client/tests/test_atlas_data_delivery.gd @@ -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 diff --git a/client/tests/test_atlas_descend_entry.gd b/client/tests/test_atlas_descend_entry.gd index 1215aa355..5dbb4c2a1 100644 --- a/client/tests/test_atlas_descend_entry.gd +++ b/client/tests/test_atlas_descend_entry.gd @@ -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) # ============================================================================= diff --git a/client/tests/test_atlas_window_cache.gd b/client/tests/test_atlas_window_cache.gd index 3fdc61d02..af5294d7c 100644 --- a/client/tests/test_atlas_window_cache.gd +++ b/client/tests/test_atlas_window_cache.gd @@ -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) diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index 030dd05b3..c77bf7d54 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -265,3 +265,336 @@ 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() — the §5 rung-selection rule, split into TWO tests +# per select_rung()'s own doc: a COVERAGE ceiling decides Region (can a +# District window even span this much world), and the `2x` visual-tolerance +# rule (design doc §5: "select the coarsest rung whose cell spacing <= +# 2*(E/C)") decides District vs. Quarter for whatever's under that ceiling. +# ============================================================================= + + +## A tight sample spacing (deep zoom-in — small E over a large C) must select +## Quarter (512 m), the finest legal rung — 2*(E/C) is far below District's +## 2,048 m spacing at this ratio. +func test_select_rung_picks_quarter_at_a_tight_sample_spacing() -> void: + # E=2000m over C=1000px -> sample spacing 2 m/px -> threshold 4 m. Even + # Quarter (512 m) is coarser than the threshold, so select_rung() falls + # through to the FINEST legal rung (its own documented fallback) rather + # than returning something even finer that doesn't exist — Quarter. + var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) + assert_str(rung).is_equal("Quarter") + + +## A sample spacing that satisfies BOTH District's own `2x` band AND the +## coverage ceiling selects District — the coarsest rung whose spacing still +## satisfies the fine-end rule, without exceeding what a District window can +## physically cover. +func test_select_rung_picks_district_at_a_moderate_sample_spacing() -> void: + # E=120,000m (under the 64*2048=131,072m coverage ceiling) over C=100px -> + # threshold = 2*120000/100 = 2,400m — satisfies District's 2,048m spacing. + var rung: String = AtlasWindowGeometry.select_rung(120_000.0, 100.0) + assert_str(rung).is_equal("District") + + +## An extent past the COVERAGE ceiling (more world than a District window can +## physically span, regardless of how generous the visual tolerance would +## otherwise be) must select Region — the coverage test, not the `2x` visual +## one, is what decides this (select_rung()'s own doc: "the coverage ceiling +## wins whenever the two disagree"). +func test_select_rung_picks_region_past_the_coverage_ceiling() -> void: + # E = full Earth-like circumference (~40,075 km) — far past the + # 64*2048=131,072m District coverage ceiling regardless of canvas_px. + var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0) + assert_str(rung).is_equal("Region") + + +## Exactly AT the coverage ceiling (E == 64*2048 = 131,072m) must still +## select District if the `2x` band also agrees — the ceiling is `>`, not +## `>=`, so the boundary value itself stays under District's own test. +func test_select_rung_coverage_ceiling_boundary_stays_district() -> void: + var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0) + assert_str(rung).is_equal("District") + + +## One metre past the coverage ceiling must flip to Region — confirms the +## ceiling actually bites right at its own boundary, not one district-window +## short of it. +func test_select_rung_one_past_the_coverage_ceiling_is_region() -> void: + var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0) + assert_str(rung).is_equal("Region") + + +## Exactly AT District's `2x` threshold (spacing_m == 2*(E/C)) must select +## District, not the next-finer rung — the rule is `<=`, not `<`. +func test_select_rung_district_threshold_boundary_is_inclusive() -> void: + # District spacing = 2048 m. Choose E/C such that 2*(E/C) == 2048 exactly: + # E=1024, C=1.0 -> E/C=1024 -> threshold=2048. E=1024 is also comfortably + # under the coverage ceiling (131,072), so the `2x` test is what's + # actually being exercised here. + var rung: String = AtlasWindowGeometry.select_rung(1024.0, 1.0) + assert_str(rung).is_equal("District") + + +## Degenerate canvas_px (<=0, an unlaid-out viewport) must fall back to the +## FINEST rung, never crash or pick the coarsest by dividing by zero — the +## documented "under-resolve is the safe failure direction" disposition (and +## must be checked BEFORE the coverage ceiling could otherwise route a +## degenerate small extent toward Region by accident). +func test_select_rung_degenerate_canvas_px_falls_back_to_finest() -> void: + var rung: String = AtlasWindowGeometry.select_rung(1000.0, 0.0) + assert_str(rung).is_equal("Quarter") + + +## 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) + + +## The exact scenario that surfaced the coverage-vs-visual-tolerance +## distinction (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 — this is the direct regression guard for the bug this +## implementation found and fixed (an earlier version of select_rung() +## selected 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") + + +## Pinned capture-resolution boundary numbers (1600x900, the coordinator's +## requested eyeball-capture viewport) — a live executable regression guard +## for select_rung()'s own doc's worked example. Region releases District's +## coverage ceiling at _view_zoom ~= 1.5625; District's own `2x` band edge +## sits at _view_zoom ~= 0.125 — i.e. BELOW (not above) the coverage-ceiling +## crossing, confirming the two never overlap at this (or any real) canvas +## size — see select_rung()'s "Tuning knobs" paragraph for what would need +## to change (DISTRICT_WINDOW_MAX_N, a server-side wire-budget change) to +## open a real District band. +func test_select_rung_1600x900_region_district_boundary_zoom() -> void: + var viewport := Vector2(1600.0, 900.0) + var canvas_px: float = maxf(viewport.x, viewport.y) + var boundary_zoom := 1.5625 + var just_inside: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport + ) + var just_outside: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport + ) + assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).override_failure_message( + "zoomed IN past ~1.5625 at 1600x900 must have released the Region coverage ceiling" + ).is_not_equal("Region") + assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).override_failure_message( + "zoomed OUT past ~1.5625 at 1600x900 must still be under the Region coverage ceiling" + ).is_equal("Region") + + +func test_select_rung_1600x900_district_quarter_boundary_zoom_confirms_no_overlap() -> void: + var viewport := Vector2(1600.0, 900.0) + var canvas_px: float = maxf(viewport.x, viewport.y) + var boundary_zoom := 0.125 + var just_inside: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport + ) + var just_outside: float = AtlasWindowGeometry.world_extent_m( + CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport + ) + # Both sides of the District/Quarter `2x`-band boundary read "Region" at + # 1600x900, NOT "District" — confirming the coverage ceiling (which + # releases at zoom~=1.5625, far above this boundary) has already forced + # Region long before the `2x` band's own edge is reached. This is the + # literal "no overlap" finding, pinned as an executable assertion. + assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).is_equal("Region") + assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).is_equal("Region") + + +# ============================================================================= +# 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) diff --git a/client/tests/test_atlas_window_overlay.gd b/client/tests/test_atlas_window_overlay.gd index 2217b0748..d60434db1 100644 --- a/client/tests/test_atlas_window_overlay.gd +++ b/client/tests/test_atlas_window_overlay.gd @@ -150,3 +150,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 diff --git a/client/tests/test_atlas_window_request.gd b/client/tests/test_atlas_window_request.gd index 942d243dd..2e5c6f261 100644 --- a/client/tests/test_atlas_window_request.gd +++ b/client/tests/test_atlas_window_request.gd @@ -16,17 +16,23 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re ## 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], @@ -70,6 +76,54 @@ 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. +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, 1, 0, "Region") + req.on_response(_mock_response("GJ380c", region_window)) + assert_bool(req.is_pending()).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() + + # ============================================================================= # (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults # ============================================================================= @@ -175,3 +229,87 @@ 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 (no closed form, per Dudley's own +# doc — "replicate the loop exactly"). +# ============================================================================= + + +## District/Quarter through the v2 mirror must be BYTE-IDENTICAL to the +## legacy mirror — the server's own +## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs` +## 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 +## `region_per_axis_cap_lands_exactly_on_wire_cap_when_uncontested`-shaped +## boundary (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) + + +## A REGION request whose derived grid would exceed WIRE_CAP_CELLS (n set so +## cell_grid_side(n) rounds to 65, just over the 64-per-axis wire-size +## ceiling) must actually HALVE — this is the case the boundary test above +## deliberately sits just below, so this one confirms the loop body actually +## fires, not just that its guard conditions are correct at the edges. +func test_clamp_window_n_mirror_v2_region_halves_when_over_wire_cap() -> void: + # n=6,450 -> cell_grid_side = round(64.5) = 65 (round-half-away-from-zero, + # matching Rust's f64::round() and GDScript's roundi() for non-negative + # inputs) -> 65² = 4,225 > WIRE_CAP_CELLS (4,096) -> must halve at least once. + var n: int = AtlasWindowRequest._clamp_window_n_mirror_v2( + 6450, AtlasWindowRequest.GRANULARITY_V2_REGION + ) + assert_int(n).override_failure_message( + "a Region request whose grid exceeds WIRE_CAP_CELLS must be halved down, not left at 6,450" + ).is_less(6450) + var side: int = AtlasWindowRequest._cell_grid_side_region_mirror(n) + assert_int(side * side).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) diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd index 7e528affc..127efedfe 100644 --- a/client/tests/test_atlas_window_viewer.gd +++ b/client/tests/test_atlas_window_viewer.gd @@ -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) diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd new file mode 100644 index 000000000..d510893c7 --- /dev/null +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -0,0 +1,361 @@ +## 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") + + +## 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") + + +## enter_orbital()'s `n` must equal the body's full equatorial circumference +## in districts (district_extent().cols) — the whole body fitted to the +## canvas, per Jeroen's HARD condition wording. +func test_enter_orbital_n_covers_the_full_circumference() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_int(v._held_n).is_equal(int(extent["cols"])) + + +## 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") + + +## 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") + + +## 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 spacing +## band) 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 legal 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). District's legal +## band (select_rung()'s own doc: the coverage ceiling and the `2x` visual +## band only overlap at small viewports — `canvas_px <= DISTRICT_WINDOW_MAX_N +## * DISTRICT_SPACING_M / 1024 = 128px`) requires a SMALL viewport here, +## unlike most of this suite's 800x600/1920x1080 fixtures. +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") + + +# ============================================================================= +# T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent- +# relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless +# of which rung's data is actually held), so they must keep working +# unmodified at Region granularity, not just District/Quarter. +# ============================================================================= + + +## The pole wall, wired through the real _apply_pan_delta() path, must still +## clamp at Region granularity — same mechanism as the existing District-rung +## test (test_wasd_pan_is_clamped_by_the_pole_wall_when_wired), just entered +## via enter_orbital() instead of enter(). +func test_pole_wall_clamps_at_region_granularity_too() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(800.0, 800.0) + var radius_km := 50.0 # tiny synthetic body — pole wall reachable by an ordinary tick + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_str(v._held_granularity_v2).is_equal("Region") + + var unclamped_magnitude: float = 500.0 * AtlasWindowViewer.PAN_SPEED_CANVAS_PX_S * v.get_view_zoom() + v._apply_pan_delta(Vector2(0.0, -1.0), 500.0) # "W"/north held, an absurdly long tick + + assert_float(absf(v.get_view_offset().y)).override_failure_message( + "the pole wall must still clamp an extreme pan at Region granularity" + ).is_less(unclamped_magnitude * 0.5) + + +## East-west wrap (canonicalize_district_center()) must still apply to the +## pan-edge refloat's resulting center at Region granularity — a pan that +## carries the screen-center column past the body's circumference must wrap +## into [0, cols), never run away to an out-of-range column, exactly as the +## District-rung wrap tests already pin (T-1142 item 6a). At Region's own +## enormous held_n (a whole circumference), an ORDINARY pan tick's +## canvas-space delta is negligible relative to the window's half-extent +## (confirmed: ~0.08 districts per 5-second tick vs. a ~9,772-district +## half-window) — so this drives _maybe_refloat_window() DIRECTLY off a +## manually-set _view_offset large enough to genuinely cross the held +## window's edge, the same "exercise the actual edge-crossing branch, not +## just its no-op early-return" discipline _maybe_refloat_window()'s own +## inside-check comment describes. +func test_pan_edge_refloat_wraps_columns_at_region_granularity_too() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(800.0, 800.0) + var radius_km := 6371.0 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_str(v._held_granularity_v2).is_equal("Region") + + # Force the held center to sit one column short of the wrap seam, then + # shift the CANVAS offset by more than half the window's own on-screen + # extent — enough to move the screen-center's mapped column past the + # window's far edge (i.e. past `cols`, crossing the seam) regardless of + # Region's huge held_n. + v._held_center = Vector2i(cols - 1, 0) + var half_window_screen_px: float = float(v._held_n) * v.get_cell_pixel_size() * v.get_view_zoom() * 0.5 + v._view_offset = v.get_view_offset() - Vector2(half_window_screen_px * 1.5, 0.0) + v._maybe_refloat_window() + + assert_int(v._held_center.x).override_failure_message( + "a pan crossing the antimeridian at Region granularity must wrap the" + + " resulting center into [0, cols), never run past cols" + ).is_less(cols) + assert_int(v._held_center.x).is_greater_equal(0) diff --git a/client/ui/implant/apps/atlas/atlas_app.gd b/client/ui/implant/apps/atlas/atlas_app.gd index 7e79fd34d..0873d852e 100644 --- a/client/ui/implant/apps/atlas/atlas_app.gd +++ b/client/ui/implant/apps/atlas/atlas_app.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/atlas_window_cache.gd b/client/ui/implant/apps/atlas/atlas_window_cache.gd index dfdec3785..9c379b98f 100644 --- a/client/ui/implant/apps/atlas/atlas_window_cache.gd +++ b/client/ui/implant/apps/atlas/atlas_window_cache.gd @@ -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 diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index f1c7b75d9..289ce8b12 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -10,6 +10,40 @@ 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. + +## 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). select_rung() uses this +## to answer "CAN a District-granularity window even cover this much world at +## all" — the coarse-end ceiling, distinct from the fine-end `2x` tolerance. +const DISTRICT_WINDOW_MAX_N: int = 64 ## Fit-and-center: given the viewport size and the window's side length in @@ -116,3 +150,291 @@ 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 (design doc §5, restated): for a world extent `E` +## metres shown across canvas `C` px, sample spacing is `E/C`. Select the +## COARSEST rung whose cell spacing is `<= 2*(E/C)` — one tier finer than a +## screen pixel, never coarser (never magnified-interpolation of a coarser +## composite, the literal thing the D-166 corollary forbids). This `2x` +## tolerance gates the FINE end only (District vs. Quarter) — see the +## coarse-end paragraph below for why Region is decided by a DIFFERENT test. +## +## `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). +## +## **Region is selected by a COVERAGE test, not the `2x` visual tolerance** +## (found live-testing the orbital-entry fit zoom, where E covers a whole +## planetary circumference). The `2x` formula is calibrated to catch the +## ZOOM-IN failure mode the corollary names explicitly — never request +## coarser derivation than the screen can currently resolve — and has no +## meaningful symmetric zoom-OUT reading: testing Region's own 204.8 km +## spacing against the SAME threshold that gates District/Quarter would +## reject Region at essentially every normal screen resolution (a whole-body +## view's sample spacing is tens of km/px, and 2x that is still far under +## 204.8 km — even though visually a ~5-10-screen-px-per-cell Region view +## reads perfectly fine, nowhere near "magnified interpolation"). The +## GENUINELY load-bearing question at the coarse end is different: can a +## District-granularity window (capped server-side at +## DISTRICT_WINDOW_MAX_N=64 districts, ~131 km per side) physically COVER +## the extent being displayed at all? Once it can't, Region is the only rung +## that CAN — this is a coverage/capacity fact, not a resolution-legibility +## judgment, and it's what actually decides "zoom out past the district rung +## transitions to Region" per the ticket's own framing. +## +## **A third finding, resolving the above two against each other:** at +## CELL_PIXEL_SIZE=16 (the shipped display scale), the fine-end `2x` band +## that would select District and the coarse-end coverage ceiling that +## selects Region do not meet — District's OWN native resolution already +## reads as "too fine" (wants Quarter) well before its 64-district coverage +## cap becomes binding (wants Region), leaving NO zoom range where the `2x` +## formula alone would ever pick District. Since the coverage ceiling is a +## hard CAPABILITY limit (a District request literally cannot serve more +## world than its per-axis cap covers) while the `2x` band is a QUALITY +## preference (finer than strictly needed is wasteful, not wrong), the +## coverage ceiling wins whenever the two disagree: check it FIRST, and only +## consult the `2x` band to choose between District and Quarter for whatever +## extent remains under that ceiling. This is a genuine engineering call this +## implementation makes (flagged to the team, not a design-doc-literal +## derivation) — see docs/architecture/atlas-zoom-ladder-t1143.md §5 Risk R4 +## ("Region rung is named but unscoped") for the open design question this +## resolves pragmatically rather than by further design-pass iteration. +## +## **Whether a District band exists at all is independent of CELL_PIXEL_SIZE** +## — it cancels out of the "does District's `2x` band overlap the coverage +## ceiling" condition entirely. The condition reduces to `canvas_px <= +## DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M / 1024` — i.e. `canvas_px <= +## 128px` at the shipped constants. At every real viewport (800px+), this is +## never satisfied: District's band is empty by construction, and the ladder +## in practice steps Region -> Quarter directly at any normal screen size. +## Verified numerically at 1600x900 (canvas_px=1600): the Region/District +## crossing (world_extent_m == the coverage ceiling) sits at `_view_zoom ≈ +## 1.5625`, and the District/Quarter crossing (the `2x` band's own edge) +## sits at `_view_zoom ≈ 0.125` — i.e. the `2x` band's own boundary is +## already PAST (a smaller zoom than) where the coverage ceiling releases +## District, so the two never overlap in the zoomed-in direction either. +## **Tuning knobs, if a real District band is wanted:** the ONLY lever that +## opens the gap is `DISTRICT_WINDOW_MAX_N` (currently 64, mirrored from the +## server's own per-axis cap) — it would need to reach `1024 * canvas_px / +## DISTRICT_SPACING_M` (≈800 at a 1600px canvas) to open a band there, a +## substantial server-side wire-size change (T-1150's `WIRE_CAP_CELLS` +## budget), not a client-only tuning knob. `CELL_PIXEL_SIZE` does NOT affect +## whether a band exists — it only shifts WHERE both crossing zooms sit on +## the wheel gesture (scaling both proportionally, preserving their ~12.5x +## gap), i.e. it is the felt-pacing knob for how much wheel travel separates +## Region from Quarter, not a way to reintroduce District. +## +## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region"). +static func select_rung(world_extent_m: float, canvas_px: float) -> String: + if world_extent_m > float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M: + return "Region" # coverage ceiling — District physically cannot span this much world + if canvas_px <= 0.0: + return "Quarter" # finest — an unlaid-out viewport must under-resolve, not over-resolve + var sample_spacing_m: float = world_extent_m / canvas_px + var threshold_m: float = 2.0 * sample_spacing_m + if DISTRICT_SPACING_M <= threshold_m: + return "District" + return "Quarter" # threshold too small for even District's own spacing -> finest legal rung + + +## 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)) + + +# ============================================================================= +# 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 diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index df355509e..2cc7da250 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -46,9 +46,21 @@ 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-1145 item 3: interim presentation toggle — true renders the smoothed ## Image/ImageTexture composite; false keeps the original crisp per-cell @@ -94,25 +106,67 @@ 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: +## 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, n, active_toggle) + _rebuild_texture_if_needed(w, grid_side, active_toggle) if _cached_texture == null: return var extent: float = float(n) * cell_px @@ -125,8 +179,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 +194,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 +212,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 +236,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. diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index 7f1c7b97d..ca5999f90 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -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,34 @@ 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/granularity/min_wl_m/ +## granularity_v2 (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. func on_response(response: Dictionary) -> void: if str(response.get("body_id", "")) != _body_id: return @@ -207,17 +291,25 @@ func on_response(response: Dictionary) -> void: 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)) + # T-1152: granularity_v2 is ALWAYS populated on a real server response + # (resolve_window_granularity_v2() always resolves to a concrete rung — + # see DistrictWindowLayer.granularity_v2's own doc), but the mock/old-shape + # response fixtures this suite's own tests build predate the field — + # default to "District" so an old-shape mock keeps matching a + # district-granularity request exactly as it did before this field existed. + var echoed_granularity_v2 := str(w.get("granularity_v2", AtlasWindowCache.DEFAULT_GRANULARITY_V2)) if ( echoed_center != _center or echoed_n != _n or echoed_granularity != _granularity or echoed_min_wl_m != _min_wl_m + or echoed_granularity_v2 != _granularity_v2 ): - 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) @@ -227,7 +319,7 @@ func _schedule_retry() -> void: 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 +328,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 diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 9ae168e80..cc53bfe7b 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -1,25 +1,38 @@ 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 now (T-1152 +## client half): the whole ladder from the canonical orbital frame (Region +## rung) down to District/Quarter granularity lives in ONE screen/Control, +## not a separate planetary heightmap viewer + a windowed drill-down. Renders +## a DistrictWindowLayer composite at whichever rung is currently held: +## 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). One colorizer +## family renders every rung unchanged (design doc §6) — AtlasWindowOverlay +## never branches on granularity_v2 for COLOR, only for the derived +## cell-grid's RESOLUTION (cell_grid_side_for_window()). ## -## 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). +## = _canvas.scale — the SAME transform idiom as the (retired) planetary +## viewer. +## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame (never +## blocks on a re-derive), but is CONTINUOUS AND UNCLAMPED ACROSS RUNGS +## (T-1153, D-013 restored for this seam): crossing a rung's spacing +## threshold (§5 rung-selection rule) fires a background request for the +## new granularity while the OLD composite keeps drawing — progressive +## refinement, no blank frame, no mode flip (§6). A pan past the held +## window's edge re-requests the SAME rung at a new center (§4/§5, +## unchanged from T-1138). +## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD +## condition) — see _maybe_reset_to_canonical_frame(). ## - _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. +## rung-reselect, entry), never talks to SimBridge directly itself. ## ## 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 @@ -28,16 +41,38 @@ extends Control ## 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 +## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153) +## Esc back (nav.pop() — the "district" nav-stack entry is +## gone as a separate hop, see atlas_app.gd's own doc) 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 stay a wide safety clamp on the raw display +## multiplier (never letting _view_zoom collapse to zero or run away toward +## infinity) — they are NOT a rung boundary any more. Wheel zoom is now +## CONTINUOUS and UNCLAMPED ACROSS RUNGS (D-226 T-1143-rulings amendment, +## Jeroen's seam ruling: "D-013's zoom gesture owns spatial descent restored +## for this seam"): crossing a rung's spacing threshold (§5's rung-selection +## rule, AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT +## granularity window at the SAME apparent screen extent, it does not clamp +## _view_zoom itself. The programmatic capture API (set_view(), T-1120) still +## clamps to this same wide range — a capture harness driving a specific +## zoom/offset pair has no rung-crossing concept of its own to trigger. +## +## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for +## enter_orbital()'s largest legal `n` (a whole equatorial circumference, up +## to hundreds of thousands of districts on a gas-giant-scale body) is never +## itself clamped — a clamped fit zoom would silently show LESS than the +## whole body, breaking Jeroen's HARD condition ("the whole body fitted to +## the canvas") at exactly the moment it matters most. 0.0005 covers a +## ~120,000 km-radius body (n≈368,000 districts) at a 3840px 4K viewport with +## headroom; a real fit_zoom this low is expected and correct at the +## canonical orbital frame, not a bug. +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 @@ -74,6 +109,14 @@ const COLOR_BG: Color = Color("#0d1117") ## 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 — the border-fade's referent repointed +## to "the previous derived composite at this position" for a rung-crossing +## zoom (progressive refinement leaves real data on screen, unlike the +## no-composite-yet case COLOR_BORDER_FADE covers). Same hue family, much +## lighter alpha — a hint that something sharper is arriving, not a claim +## that the current view is empty or 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 @@ -119,6 +162,16 @@ 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 ("Quarter"/"District"/"Region") this viewer +## is currently HOLDING (the last-adopted _window's own rung) — distinct from +## _window_request.get_granularity_v2(), which is what's most recently been +## REQUESTED (may be a finer/coarser rung already in flight while the held +## composite is still the previous rung's, per the progressive-refinement +## contract: hold the old composite, swap only when the new one arrives). +## Defaults to District — the ladder's historical entry rung, and the correct +## disposition for AtlasDescendGeometry click-through descent (still District, +## see enter()'s own doc). +var _held_granularity_v2: String = "District" # ── Pan/zoom state ───────────────────────────────────────────────────────── var _view_offset: Vector2 = Vector2.ZERO @@ -202,9 +255,18 @@ func _exit_tree() -> void: ## 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. +## equivalent Vector2i, from a click-through's derived position — §5's "pan +## center read as click point") at District granularity. n defaults to the +## client's interactive default (32), half the server's hard cap. Kept as a +## thin District-rung wrapper over _enter_at_rung() (T-1153) — a click-to- +## descend-to-point shortcut on top of the continuous ladder (Jeroen's +## ruling: "if a click-to-descend-to-point remains cheap to keep... wired to +## the same descent path"). No screen currently calls this directly (the +## retired planetary click-through it served no longer exists — see +## atlas_app.gd's own doc); it survives as the landing point a future +## map-object click (e.g. a settlement marker on the Region-rung view) would +## wire into, and as a direct-call entry for tests/tools that want a +## District-rung window without going through enter_orbital() first. ## ## T-1142: `district_center` is canonicalized (wrap column / clamp row) ## BEFORE it becomes `_held_center` or reaches the request — matching the @@ -220,18 +282,72 @@ func enter( 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: "the whole body fitted to the canvas, +## centered at the body's canonical origin"). This is the new "regional" nav +## entry point (T-1152 client half — supersedes AtlasViewer's heightmap +## texture as the sole entry): the player lands on a fully-derived Region-rung +## view of the whole body, then wheel-zoom descends CONTINUOUSLY from there — +## no separate planetary screen, no click-through required to reach the +## windowed view at all (though enter() above stays wired for a +## click-to-descend shortcut, per Jeroen's ruling). +## +## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the +## equator" (AtlasDescendGeometry's own doc, mirroring +## district_profile.rs). Canonical extent = the WHOLE equatorial +## circumference in districts (district_extent().cols), i.e. one full +## circumnavigation — the same quantity is_fully_zoomed_out()/the +## full-zoom-out reset (see _maybe_reset_to_canonical_frame()) test against, +## so entry and reset always agree on what "the top" means. No-radius bodies +## (tiny test bodies) fall back to the District-rung default window — there +## is no planetary circumference concept to derive a Region-rung n from (same +## fallback disposition AtlasDescendGeometry's own no-radius branches use +## throughout). +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 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) + + +## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center` +## must already be canonicalized by the caller (enter_orbital()'s (0,0) needs +## no canonicalization; enter()'s does its own before calling in). Resets +## every piece of held/request state for a fresh descent, exactly as the +## pre-T-1153 enter() always did, plus the new _held_granularity_v2 tracking. +func _enter_at_rung( + body: Dictionary, + system: Dictionary, + district_center: Vector2i, + n: int, + granularity_v2: String ) -> void: _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 = district_center _held_n = n + _held_granularity_v2 = granularity_v2 _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) + _window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n, granularity_v2) _refresh_screen_header() grab_focus() queue_redraw() @@ -317,23 +433,47 @@ func _on_atlas_layers_received(response: Dictionary) -> void: _window_request.on_response(response) +## T-1153: progressive refinement — this is the ONE place a new rung's +## window gets adopted (swapped in), and it deliberately does NOT clear +## `_window` first. The OLD composite (whatever rung it was) stays drawn +## every frame up to and including the one before this call — no blank +## frame, no mode flip (§6 acceptance criterion) — because `_window` is a +## single-slot "the composite currently drawn" reference that only ever gets +## REPLACED, never nulled, once a window has been adopted at least once +## (enter()/_enter_at_rung() nulls it only at a fresh descent, a real +## navigation event, not a rung 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 @@ -343,8 +483,12 @@ func _on_window_ready(window: Dictionary) -> void: # ============================================================================= -# 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,6 +499,14 @@ func _apply_transform() -> void: _overlay_node.queue_redraw() +## Cursor-anchored zoom (D-013 restored for this seam, Jeroen's ruling): the +## CANVAS POINT under the cursor stays fixed on screen across the zoom step — +## zooming toward the cursor, not the view center. Unclamped ACROSS RUNGS +## (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies to the raw +## multiplier itself — see that constant's own doc); after applying the new +## zoom, checks whether the currently-displayed world extent now calls for a +## different rung (_maybe_reselect_rung()) and whether the view has reached +## the ladder's top rest state (_maybe_reset_to_canonical_frame()). func _zoom_at(mouse_pos: Vector2, factor: float) -> void: var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) if is_equal_approx(new_zoom, _view_zoom): @@ -363,6 +515,97 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void: _view_zoom = new_zoom _view_offset = mouse_pos - local_before * _view_zoom _apply_transform() + if _maybe_reset_to_canonical_frame(): + return # the reset already re-fit + re-requested at the top rung + _maybe_reselect_rung() + + +## The world extent (metres) currently displayed across the LARGER viewport +## dimension — the `E` half of the §5 rung-selection rule's `E/C`. A pure +## function of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own +## doc for why the currently-held rung is NOT an input: the composite's +## on-screen footprint is rung-invariant by construction, so sample density +## depends only on zoom). Thin wrapper kept here so callers don't need to +## know the pure function lives on AtlasWindowGeometry (T-1153 — extracted +## there, alongside select_rung(), to keep the §5 math unit-testable without +## a Control in the tree). +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 coarsest legal rung for the NOW-displayed world extent +## (_current_world_extent_m() over the viewport's larger dimension). If that +## differs from what's currently HELD on screen, request the new granularity +## centered on the CURRENT screen-center's district position (reusing +## _screen_center_district() — the same screen-to-district math +## _maybe_refloat_window() already established, which is rung-agnostic since +## CELL_PIXEL_SIZE is always district-based regardless of the held rung's +## true cell spacing — see atlas_window_overlay.gd's cell_grid_side_for_window() +## doc for why that's true). +## +## Progressive refinement, not block-on-derive: this does NOT touch `_window` +## or `_held_granularity_v2` — the OLD composite keeps drawing every frame +## (border-fade/pending-indicator per R6 shows the request is in flight, see +## _draw_border_fade()) until _on_window_ready() adopts the NEW rung's window +## once it actually arrives (§6 "no mode flip": never a blank frame, never a +## clear-then-redraw). +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) + if 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() + _held_center = new_center + _window_request.request_debounced( + _dict_str(_body, "body_id", ""), new_center, _held_n, target_rung + ) + + +## The DistrictPos the current screen center maps to, in RAW absolute +## district space (matching _maybe_refloat_window()'s own convention — only +## the caller canonicalizes the final value it actually stores/sends). Thin +## wrapper over AtlasWindowGeometry.screen_center_to_district() (T-1153 — +## extracted alongside the rung-selection math for the same testability +## reason) so both the pan-edge refetch and the rung-reselect refetch share +## ONE screen-to-district formula rather than two copies that could drift +## (the exact lesson _maybe_refloat_window()'s own doc already establishes +## for the pan case). +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) + + +## Jeroen's HARD condition (D-226 T-1143-rulings amendment): "a full +## zoom-out resets to the original canonical planetary frame and location" — +## the ladder's TOP REST STATE, never a drifted pan/zoom-out state. Fires +## when the CURRENTLY DISPLAYED world extent (at _held_granularity_v2, the +## rung actually on screen — deliberately NOT the in-flight request's rung, +## so this can't fire prematurely off a request that hasn't landed yet) +## covers the whole body (AtlasWindowGeometry.is_fully_zoomed_out()) AND the +## player isn't ALREADY sitting at the canonical frame (center == (0,0) — +## re-entering the SAME enter_orbital() state on every zoom tick past the +## threshold would fight a player trying to zoom back IN from the top, since +## every zoom-out tick would keep re-snapping to the identical framing). +## Returns true if it fired (the caller should skip _maybe_reselect_rung() — +## the reset already re-requested at the canonical Region-rung window). +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 _held_center == Vector2i.ZERO and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION: + 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 — must survive on @@ -412,15 +655,9 @@ func set_view(zoom: float, offset: Vector2) -> void: 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)) + 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 +666,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 +682,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 + ) # ============================================================================= @@ -457,22 +701,37 @@ func _draw() -> void: 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) @@ -485,15 +744,22 @@ func _build_screen_header() -> void: ## 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". +## "4.1 x 4.1 km . 2.0 km/cell". T-1153: the extent (`n` districts) is +## rung-INVARIANT (n is always district extent — see +## AtlasWindowOverlay.cell_grid_side_for_window()'s doc), but the km/cell +## reading must reflect the HELD rung's actual spacing (2.048 km at District, +## 0.512 km at Quarter, 204.8 km at Region) — this is the "continuous +## metres-per-pixel/extent readout" the 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, no named +## mode chrome does. 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 spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_held_granularity_v2) / 1000.0 + var extent_line: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km] var title: String = "REGIONAL — %s" % location_label.to_upper() _screen_header.set_content(title, extent_line) @@ -608,84 +874,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) # ============================================================================= diff --git a/client/ui/implant/apps/atlas/screens/district_screen.gd b/client/ui/implant/apps/atlas/screens/district_screen.gd deleted file mode 100644 index 1b009ef06..000000000 --- a/client/ui/implant/apps/atlas/screens/district_screen.gd +++ /dev/null @@ -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() diff --git a/client/ui/implant/apps/atlas/screens/regional_screen.gd b/client/ui/implant/apps/atlas/screens/regional_screen.gd index b32a54848..0911ffc57 100644 --- a/client/ui/implant/apps/atlas/screens/regional_screen.gd +++ b/client/ui/implant/apps/atlas/screens/regional_screen.gd @@ -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)