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.
155 lines
6.5 KiB
GDScript
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()
|