diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd index 5331edbf3..b352f9f4e 100644 --- a/client/tests/test_atlas_zoom_ladder.gd +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -372,12 +372,16 @@ func test_reset_after_crossing_out_and_back_snaps_to_the_canonical_fit_zoom() -> ## zoom (0.004, chosen below the fit zoom) regardless of the reset. This ## test reproduces that shape directly: continue zooming out PAST the point ## where the reset first re-enters tile mode, all the way to a target zoom -## BELOW the fit value. Before the second live-round-5 fix, `_view_zoom` -## drifted back down from the fit value on every subsequent `_zoom_at()` -## tick (ordinary multiplicative scaling doesn't care that a reset just -## happened) while the mode/center/granularity guard read "already -## canonical" and silently let it drift, landing on whatever the LOOP's -## target zoom happened to be instead of the fit value. +## BELOW the fit value. Before the round-5 fix, `_view_zoom` drifted back +## down from the fit value on every subsequent `_zoom_at()` tick while the +## mode/center/granularity guard read "already canonical" and silently let +## it drift, landing on whatever the LOOP's target zoom happened to be +## instead of the fit value. **Live round 6 update:** the MECHANISM that +## now holds this assertion changed — `_zoom_at()`'s own zoom FLOOR (not a +## re-firing reset) is what keeps `_view_zoom` pinned at fit through +## continued zoom-out ticks; see `_maybe_reset_to_canonical_frame()`'s own +## doc for why re-firing on every tick caused a request storm. This test's +## own assertions are unchanged — only the doc below was updated to match. func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> void: var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) add_child(v) @@ -420,14 +424,175 @@ func test_reset_resnaps_even_after_continued_zoom_out_past_the_first_reset() -> ( "post-reset _view_zoom (%.6f) must equal the canonical fit zoom (%.6f) even" + " though the wheel gesture continued past the point where the reset first" - + " fired (target was %.6f, BELOW the fit zoom) — the guard must re-fire on" - + " every subsequent tick where the zoom has drifted away from the fit value," - + " not just once" + + " fired (target was %.6f, BELOW the fit zoom) — _zoom_at()'s own zoom floor" + + " must keep pinning it at fit through every subsequent tick, not just once" ) % [v._view_zoom, fit_zoom, target_zoom] ).is_equal_approx(fit_zoom, 0.000001) +## Live round 6's ANTI-STORM test — the exact repro the coordinator's live +## drive caught: drive a REAL continued zoom-out gesture (via `_zoom_at()`, +## the same call path the live drive uses — NOT calling +## `_maybe_reset_to_canonical_frame()` directly with unchanged state, which +## trivially can't reproduce the drift the storm depends on) many ticks past +## the point where the reset first fires — asserts ZERO additional tile-set +## entries occur across the WHOLE gesture. Spies on `AtlasWindowTileSet`'s +## own child `AtlasWindowRequest` node INSTANCES (captured right after the +## FIRST reset) — a fresh `enter_orbital()` call tears down (`queue_free()`s) +## every one of them and creates BRAND NEW ones, so "the same node +## instances are still alive and still the tile set's children after 100 +## more ticks" is a direct, non-invasive proxy for "the reset never fired +## again" — no new production instrumentation needed. Before the round-6 +## fix, `_zoom_at()`'s continued multiplicative zoom-out drifted `_view_zoom` +## below fit on every subsequent tick, the level-triggered guard read "not +## already there" every time, and `enter_orbital()` fired repeatedly: +## tearing down and recreating the tile set (and its 6 request nodes) every +## tick — exactly the "889 of 897 wire responses arrived during one +## zoom-out phase" storm. +func test_reset_evaluated_repeatedly_at_canonical_frame_issues_zero_additional_requests() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — a tiling body, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + # Zoom IN far enough to leave tile mode, then zoom back OUT past the + # first reset — same shape as the round-5 continued-zoom-out test, but + # this time spying on the tile set across the WHOLE remaining gesture + # instead of only checking the final zoom value. + var cursor_pos := Vector2(1100.0, 300.0) # matches tmp_drive_ladder.gd's own aim point + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: the first reset must have fired before spying on the tile set" + ).is_true() + + var tile_set = v.get_tile_set() + var original_requests: Array = tile_set.get_children() + assert_int(original_requests.size()).override_failure_message( + "sanity: the first reset must have created real tile-request child nodes to spy on" + ).is_greater(0) + + # Continue the SAME zoom-out gesture 100 MORE ticks past the first + # reset — a real wheel gesture has no way to stop exactly at the reset + # point, and holding the wheel down (or residual scroll momentum) keeps + # sending ticks. None of these must tear down/recreate the tile set. + for _i in range(100): + v._zoom_at(cursor_pos, 1.0 / 1.05) + + var current_requests: Array = tile_set.get_children() + assert_int(current_requests.size()).override_failure_message( + "the tile set's child count must be unchanged after 100 more continued" + + " zoom-out ticks — a changed count means teardown/recreate happened" + ).is_equal(original_requests.size()) + for i in range(original_requests.size()): + assert_bool(is_instance_valid(original_requests[i])).override_failure_message( + "original tile-request node #%d must still be alive — a storm would have" + + " queue_free()'d it and created a fresh one" % i + ).is_true() + assert_bool(is_same(original_requests[i], current_requests[i])).override_failure_message( + ( + "tile-request node #%d must be the SAME instance as right after the" + + " first reset — a different object at the same index means the tile" + + " set was torn down and recreated (a storm), even if the count" + + " coincidentally matches" + ) + % i + ).is_true() + + +## Live round 6's BLACK-ENTRY repro: enter_orbital(), then deliver the six +## wire-accurate tile responses WHILE a REAL continued zoom-out gesture (via +## `_zoom_at()`, matching the live drive's actual input shape — a held +## wheel-down keeps sending ticks concurrently with responses streaming in +## from the server) is in flight — asserts all six are accepted and HELD +## (tile set stable throughout, no teardown between delivery and the final +## assertion). Before the round-6 fix, the level-triggered guard fired on +## every zoom-out tick once `_view_zoom` drifted below fit, tearing down the +## tile set mid-delivery and orphaning responses addressed to now-freed +## request nodes — nothing ever accumulated, and the mosaic stayed black +## even though the server dutifully answered every request. +func test_six_tile_responses_survive_concurrent_reset_evaluation_and_are_held() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — 6 tiles, the live-repro shape + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + var tile_set = v.get_tile_set() + var tiles: Array = tile_set.get_tiles() + assert_int(tiles.size()).override_failure_message( + "sanity: Lendel must produce Lendel's own real tile count (6) for this" + + " repro to be faithful, not a smaller synthetic count" + ).is_equal(6) + + # Same continued zoom-out gesture as the anti-storm test above — leave + # tile mode, cross back into it (the first reset), then KEEP sending + # zoom-out ticks (a real held wheel has no way to stop exactly at the + # reset point). Responses are delivered interleaved with these ticks, + # exactly matching the live drive's concurrent shape. + var cursor_pos := Vector2(1100.0, 300.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).is_false() + for _i in range(200): + v._zoom_at(cursor_pos, 1.0 / 1.05) + if v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: the first reset must have fired before delivering responses" + ).is_true() + + for i in range(tiles.size()): + var center: Vector2i = tiles[i]["center"] + var tile_window: Dictionary = { + "center": [center.x, center.y], + "n": AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION, + "granularity": SERVER_LEGACY_GRANULARITY_REGION_SENTINEL, + "granularity_v2": "Region", + "morphology": PackedByteArray([8, 14, 0, 1]), + "elev_q": PackedByteArray([40, 90, 5, 60]), + "temp_dc": [120, 95, -32768, 60], + "moisture_q": PackedByteArray([50, 30, 90, 20]), + "vegetation": PackedByteArray([2, 1, 6, 3]), + "glaciation": PackedByteArray([0, 0, 1, 2]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) + # Interleave several MORE continued zoom-out ticks, matching the live + # drive's per-frame cadence — none of these must tear anything down. + for _tick in range(5): + v._zoom_at(cursor_pos, 1.0 / 1.05) + + var final_tiles: Array = tile_set.get_tiles() + assert_int(final_tiles.size()).override_failure_message( + "the tile set must still have all 6 tile slots — a storm mid-delivery" + + " would have torn it down and rebuilt it with fresh (unfulfilled) slots" + ).is_equal(6) + for i in range(final_tiles.size()): + assert_that(final_tiles[i]["window"]).override_failure_message( + ( + "tile #%d's window must be HELD (non-null) — all six wire-accurate" + + " responses delivered during a concurrent continued zoom-out gesture" + + " must survive to be accepted, not be silently dropped by an" + + " orphaning teardown" + ) + % i + ).is_not_null() + + ## Not fully zoomed out (a normal District-rung view) must NOT trigger the ## reset — only reaching the top of the ladder resets, not every zoom step. func test_reset_to_canonical_frame_does_not_fire_when_not_fully_zoomed_out() -> void: diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index cf742db71..908feb70b 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -46,18 +46,15 @@ const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 ## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display ## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED -## ACROSS RUNGS (D-013 restored for this seam): crossing a rung's coverage -## ceiling (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT -## granularity at the SAME apparent screen extent, never clamping -## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same -## range independently. +## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling +## (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT granularity +## at the SAME apparent screen extent, never clamping _view_zoom itself. +## set_view() (T-1120 capture API) clamps to this same range independently. ## ## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for -## enter_orbital()'s largest legal `n` (up to hundreds of thousands of -## districts on a gas-giant-scale body) is never itself clamped — that would +## enter_orbital()'s largest legal `n` is never itself clamped — that would ## silently show LESS than the whole body, breaking Jeroen's HARD condition. -## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport with -## headroom. +## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport. const MIN_ZOOM: float = 0.0005 const MAX_ZOOM: float = 64.0 const ZOOM_STEP: float = 1.15 @@ -145,13 +142,10 @@ 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, 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 (see enter()'s own doc). +## T-1153: the granularity_v2 tag this viewer is currently HOLDING (the +## last-adopted _window's own rung) — distinct from +## _window_request.get_granularity_v2() (most recently REQUESTED, may be a +## different rung already in flight). Defaults to District (see enter()'s doc). var _held_granularity_v2: String = "District" # ── Pan/zoom state ───────────────────────────────────────────────────────── @@ -248,13 +242,11 @@ func _exit_tree() -> void: ## Enter the window screen centered on `district_center` at District -## granularity. n defaults to 32, half the server's hard cap. Thin -## District-rung wrapper over _enter_at_rung() (T-1153) — a -## click-to-descend-to-point shortcut; survives as a direct-call test entry. +## granularity. n defaults to 32. Thin wrapper over _enter_at_rung() +## (T-1153); survives as a direct-call test entry. ## -## T-1142: `district_center` is canonicalized (wrap column / clamp row) -## BEFORE it becomes `_held_center` or reaches the request — matching the -## server's normalize_window_center() so the echo comparison never mismatches. +## T-1142: `district_center` is canonicalized BEFORE it becomes +## `_held_center` — matching the server's normalize_window_center(). func enter( body: Dictionary, system: Dictionary, @@ -272,11 +264,10 @@ func enter( ## frame (Jeroen's HARD condition: whole body fitted to canvas, centered at ## the canonical origin). The "regional" nav entry point (T-1152 client ## half): the player lands on a fully-derived Region-rung view of the whole -## body, then wheel-zoom descends CONTINUOUSLY from there. -## -## Canonical origin = district (0,0) — same quantity -## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against. -## No-radius bodies fall back to the District-rung default window. +## body, then wheel-zoom descends CONTINUOUSLY from there. Canonical origin +## = district (0,0), same quantity is_fully_zoomed_out()/ +## _maybe_reset_to_canonical_frame() test against. No-radius bodies fall +## back to the District-rung default window. ## ## **Live round 3 (design doc §4): the rest state must TILE.** A single ## wire-capped Region window covers only a fraction of a real body's @@ -302,9 +293,8 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void: ## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as ## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`. -## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its -## own TILE_N-sized request independently), so extent math elsewhere needs -## no tile-specific branch. +## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps +## its own TILE_N-sized request independently). func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void: var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) var n: int = int(extent["cols"]) @@ -330,10 +320,9 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> ## must already be canonicalized by the caller. Resets every piece of ## held/request state for a fresh descent, plus _held_granularity_v2. ## -## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be -## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` — -## mirroring AtlasWindowRequest.request_now()'s own `_n` clamp (PR #191 Tyre -## C1). Raw `n` left `_held_n` disagreeing with the server's clamped echo. +## **C1 clamp-mirror, one layer up:** `n` MUST be clamped via +## `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` — mirroring +## AtlasWindowRequest.request_now()'s own clamp (PR #191 Tyre C1). func _enter_at_rung( body: Dictionary, system: Dictionary, @@ -378,9 +367,8 @@ func _fit_and_center() -> void: _apply_transform() -## T-1142: needs the body's rows_half, in whole districts — a no-radius body -## has no pole concept (matching canonicalize_district_center()'s own -## no-radius identity), so the wall is a no-op there (rows_half=0). +## T-1142: needs the body's rows_half — a no-radius body has no pole +## concept, so the wall is a no-op there (rows_half=0). func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -538,11 +526,17 @@ func _apply_transform() -> void: ## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays -## fixed on screen across the zoom step. Unclamped across rungs (only the -## wide MIN_ZOOM/MAX_ZOOM safety clamp applies); after applying, checks -## whether the extent now calls for a different rung or the top rest state. +## fixed on screen. Unclamped across rungs (only the wide MIN_ZOOM/MAX_ZOOM +## safety clamp applies); after applying, checks the top rest state. func _zoom_at(mouse_pos: Vector2, factor: float) -> void: var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) + # Live round 6: once settled at the canonical frame, a continued zoom-OUT + # tick must not drift `_view_zoom` below fit (see the reset's own doc for + # why that caused a request storm). Clamping the ZOOM here — not the + # reset guard — keeps the reset edge-triggered. Zoom-IN is never clamped. + if new_zoom < _view_zoom and _is_at_canonical_frame(): + var fit_zoom: float = _canonical_fit_zoom() + new_zoom = maxf(new_zoom, fit_zoom) if is_equal_approx(new_zoom, _view_zoom): return var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom @@ -564,19 +558,16 @@ func _current_world_extent_m() -> float: ## §5 rung-selection rule + progressive refinement (T-1153): after a zoom ## step, recompute the legal rung for the NOW-displayed world extent. If it ## differs from what's HELD, request the new granularity centered on the -## CURRENT screen-center (_screen_center_district(), same formula as -## _maybe_refloat_window()). +## CURRENT screen-center (same formula as _maybe_refloat_window()). ## -## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be -## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale -## large `_held_n` desyncs `_on_window_ready()`'s `w_n != _held_n` check and -## drops the refinement forever. +## **C1 clamp-mirror, a THIRD layer up:** `_held_n` MUST be re-clamped via +## `_clamp_window_n_mirror_v2()` for the TARGET rung — a stale large +## `_held_n` desyncs `_on_window_ready()`'s staleness check. ## ## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` — ## the OLD composite keeps drawing until _on_window_ready() adopts the new -## one (§6 "no mode flip"). Live round 5: this lag is exactly what made -## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire — see that -## function's own doc. +## one (§6 "no mode flip"). Live round 5: this lag is what made +## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire. func _maybe_reselect_rung() -> void: if _held_n <= 0: return @@ -617,8 +608,8 @@ func _maybe_reselect_rung() -> void: ## The DistrictPos the current screen center maps to, in RAW absolute -## district space. Thin wrapper over AtlasWindowGeometry. -## screen_center_to_district() so pan-edge/rung-reselect refetch share one formula. +## district space. Thin wrapper over screen_center_to_district() so +## pan-edge/rung-reselect refetch share one formula. func _screen_center_district() -> Vector2i: var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district( size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n @@ -627,29 +618,59 @@ func _screen_center_district() -> Vector2i: return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km) +## Live round 6: whether the body needs mosaic tiling — mirrors +## enter_orbital()'s own dispatch condition so entry/predicate/guard agree. +func _canonical_tile_mode(radius_km: float) -> bool: + return AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1 + + +## Live round 6: the fit zoom enter_orbital() lands on for the CURRENT +## body/viewport — read by both the canonical predicate and _zoom_at()'s floor. +func _canonical_fit_zoom() -> float: + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + return _view_zoom # no-radius body — no canonical frame concept, floor is a no-op + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var canonical_n: int = int(extent["cols"]) + var fit: Dictionary = AtlasWindowGeometry.fit_window_view( + get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM + ) + return float(fit["zoom"]) + + +## Live round 6: true when the CURRENT view EXACTLY matches the canonical +## frame — center/mode/granularity plus `_view_zoom` at fit (float epsilon). +func _is_at_canonical_frame() -> bool: + var radius_km: float = float(_body.get("body_radius_km", 0.0)) + if radius_km <= 0.0: + return false + return ( + _held_center == Vector2i.ZERO + and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION + and _tile_mode == _canonical_tile_mode(radius_km) + and is_equal_approx(_view_zoom, _canonical_fit_zoom()) + ) + + ## Jeroen's HARD condition: "a full zoom-out resets to the original -## canonical planetary frame and location" — the ladder's TOP REST STATE, -## never a drifted pan/zoom-out state. Fires when the CURRENTLY DISPLAYED -## extent covers the whole body AND the player isn't ALREADY at the -## canonical frame (re-snapping every tick would fight a zoom-in-from-the- -## top gesture). Returns true if it fired (caller skips _maybe_reselect_rung()). +## canonical planetary frame and location." EDGE-triggered (live round 6): +## fires only on the transition INTO fully-zoomed-out from non-canonical. ## -## **Live round 5 fix:** the "already there" guard checked only +## **Live round 5 fix:** the old guard checked only ## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on -## response adoption, §6). A TILING body's `_held_granularity_v2` stays -## "Region" after zooming IN crosses `_tile_mode -> false` (no response -## landed yet); zooming back OUT misread that stale value as "already -## canonical," so `_view_zoom` shrank to MIN_ZOOM instead of the fit value. -## Fixed by also requiring `is_tile_mode()` to match a fresh entry's value. +## response adoption). A TILING body's granularity stays stale "Region" +## after zooming IN leaves tile mode, misreading "already canonical" and +## never resetting. Fixed by also requiring `is_tile_mode()` to match. ## -## **Live round 5, SECOND fix (same repro, one tick later):** a real wheel -## gesture keeps sending zoom-out ticks AFTER the reset fires — `_zoom_at()` -## scales `_view_zoom` down every tick regardless, so it drifts below the -## fit value again almost immediately (the fit zoom sits right at the -## fully-zoomed-out threshold by construction). The mode/center/granularity -## guard then reads "already canonical" (true — those never moved) and -## skips re-firing, even though the ZOOM drifted away. `_view_zoom` must -## also be compared against the CURRENT fit zoom. +## **Live round 6 fix (round 5's SECOND fix overshot into a storm):** a +## per-tick zoom-equality check on THIS guard made it LEVEL-triggered — +## continued zoom-out kept nudging `_view_zoom` below fit, so the guard +## read "not already there" every tick and `enter_orbital()` fired +## repeatedly: tile set torn down/recreated each time, orphaning in-flight +## responses (nothing held → black), flooding the server (889/897 wire +## responses in one zoom-out phase). Fixed by moving the zoom-drift concern +## to `_zoom_at()`'s own zoom floor instead — this guard's +## mode/center/granularity check alone stays edge-triggered. func _maybe_reset_to_canonical_frame() -> bool: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -657,27 +678,13 @@ func _maybe_reset_to_canonical_frame() -> bool: var world_extent_m: float = _current_world_extent_m() if not AtlasWindowGeometry.is_fully_zoomed_out(world_extent_m, radius_km): return false - var canonical_tile_mode: bool = AtlasWindowGeometry.compute_tile_grid(radius_km).size() > 1 - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var canonical_n: int = int(extent["cols"]) - var fit: Dictionary = AtlasWindowGeometry.fit_window_view( - get_rect().size, canonical_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM - ) - if ( - _held_center == Vector2i.ZERO - and _held_granularity_v2 == AtlasWindowRequest.GRANULARITY_V2_REGION - and _tile_mode == canonical_tile_mode - and is_equal_approx(_view_zoom, float(fit["zoom"])) - ): + if _is_at_canonical_frame(): return false # already at the canonical frame — don't fight a zoom-in-from-the-top gesture enter_orbital(_body, _system) return true -## Programmatic view control (T-1120 capture-API parity — must survive on -## every viewer this app exposes, per the ticket's explicit note, even one -## that never got user pan/zoom to begin with on the OTHER seam this ticket -## removes it from). +## Programmatic view control (T-1120 capture-API parity). func get_view_zoom() -> float: return _view_zoom @@ -703,9 +710,8 @@ func set_view(zoom: float, offset: Vector2) -> void: ## — if so, float a NEW window centered on that point via the debounced path. ## ## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute -## district space — only the FINAL new_center is canonicalized (wrap column, -## clamp row), matching the server's normalize_window_center() so the echo -## comparison/cache key stay on the same canonical form. +## district space — only the FINAL new_center is canonicalized, matching the +## server's normalize_window_center(). func _maybe_refloat_window() -> void: if _held_n <= 0: return @@ -833,21 +839,18 @@ func _location_label() -> String: # ============================================================================= -## No city panel / sidebar in this mode (yet) — the window carries no -## settlement join of its own (see _location_label's doc), so there is -## nothing to hit-test against and this always reads false. Wired into -## _gui_input exactly where AtlasViewer's own _is_over_ui is (same guard -## shape) so a future sidebar addition only needs to change THIS function's -## body, not every call site. +## No city panel / sidebar in this mode (yet) — nothing to hit-test against, +## so this always reads false. Wired into _gui_input exactly where +## AtlasViewer's own _is_over_ui is, so a future sidebar addition only +## needs to change THIS function's body. func _is_over_ui(_pos: Vector2) -> bool: return false -## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click -## semantics with map objects). What remains: wheel zoom and tracking the -## local mouse position for edge-scroll (_process() has no InputEvent of its -## own). WASD/arrow panning does NOT go through _gui_input — it's a -## HELD-key, frame-rate-independent pan polled every frame in _process(). +## T-1145 item 2: LMB-drag panning is GONE (drag broke click semantics with +## map objects). What remains: wheel zoom and tracking mouse position for +## edge-scroll. WASD/arrow panning does NOT go through _gui_input — it's a +## HELD-key pan polled every frame in _process(). func _gui_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.is_echo(): _handle_key(event as InputEventKey) @@ -876,12 +879,9 @@ func _handle_key(event: InputEventKey) -> void: back_pressed.emit() -## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied -## here (not _gui_input) since both are HELD-state effects, not discrete -## events — polls held state every frame and hands the direction + delta to -## _apply_pan_delta() (split out for testability — a gdUnit test drives it -## directly rather than faking Godot's global Input singleton). Skips while -## hidden (screen not the active nav-stack entry). +## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both HELD-state +## effects polled every frame, handed to _apply_pan_delta() (split out for +## testability). Skips while hidden (screen not the active nav-stack entry). func _process(delta: float) -> void: if not visible: return