fix(client): T-1153 round 6 — zoom floor at the canonical frame; reset guard purely edge-triggered
The round-5 per-tick zoom comparison inside the reset guard was level- triggered: at the canonical frame every continued zoom-out tick drifted _view_zoom a hair below fit, read as 'not there', and re-fired enter_orbital — tile set torn down/recreated per tick, in-flight responses orphaned (black entry), server flooded (889 of 897 responses in one drive phase). The drift concern now lives in _zoom_at as a ZOOM FLOOR (once at the canonical frame, zoom-out clamps at fit); the guard keys only on fields that never drift (_held_center/_held_granularity_v2/ _tile_mode) — genuinely edge-triggered. Three shared helpers keep the guard and the floor on one definition of 'canonical frame'. Anti-storm regression drives 100 real _zoom_at ticks past the first reset and asserts tile-set child node identities never change; the black-entry repro interleaves Lendel's six wire-accurate tile responses with continued ticks and asserts all six held. Both revert-verified with the exact live symptoms. Round-5 reset tests pass unchanged. Live- verified twice: exactly 6 requests at entry and at reset, no storm.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user