Files
settled-reach/client/ui/implant/apps/atlas/atlas_window_cache.gd
T
jpmschweitzer ce90d69ae8 feat(client): T-1153 + T-1152 client half — continuous cursor-anchored zoom ladder, Region-rung orbital entry, click-through retired
The atlas 'regional' screen now opens the LADDER at the canonical orbital
frame (Region granularity, whole body fitted and centered) and wheel zoom
descends continuously — cursor-anchored, unclamped across rungs, with
progressive refinement (held composite keeps drawing, finer rung swaps in
place on arrival; no blank frame, no mode flip). Full-zoom-out resets to
the canonical planetary frame per Jeroen's HARD condition
(is_fully_zoomed_out = extent >= body circumference, not a zoom-value
heuristic). The district_screen nav hop is deleted — D-013 restored:
descent is a zoom gesture, not a nav push. AtlasViewer's heightmap-texture
path is unreachable from nav (code intact; overlay surface deferred, see
report/tickets).

Rung selection: design doc §5's literal formula has NO legal District band
at any real viewport (visual-tolerance band and n=64 coverage ceiling
never overlap — pinned by executable boundary tests at 1600x900);
select_rung() splits it into a coverage ceiling (decides Region) then the
2x visual tolerance (District vs Quarter), documented at the function.
In practice the ladder steps Region -> Quarter directly.

Wire: window_granularity_v2 encoded (omitted at District for byte-compat),
granularity_v2 echoed value keyed + staleness-guarded end to end; Region
clamp mirror replicates the server's bounded halving loop (no closed
form). MIN/MAX_ZOOM widened to [0.0005, 64] — the old 0.5 floor would
have clamped a real body's canonical fit zoom, violating the reset
condition.

Real pre-existing bug fixed in atlas_window_overlay.gd: the draw path used
echoed n as both cell-grid dimension and district extent — only
coincidentally correct at District granularity; Quarter/Region would have
read wrong array offsets. cell_grid_side_for_window() now mirrors the
server's WindowGranularity::cell_grid_side.

Tests: +26 pure-function geometry tests, new 30-test zoom-ladder suite,
extensions across the window cache/request/overlay/delivery suites.
Full suite 3518 green; cold-parse clean.
2026-07-22 11:41:37 +02:00

155 lines
6.5 KiB
GDScript

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; 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, 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/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 —
## no separate linked-list/counter bookkeeping needed.
##
## Consumed via explicit load() by path (no class_name), matching
## atlas_overlay_bar.gd/atlas_legend_panel.gd (review #8 precedent
## elsewhere in this app): the owner constructs one instance and holds it,
## same shape as those two.
const DEFAULT_MAX_ENTRIES: int = 24
## Mirrors the server's WINDOW_GRANULARITY_DISTRICT (layer_proxy.rs) — the
## 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
func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
_max_entries = maxi(1, max_entries)
## 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,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> String:
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_v2).
func has(
body_id: String,
center: Vector2i,
n: int,
granularity: int = DISTRICT_GRANULARITY,
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, granularity_v2))
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
## null on a miss — callers must not confuse this with a real "None" server
## response, which is a different concept (§1: an as-yet-underived window is
## carried as `district_window: None` inside a `Ready` AtlasLayerResponse,
## not a cache state).
func get_window(
body_id: String,
center: Vector2i,
n: int,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> Variant:
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]
# Touch: erase + re-insert moves the key to the end (most-recently-used).
_entries.erase(key)
_entries[key] = value
return value
## Store a window, evicting the least-recently-used entry(ies) if over
## capacity. Overwriting an existing key also counts as a touch.
func put(
body_id: String,
center: Vector2i,
n: int,
window: Dictionary,
granularity: int = DISTRICT_GRANULARITY,
min_wl_m: int = 0,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
var key := make_key(body_id, center, n, granularity, min_wl_m, granularity_v2)
if _entries.has(key):
_entries.erase(key)
_entries[key] = window
while _entries.size() > _max_entries:
var oldest_key: String = _entries.keys()[0]
_entries.erase(oldest_key)
func size() -> int:
return _entries.size()
func clear() -> void:
_entries.clear()