Files
settled-reach/client/ui/implant/apps/atlas/atlas_window_tile_set.gd
T
jpmschweitzerandClaude Fable 5 4c34327b41 docs(ui): PR #193 review capture — D-226 pending-shape protocol note + stagger comment dedupe
Tyre's APPROVE items (T-1163): record the two-legal-wire-shapes-for-one-
logical-state protocol invariant (whole-response Pending AND Ready+null
district_window both mean 're-poll'; only NotFound/Error are terminal) as
a D-226 note under the T-1124 §4 amendment area, so a future server
refactor of the asymmetry must migrate every consumer in the same change.
Drop the duplicated 5-line stagger comment in atlas_window_tile_set.gd.

Follow-up tickets filed on main: T-1164 (tiled terminal-recovery),
T-1165 (queue_redraw edge root-cause), T-1166 (cold-launch test tier).

Tickets: T-1163

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:11:22 +02:00

204 lines
9.2 KiB
GDScript

extends Node
## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's
## ruling, design doc §4: "the top rest state is the WHOLE body, served as
## progressive capped-density TILING"). A single wire-capped Region window
## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a
## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single
## window is ~a third of the body, the exact live-round finding: shot 01's
## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference).
##
## Owns N independent `AtlasWindowRequest` child instances — one per tile —
## reusing 100% of the EXISTING, already-tested single-window request/cache/
## debounce/retry machinery (atlas_window_request.gd) rather than
## reinventing multi-window orchestration from scratch. Each tile is just a
## Region-granularity window request at its own canonicalized center
## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already
## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline),
## so nothing about the request/cache LAYER needed to change for tiling to
## work — only the ORCHESTRATION (issue N requests instead of one) and the
## DRAWING (a mosaic instead of one composite) are new.
##
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/
## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer)
## passes itself to `_init()`.
##
## Progressive arrival (design doc §4's own "with visible refinement as
## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects
## independently — a tile's own `_tiles[i]["window"]` updates the moment
## THAT tile's response lands, with no dependency on any other tile's
## arrival. The viewer/overlay reads `get_tiles()` every draw and renders
## whichever tiles have arrived so far — an empty/border-fade gap for the
## rest, exactly the same "hold what's there, sharpen in place" contract
## single-window progressive refinement already has (§6 "no mode flip"),
## just per-tile instead of per-composite.
signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
var _body_id: String = ""
var _tile_n: int = AtlasWindowGeometry.TILE_N
## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest,
## "window": Variant (null until arrived)} — one entry per tile, in the SAME
## deterministic order compute_tile_grid() produces (stable fill order, see
## that function's own doc).
var _tiles: Array = []
func _init(owner_ref = null) -> void:
_owner = owner_ref
## Unlike an individual AtlasWindowRequest (which has no signal connection of
## its own — the OWNING viewer forwards responses to it, per that class'
## own doc), the tile set DOES connect directly to
## SimBridge.atlas_layers_received itself and fans a single response out to
## EVERY tile's own `on_response()` — each tile's OWN staleness guard
## (center/n/granularity_v2) decides whether that particular response is
## the one IT was waiting for; only the matching tile ever adopts it. This
## is the same "one shared inbound signal, N independent consumers filtering
## by their own criteria" shape the design already uses elsewhere (every
## AtlasWindowRequest instance filters on its own state from a common
## broadcast — tiling just means N instances share the broadcast instead of
## one).
func _ready() -> void:
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
func _exit_tree() -> void:
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
func _on_atlas_layers_received(response: Dictionary) -> void:
for tile: Dictionary in _tiles:
var request = tile["request"]
if is_instance_valid(request):
request.on_response(response)
## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid,
## tears down any PREVIOUS tile set's child request nodes (a fresh
## enter_orbital() on a DIFFERENT body must not leave stale tile requests
## from the old body wired up), and issues one request per tile immediately
## (no debounce — matching AtlasWindowRequest.request_now()'s own "first
## window" contract, §5: entry is never debounced, only pan/rung-reselect
## refetches are).
func enter(body_id: String, body_radius_km: float) -> void:
_teardown()
_body_id = body_id
var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km)
for i in range(centers.size()):
var center: Vector2i = centers[i]
var request = AtlasWindowRequest.new(self)
request.name = "Tile%d" % i
# Cold-start dossier round 3 hardening: deterministic per-tile retry
# stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the
# same frame and retry in perfect lockstep, a request pulse every
# RETRY_DELAY instead of a spread trickle. Set BEFORE request_now()
# so it's already in place for the very first retry, if one fires.
request._stagger_index = i
add_child(request)
var tile_index := i # capture by value for the lambda below
request.window_ready.connect(
func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window)
)
_tiles.append({"center": center, "request": request, "window": null})
request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION)
func _on_tile_window_ready(index: int, window: Dictionary) -> void:
if index < 0 or index >= _tiles.size():
return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown)
_tiles[index]["window"] = window
tile_ready.emit(index)
## Tear down every tile's request node — disconnects nothing explicitly
## (queue_free() on a Node disconnects all its own signal connections
## automatically, Godot's documented behavior) but DOES clear `_tiles` so a
## stale index from an in-flight-but-now-orphaned request's eventual
## response can never reach `_on_tile_window_ready()` with a now-meaningless
## index (guarded there too, belt-and-suspenders).
func _teardown() -> void:
for tile: Dictionary in _tiles:
var request = tile["request"]
if is_instance_valid(request):
request.queue_free()
_tiles.clear()
## The current tile set, for the viewer/overlay to draw — an Array of
## {"center": Vector2i, "window": Variant} (the "request" key is internal,
## not exposed here; callers only need center + arrived-or-null window).
func get_tiles() -> Array:
var result: Array = []
for tile: Dictionary in _tiles:
result.append({"center": tile["center"], "window": tile["window"]})
return result
## True while at least one tile's `window` hasn't arrived yet — the viewer's
## cold-start self-healing redraw (see AtlasWindowViewer._process()'s own
## doc) polls this every frame so a mosaic's paint can never silently wedge
## behind a lost/late queue_redraw() no matter which signal edge it was
## supposed to ride in on. Also the source of truth for whether the §4
## pending treatment should show.
func has_pending_tiles() -> bool:
for tile: Dictionary in _tiles:
if tile["window"] == null:
return true
return false
## True once at least ONE tile has a real window — distinct from
## has_pending_tiles()'s "at least one MISSING" (both can be true at once,
## mid-arrival). PR #192 cold-start round 2: a cold server's first
## AnalyzeBody can take >10s with ZERO tiles landed the whole time — the
## per-tile border-fade wash alone (subtle, same color as every OTHER
## no-data-yet state) read as broken darkness in a live cold capture, not
## loading. The viewer uses this to gate an unmistakable "DERIVING
## TERRAIN…" label: shown while this is false (nothing has arrived at all —
## the reassurance is needed most), dropped the moment even one tile lands
## (per-tile washes alone read fine once real content is visibly filling in
## around the gaps).
func has_any_tile_arrived() -> bool:
for tile: Dictionary in _tiles:
if tile["window"] != null:
return true
return false
## True once tiling is active for the current body — a body whose whole
## circumference fits in ONE Region window's own coverage ceiling produces
## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so
## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling
## machinery, technically) from "genuinely multiple tiles" — the viewer uses
## this to decide whether the tile-set draw path or the ORIGINAL
## single-window draw path is simpler/preferred for a small body (both are
## correct; single-window avoids the extra Node/signal overhead when there's
## only ever going to be one tile).
func is_multi_tile() -> bool:
return _tiles.size() > 1
func get_tile_count() -> int:
return _tiles.size()
## True if every tile currently has an arrived window — the viewer/legend
## chrome can use this to know when the mosaic is "complete" vs. still
## progressively filling in.
func is_fully_arrived() -> bool:
if _tiles.is_empty():
return false
for tile: Dictionary in _tiles:
if tile["window"] == null:
return false
return true