Merge remote-tracking branch 'origin/ladder-cold-start'
This commit is contained in:
@@ -126,6 +126,9 @@ build: build-server build-client
|
||||
build-server:
|
||||
cd server && cargo build
|
||||
|
||||
build-server-release:
|
||||
cd server && cargo build --release
|
||||
|
||||
build-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@# First import may error on theme/font loading before the import scan completes.
|
||||
@@ -161,9 +164,14 @@ game: stop build
|
||||
# without it SimBridge boots in test mode (instant fake CONNECTED, no network
|
||||
# at all), which would make the companion open the Atlas against a dynamic
|
||||
# test-harness snapshot instead of the real systems.db world via the wire.
|
||||
atlas: build-server build-client
|
||||
# Release server for the atlas: a cold DEBUG server takes >10s before the
|
||||
# first tile of a new body can exist (first-body terrain analysis), which
|
||||
# reads as a black/broken map on entry (live-verified 2026-07-22). The
|
||||
# release binary serves a cold body's first tiles in well under a second.
|
||||
# SR_SERVER_BIN tells atlas_standalone.gd's spawn path which binary to use.
|
||||
atlas: build-server-release build-client
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@SR_LIVE=1 $(GODOT) --path client scenes/atlas_standalone.tscn
|
||||
@SR_LIVE=1 SR_SERVER_BIN=server/target/release/settled-reach-server $(GODOT) --path client scenes/atlas_standalone.tscn
|
||||
|
||||
stop:
|
||||
@lsof -ti :9876 | xargs -r kill 2>/dev/null || true
|
||||
|
||||
@@ -263,8 +263,26 @@ func _wait_for_connected(timeout_s: float) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func _server_binary_path() -> String:
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
## Pure function: SR_SERVER_BIN env override, else the debug build path —
|
||||
## same two-tier shape as _attach_port()'s SR_PORT (D-254 §1). `make atlas`
|
||||
## now builds the RELEASE server and passes SR_SERVER_BIN so a `_spawn_server()`
|
||||
## cold-start (this file's own live-repro path — the coordinator's cold-start
|
||||
## dossier) gets sub-second first-tile derivation instead of a debug build's
|
||||
## multi-second AnalyzeBody. `override_env`/`override_project_dir` exist ONLY
|
||||
## for direct-call unit tests (mirrors _attach_port(port_env)'s own param
|
||||
## shape) — every real caller uses the zero-arg form. Accepts either an
|
||||
## ABSOLUTE path or one relative to the project root (`res://../`), so
|
||||
## SR_SERVER_BIN can be given as `server/target/release/settled-reach-server`
|
||||
## (the Makefile's own shape) without the caller having to know this script's
|
||||
## `res://` layout.
|
||||
static func _server_binary_path(override_env: String = "", override_project_dir: String = "") -> String:
|
||||
var project_dir := (
|
||||
override_project_dir if not override_project_dir.is_empty()
|
||||
else ProjectSettings.globalize_path("res://")
|
||||
)
|
||||
var env := override_env if not override_env.is_empty() else OS.get_environment("SR_SERVER_BIN")
|
||||
if not env.is_empty():
|
||||
return env if env.is_absolute_path() else project_dir.path_join("../" + env)
|
||||
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
## PR #192 cold-start dossier — coordinator's live repro against a freshly-
|
||||
## spawned (cold) server (`make atlas` shape, first AnalyzeBody taking
|
||||
## seconds): BUG 1 (tile-mosaic paint never resolving), the legend-stacking
|
||||
## half of BUG 2, and BUG 3 (the "DERIVING TERRAIN…" pending-state label,
|
||||
## round 2). Split out of test_atlas_zoom_ladder.gd purely for file-length
|
||||
## reasons (gdlint max-file-lines) — same instantiation/mock-response
|
||||
## conventions as that file, not a different testing philosophy.
|
||||
## RegionalScreen's own re-entry-guard half of BUG 2 is covered separately
|
||||
## in test_regional_screen.gd (a different layer — nav, not the viewer).
|
||||
class_name TestAtlasColdStart
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
|
||||
|
||||
## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors
|
||||
## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the
|
||||
## real wire value matters, not a convenient placeholder).
|
||||
const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict (n=2 by default) — mirrors
|
||||
## test_atlas_zoom_ladder.gd's own _mock_window().
|
||||
static func _mock_window(center: Vector2i, n: int = 2) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"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]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUG 1 — tile-mosaic paint self-heal.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Counts real _draw() invocations — CanvasItem exposes no public
|
||||
## "is a redraw pending" query in this Godot version, so the only reliable
|
||||
## signal that queue_redraw() actually had an effect is the engine calling
|
||||
## _draw() again on a subsequent frame. Subclasses the REAL AtlasWindowOverlay
|
||||
## (not a duck-typed stub) so drawing still runs through the genuine
|
||||
## production code path — this spy only adds counting, nothing else.
|
||||
class _CountingOverlay extends AtlasWindowOverlay:
|
||||
var draw_count := 0
|
||||
|
||||
func _draw() -> void:
|
||||
draw_count += 1
|
||||
super._draw()
|
||||
|
||||
|
||||
## On a cold server, a tile's window_ready can land well after entry's own
|
||||
## paint window, and a live repro showed the mosaic staying black even with
|
||||
## every tile held/textured — only resolving on an unrelated gesture.
|
||||
## _process() must therefore queue a redraw on BOTH the viewer and the
|
||||
## overlay every frame while any tile is still pending, regardless of
|
||||
## whether the tile-arrival signal path painted correctly on its own. Proven
|
||||
## here by swapping the REAL overlay for a _draw()-counting subclass right
|
||||
## after entry (once the entry-time redraw has already resolved via a real
|
||||
## frame), then calling _process() directly with NO input/gesture and
|
||||
## confirming a further frame actually invokes _draw() again — revert-
|
||||
## verified against a version of _process() with the self-heal removed
|
||||
## (fails without it, since nothing else re-queues while idle).
|
||||
func test_process_self_heals_the_overlay_redraw_while_tiles_are_pending() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
assert_bool(v.get_tile_set().has_pending_tiles()).override_failure_message(
|
||||
"sanity: entry must leave every tile pending before any response arrives"
|
||||
).is_true()
|
||||
|
||||
# Swap in the counting spy AFTER entry (so entry's own queue_redraw()
|
||||
# calls don't pollute the baseline) but the OLD overlay is freed and the
|
||||
# spy re-added under the same _canvas parent, matching _ready()'s own
|
||||
# construction shape exactly.
|
||||
var spy := _CountingOverlay.new()
|
||||
spy.viewer = v
|
||||
v._overlay_node.queue_free()
|
||||
v._overlay_node = spy
|
||||
v._canvas.add_child(spy)
|
||||
|
||||
await get_tree().process_frame # let this frame settle with the spy in place
|
||||
await get_tree().process_frame
|
||||
var baseline: int = spy.draw_count
|
||||
assert_int(baseline).override_failure_message(
|
||||
"sanity: the spy must have been drawn at least once before the no-input"
|
||||
+ " frame below, or this test can't distinguish self-heal from a first draw"
|
||||
).is_greater(0)
|
||||
|
||||
# No pan/zoom/gesture — the ONLY thing that should cause another _draw()
|
||||
# is _process()'s own self-heal, since has_pending_tiles() is still true
|
||||
# (no response has been delivered).
|
||||
v._process(0.016)
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_int(spy.draw_count).override_failure_message(
|
||||
"_process() must queue a redraw every frame while has_pending_tiles()"
|
||||
+ " is true, with NO input/gesture — draw_count must have advanced past"
|
||||
+ " the baseline (%d), the cold-start self-heal" % baseline
|
||||
).is_greater(baseline)
|
||||
|
||||
|
||||
## The self-heal must STOP once every tile has arrived — a redraw queued
|
||||
## forever regardless of state would just be a disguised always-redraw, not
|
||||
## a targeted fix for the pending window.
|
||||
func test_process_stops_self_healing_once_every_tile_has_arrived() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
var tile_set = v.get_tile_set()
|
||||
for tile: Dictionary in tile_set.get_tiles():
|
||||
var window: Dictionary = _mock_window(tile["center"])
|
||||
window["granularity_v2"] = "Region"
|
||||
window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL
|
||||
window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(tile_set.has_pending_tiles()).override_failure_message(
|
||||
"sanity: every tile must have arrived after this loop"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUG 2 — legend stacking (the viewer-level half; see test_regional_screen.gd
|
||||
# for the nav-layer re-entry guard).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Coordinator's live scene dump: WindowLegend measured 260x2343 px, ~10
|
||||
## legends stacked — ImplantPanel.clear() used deferred queue_free(), so
|
||||
## same-frame repeat refresh() calls piled new content onto STALE not-yet-
|
||||
## freed children instead of replacing them. N refresh() calls in the SAME
|
||||
## frame (no process_frame between them, matching how the actual trigger —
|
||||
## RegionalScreen.enter() previously lacking its own re-entry guard — landed
|
||||
## repeat enter_orbital() calls back to back) must leave exactly ONE legend's
|
||||
## worth of children, not N stacked copies.
|
||||
func test_legend_refresh_is_idempotent_against_same_frame_re_entry() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
var baseline_count: int = v._legend_panel.get_implant_children().size()
|
||||
for _i in range(10):
|
||||
v._legend_panel.refresh()
|
||||
var after_count: int = v._legend_panel.get_implant_children().size()
|
||||
|
||||
assert_int(after_count).override_failure_message(
|
||||
(
|
||||
"10 same-frame refresh() calls must leave exactly ONE legend's worth of"
|
||||
+ " children (%d), not %d stacked copies — ImplantPanel.clear() must"
|
||||
+ " free immediately, not defer via queue_free()"
|
||||
) % [baseline_count, after_count]
|
||||
).is_equal(baseline_count)
|
||||
|
||||
|
||||
## PR #192 cold-start round 2: the children-count fix above is necessary but
|
||||
## not sufficient — the coordinator's live scene dump showed the CONTAINER
|
||||
## itself measured 260x2343px even after that fix landed. Root cause turned
|
||||
## out to be BROADER than "only after stacking": this panel is manually
|
||||
## positioned under AtlasWindowViewer (not inside a parent Container), so
|
||||
## `size` NEVER tracks a shrinking `get_minimum_size()` on its own at
|
||||
## all — confirmed directly (instrumented and reverted) that even a
|
||||
## completely FRESH, never-refreshed-twice legend shows `size` frozen at
|
||||
## whatever it happened to be on its very first measurement, while
|
||||
## get_minimum_size() reports the correct value the whole time. The
|
||||
## regression here therefore compares `size.y` against the RELIABLE ground
|
||||
## truth (`get_minimum_size().y`, confirmed correct in every trace) rather
|
||||
## than an earlier `size.y` snapshot — comparing size-to-size would pass
|
||||
## trivially if BOTH numbers were equally stuck at the same stale value,
|
||||
## which is exactly what silently happened during earlier drafts of this
|
||||
## test. Reproduces the stacking shape (N same-frame refresh() calls) for
|
||||
## realism, matching the coordinator's own trigger — the coordinator's
|
||||
## acceptance bar: "after N refreshes the panel rect height must be within
|
||||
## one legend's height" (of the CORRECT single-legend height, i.e. the
|
||||
## settled minimum size).
|
||||
func test_legend_panel_shrinks_back_after_a_stacking_window() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
|
||||
# Reproduce the stacking window directly (same-frame repeat refresh()
|
||||
# calls, matching the pre-fix trigger shape) — this drives the panel's
|
||||
# minimum size up the same way N repeat enter_orbital() calls did live.
|
||||
for _i in range(10):
|
||||
v._legend_panel.refresh()
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
# A further, ordinary refresh() (the kind every real rung change already
|
||||
# triggers) must leave the panel within one legend's height.
|
||||
v._legend_panel.refresh()
|
||||
# reset_to_content_size() is deferred (see its own doc — get_minimum_size()
|
||||
# is momentarily wrong for RichTextLabel.fit_content children until the
|
||||
# panel's real width has been laid out once) — a real frame must elapse
|
||||
# for the deferred reset_size() call to actually run.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
var one_legend_height: float = v._legend_panel.get_minimum_size().y
|
||||
assert_float(one_legend_height).override_failure_message(
|
||||
"sanity: a single settled legend must have a real, non-zero measured minimum height"
|
||||
).is_greater(0.0)
|
||||
assert_float(v._legend_panel.size.y).override_failure_message(
|
||||
(
|
||||
"after a stacking window, the legend panel's rect height (%.1f) must"
|
||||
+ " shrink back to within one legend's height (%.1f, the panel's own"
|
||||
+ " correctly-settled get_minimum_size()) — reset_to_content_size()"
|
||||
+ " must actually collapse the Control back down, not just hold onto"
|
||||
+ " its previously-grown size"
|
||||
) % [v._legend_panel.size.y, one_legend_height]
|
||||
).is_less_equal(one_legend_height + 1.0) # +1.0: float rounding slack
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUG 3 (round 2) — "DERIVING TERRAIN…" label: the subtle per-tile
|
||||
# COLOR_BORDER_FADE wash alone was invisible in a live cold capture. The
|
||||
# viewer draws an unmistakable centered label while ZERO tiles have arrived,
|
||||
# dropping it the instant even one lands.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The viewer must show the label exactly while is_tile_mode() is true AND
|
||||
## has_any_tile_arrived() is false — the coordinator's "ZERO tiles have
|
||||
## arrived" trigger condition, pinned directly against real tile-set state
|
||||
## (not a mock) via a real enter_orbital() on a tiling body. Also exercises
|
||||
## _draw()'s ACTUAL dispatch to _draw_deriving_terrain_label() through a
|
||||
## real frame (queue_redraw() + await process_frame, matching the
|
||||
## _CountingOverlay spy pattern from the BUG 1 self-heal tests above) —
|
||||
## proving the draw call itself is reachable and doesn't error, not just
|
||||
## that the underlying predicate is correct.
|
||||
func test_deriving_terrain_label_condition_true_before_any_tile_arrives() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
assert_bool(v.is_tile_mode()).is_true()
|
||||
assert_bool(v._tile_set.has_any_tile_arrived()).override_failure_message(
|
||||
"sanity: entry must leave every tile unarrived before any response arrives"
|
||||
).is_false()
|
||||
v.queue_redraw()
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
## The instant even ONE tile lands, the label's own gate condition must flip
|
||||
## off — per-tile washes alone are the right treatment once real content is
|
||||
## visibly filling in (coordinator: "dropping to per-tile washes once the
|
||||
## first tile lands").
|
||||
func test_deriving_terrain_label_condition_false_after_one_tile_arrives() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
var radius_km := 6238.4 # GJ380c (Lendel)
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {})
|
||||
var tile_set = v.get_tile_set()
|
||||
var first_tile: Dictionary = tile_set.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(first_tile["center"])
|
||||
window["granularity_v2"] = "Region"
|
||||
window["granularity"] = SERVER_LEGACY_GRANULARITY_REGION_SENTINEL
|
||||
window["n"] = AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_bool(tile_set.has_any_tile_arrived()).override_failure_message(
|
||||
"the label's own gate condition (NOT has_any_tile_arrived()) must flip"
|
||||
+ " false the instant a single tile lands, dropping the label"
|
||||
).is_true()
|
||||
|
||||
|
||||
## Single-window mode (District/Quarter/small-body Region, not tile mode)
|
||||
## never shows this label at all — it's a mosaic-specific cue for the
|
||||
## "whole orbital rest state is still deriving" case, not every wait state
|
||||
## (the single-window path already has its own COLOR_BORDER_FADE treatment,
|
||||
## unchanged by this round).
|
||||
func test_deriving_terrain_label_never_applies_outside_tile_mode() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2)
|
||||
assert_bool(v.is_tile_mode()).override_failure_message(
|
||||
"sanity: a District-rung enter() must never be tile mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
## centered_label_baseline() pure geometry: the X component is the
|
||||
## VIEWPORT-CENTERED text block's left-anchor-adjusted X (viewport center
|
||||
## minus half the text width — draw_string() itself does the final
|
||||
## horizontal centering from there via HORIZONTAL_ALIGNMENT_CENTER, this
|
||||
## only sets up where that alignment measures from); the Y component sits
|
||||
## at viewport-center (a draw_string() baseline is the text's OWN vertical
|
||||
## center here, by construction: center.y - text.y/2 + text.y/2 == center.y).
|
||||
func test_centered_label_baseline_centers_a_symmetric_case() -> void:
|
||||
var viewport_size := Vector2(1000.0, 800.0)
|
||||
var text_size := Vector2(200.0, 40.0)
|
||||
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(viewport_size, text_size)
|
||||
assert_that(baseline).is_equal(Vector2(400.0, 400.0))
|
||||
|
||||
|
||||
## A zero-size viewport (never laid out yet) must not crash — degenerate
|
||||
## input, not a real scenario, but the function must stay total.
|
||||
func test_centered_label_baseline_zero_viewport_does_not_crash() -> void:
|
||||
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(
|
||||
Vector2.ZERO, Vector2(100.0, 20.0)
|
||||
)
|
||||
assert_that(baseline).is_equal(Vector2(-50.0, 0.0))
|
||||
@@ -89,6 +89,60 @@ func test_attach_port_whitespace_env_falls_back_to_default() -> void:
|
||||
assert_that(s._attach_port(" ")).is_equal(9876)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _server_binary_path — SR_SERVER_BIN env override, else the debug build path
|
||||
# (same two-tier shape as _attach_port's SR_PORT). PR #192 cold-start round 2:
|
||||
# `make atlas` now builds RELEASE and passes SR_SERVER_BIN so a spawned cold
|
||||
# server's first AnalyzeBody is sub-second instead of a debug build's
|
||||
# multi-second derivation.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_server_binary_path_unset_env_falls_back_to_debug_path() -> void:
|
||||
var s = _script()
|
||||
assert_that(s._server_binary_path("", "/project")).is_equal(
|
||||
"/project/../server/target/debug/settled-reach-server"
|
||||
)
|
||||
|
||||
|
||||
func test_server_binary_path_relative_env_is_joined_to_project_root() -> void:
|
||||
# The exact shape make atlas's own SR_SERVER_BIN value takes: relative to
|
||||
# the repo root, not to client/'s res:// tree — matching the Makefile's
|
||||
# own "server/target/release/settled-reach-server" string.
|
||||
var s = _script()
|
||||
assert_that(
|
||||
s._server_binary_path("server/target/release/settled-reach-server", "/project")
|
||||
).is_equal("/project/../server/target/release/settled-reach-server")
|
||||
|
||||
|
||||
func test_server_binary_path_absolute_env_is_used_verbatim() -> void:
|
||||
var s = _script()
|
||||
assert_that(s._server_binary_path("/opt/custom/settled-reach-server", "/project")).is_equal(
|
||||
"/opt/custom/settled-reach-server"
|
||||
)
|
||||
|
||||
|
||||
## The acceptance shape the coordinator asked for verbatim: "env set -> that
|
||||
## path used; unset -> debug path unchanged" — via the REAL OS.get_environment
|
||||
## read (zero override_env arg), not the injectable param the tests above use
|
||||
## for isolation. OS.set_environment() is the standard gdUnit4-safe way to
|
||||
## drive a real env var for the duration of one test without touching the
|
||||
## actual process environment permanently.
|
||||
func test_server_binary_path_real_env_set_overrides_debug_path() -> void:
|
||||
var s = _script()
|
||||
OS.set_environment("SR_SERVER_BIN", "server/target/release/settled-reach-server")
|
||||
var result: String = s._server_binary_path("", "/project")
|
||||
OS.set_environment("SR_SERVER_BIN", "")
|
||||
assert_that(result).is_equal("/project/../server/target/release/settled-reach-server")
|
||||
|
||||
|
||||
func test_server_binary_path_real_env_unset_leaves_debug_path_unchanged() -> void:
|
||||
var s = _script()
|
||||
OS.set_environment("SR_SERVER_BIN", "")
|
||||
var result: String = s._server_binary_path("", "/project")
|
||||
assert_that(result).is_equal("/project/../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _parse_listening_line — main.rs's "LISTENING:{port}" stdout signal
|
||||
# (server/src/main.rs) parsed to an int, or -1 if the line doesn't match.
|
||||
|
||||
@@ -87,6 +87,12 @@ class _SingleWindowViewerStub:
|
||||
## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly:
|
||||
## Array of {"center": Vector2i, "window": Variant}).
|
||||
class _TileModeViewerStub:
|
||||
# PR #192 cold-start dossier: AtlasWindowOverlay reads viewer.COLOR_BORDER_FADE
|
||||
# directly for a pending tile's wash (avoids a cyclic preload of the
|
||||
# viewer's own script — see that read site's own doc) — mirrored here
|
||||
# byte-for-byte (AtlasWindowViewer.COLOR_BORDER_FADE, private const).
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
|
||||
var tiles: Array = []
|
||||
var held_center: Vector2i = Vector2i.ZERO
|
||||
var held_n: int = 0
|
||||
@@ -357,3 +363,45 @@ func test_tile_mosaic_draw_produces_visible_pixels() -> void:
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
|
||||
|
||||
## (c) PR #192 cold-start dossier, BUG 3: a mosaic tile with NO window yet
|
||||
## (the cold-server "still working" state, every tile in the mosaic at once
|
||||
## right after enter_orbital() on a real cold server) must render the SAME
|
||||
## border-fade wash the single-window path already gives its own
|
||||
## no-composite-yet wait — not a bare COLOR_BG gap that reads as broken.
|
||||
## Same real-render infrastructure as (a)/(b): a pending tile (`window: null`)
|
||||
## must still produce visible non-background pixels, proving the wash
|
||||
## genuinely draws rather than the loop just `continue`-ing past it silently.
|
||||
func test_pending_tile_gets_a_visible_border_fade_wash() -> void:
|
||||
if _dummy_renderer_active():
|
||||
print(SKIP_REASON)
|
||||
return
|
||||
var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new()
|
||||
var stub := _TileModeViewerStub.new()
|
||||
var tile_n: int = AtlasWindowGeometry.TILE_N
|
||||
var cell_px: float = stub.get_cell_pixel_size()
|
||||
stub.held_center = Vector2i.ZERO
|
||||
stub.held_n = 19139 # GJ380c/Lendel's own raw circumference (live round 4)
|
||||
# Same centered-tile placement as (b) above, but with `window: null` —
|
||||
# the pending state this fix targets, instead of a real response.
|
||||
var half_tile: float = float(tile_n) * 0.5
|
||||
var half_body: float = float(stub.held_n) * 0.5
|
||||
var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body))
|
||||
stub.tiles = [{"center": lone_tile_center, "window": null}]
|
||||
overlay.viewer = stub
|
||||
|
||||
var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px)
|
||||
var image: Image = await _render_to_image(overlay, zoom)
|
||||
var fraction: float = _non_background_fraction(image)
|
||||
|
||||
assert_float(fraction).override_failure_message(
|
||||
(
|
||||
"a pending tile (window == null) must still render a visible border-fade"
|
||||
+ " wash — got only %.2f%% of the frame differing from COLOR_BG, meaning"
|
||||
+ " the tile is a bare background gap during the cold-server wait, which"
|
||||
+ " reads as broken rather than 'still working' (coordinator's closing"
|
||||
+ " question, PR #192 cold-start dossier)."
|
||||
)
|
||||
% (fraction * 100.0)
|
||||
).is_greater(MIN_NON_BACKGROUND_FRACTION)
|
||||
|
||||
@@ -57,6 +57,27 @@ static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's
|
||||
## first-ever response (server/src/atlas/layer_proxy.rs's
|
||||
## get_or_generate()/serve_district_window(), whole-body cache MISS branch —
|
||||
## `AtlasLayerResponse { status: Pending, district_window: None, ... }`,
|
||||
## confirmed directly against that source). `body_id` is the only
|
||||
## identifying field.
|
||||
static func _pending_response(body_id: String) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Pending", "district_window": null}
|
||||
|
||||
|
||||
static func _not_found_response(body_id: String) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "NotFound", "district_window": null}
|
||||
|
||||
|
||||
## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded
|
||||
## `AtlasLayerStatus::Error(String)` variant's status STRING is always just
|
||||
## "Error" (the message rides in a separate `error` field).
|
||||
static func _error_response(body_id: String, message: String = "boom") -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Error", "error": message, "district_window": null}
|
||||
|
||||
|
||||
func _make_request() -> Variant:
|
||||
var owner_stub := RefCounted.new()
|
||||
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
||||
@@ -454,3 +475,178 @@ func test_clamp_window_n_mirror_v2_region_per_axis_cap_alone_satisfies_wire_cap_
|
||||
func test_cell_grid_side_region_mirror_minimum_is_one() -> void:
|
||||
assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(1)).is_equal(1)
|
||||
assert_int(AtlasWindowRequest._cell_grid_side_region_mirror(50)).is_equal(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PR #192 cold-start round 3 — the single-window half of the launch-shape
|
||||
# gap: the SAME status-gate bug the tile fan-out has (test_atlas_window_tile_set.gd's
|
||||
# own regressions) applies equally here, since on_response() is the shared
|
||||
# class both paths use. A first descent onto a cold body with NO tiles
|
||||
# (District/Quarter rung, or a small Region body) hits the identical
|
||||
# whole-body-cache-miss -> status:"Pending" wire shape.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The exact bug, single-window shape: a whole-response Pending on a cold
|
||||
## first request must increment the retry counter and schedule a re-poll —
|
||||
## not be silently dropped by the OLD `status != "Ready" -> return` gate.
|
||||
func test_cold_request_whole_response_pending_increments_retry_count() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
req.on_response(_pending_response("GJ380c"))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a whole-response Pending must leave the request still pending, not"
|
||||
+ " silently give up"
|
||||
).is_true()
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"a whole-response Pending must increment the retry counter — the"
|
||||
+ " exact bug: the OLD status-gate dropped this before ever reaching"
|
||||
+ " the retry-scheduling code, leaving retries at 0 forever"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
## Full convergence: a whole-response Pending, then a real Ready for the
|
||||
## RE-REQUEST, must be accepted — proving the retry loop's own re-request
|
||||
## actually gets picked up, not just that the counter increments. The
|
||||
## mid-test retries==1 assertion is what makes this genuinely load-bearing:
|
||||
## without it, a Ready delivered ANY time after a Pending (retried or not)
|
||||
## trivially passes this test's final assertion, since accepting a fresh
|
||||
## Ready response was never the broken behavior — only the retry itself was.
|
||||
func test_cold_request_converges_after_pending_then_real_ready() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
|
||||
req.on_response(_pending_response("GJ380c"))
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"sanity: the retry must have actually been scheduled before this test"
|
||||
+ " waits for it to fire — otherwise the final assertion below would"
|
||||
+ " pass even if the retry never happened at all"
|
||||
).is_equal(1)
|
||||
|
||||
await get_tree().create_timer(0.6).timeout # past the first retry delay
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(2, 2), 2)
|
||||
req.on_response(_mock_response("GJ380c", window))
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a real Ready response after the pending/retry cycle must be accepted"
|
||||
).is_false()
|
||||
|
||||
|
||||
## NotFound must give up immediately, not retry.
|
||||
func test_cold_request_not_found_gives_up_immediately_without_retry() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
|
||||
req.on_response(_not_found_response("GJ380c"))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"NotFound must give up immediately, not stay pending waiting for a retry"
|
||||
).is_false()
|
||||
assert_int(req._retries).is_equal(0)
|
||||
|
||||
|
||||
## Error must give up immediately too.
|
||||
func test_cold_request_error_gives_up_immediately_without_retry() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
|
||||
req.on_response(_error_response("GJ380c"))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"Error must give up immediately, not stay pending waiting for a retry"
|
||||
).is_false()
|
||||
assert_int(req._retries).is_equal(0)
|
||||
|
||||
|
||||
## PR #193 review (Hoshe): a whole-response Pending for a DIFFERENT body
|
||||
## must not touch this request's retry state — the body_id guard runs
|
||||
## BEFORE the status branch in on_response(), so someone else's cold-body
|
||||
## pending can never burn one of OUR 30 retries (or reschedule our timer).
|
||||
## Structurally guaranteed by guard ordering today; this test pins the
|
||||
## ordering, because a refactor that moves the status branch first would
|
||||
## silently cross-wire every concurrent cold descent (multi-body Atlas
|
||||
## browsing, or the orbital tile fan-out where all requests share one
|
||||
## broadcast signal). The final Ready-for-OUR-body assertion proves the
|
||||
## request is genuinely unaffected, not just un-retried.
|
||||
func test_pending_for_a_different_body_does_not_touch_retry_state() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
req.on_response(_pending_response("OtherBody"))
|
||||
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"a Pending for a DIFFERENT body must not increment OUR retry counter"
|
||||
+ " — the body_id guard must run before the status branch"
|
||||
).is_equal(0)
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a wrong-body Pending must leave the request still pending its own"
|
||||
+ " response, neither given up nor retried"
|
||||
).is_true()
|
||||
|
||||
var window: Dictionary = _mock_window(Vector2i(2, 2), 2)
|
||||
req.on_response(_mock_response("GJ380c", window))
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"after ignoring a wrong-body Pending, our OWN Ready must still be"
|
||||
+ " accepted normally — the request state must be genuinely untouched"
|
||||
).is_false()
|
||||
assert_int(req._retries).is_equal(0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _retry_delay_for() — deterministic exponential backoff + per-tile stagger
|
||||
# (PR #192 cold-start round 3 hardening: 6 tiles retrying in perfect
|
||||
# lockstep on a whole-response Pending is a real request-pulse risk even
|
||||
# though it isn't what caused the starvation bug above).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_retry_delay_for_first_retry_is_the_initial_delay() -> void:
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx(
|
||||
AtlasWindowRequest.INITIAL_RETRY_DELAY, 0.0001
|
||||
)
|
||||
|
||||
|
||||
func test_retry_delay_for_doubles_each_retry_until_the_cap() -> void:
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(1, 0)).is_equal_approx(0.5, 0.0001)
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(2, 0)).is_equal_approx(1.0, 0.0001)
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(3, 0)).is_equal_approx(2.0, 0.0001)
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(4, 0)).is_equal_approx(4.0, 0.0001)
|
||||
# Retry 5 would double past MAX_RETRY_DELAY (8.0) — must clamp, not keep growing.
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(5, 0)).is_equal_approx(
|
||||
AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001
|
||||
)
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(20, 0)).is_equal_approx(
|
||||
AtlasWindowRequest.MAX_RETRY_DELAY, 0.0001
|
||||
)
|
||||
|
||||
|
||||
## The deterministic stagger: tile index i's delay is offset by
|
||||
## STAGGER_STEP*i on top of the same backoff schedule — directly assertable,
|
||||
## not a randomized jitter a test would have to tolerance-check.
|
||||
func test_retry_delay_for_staggers_deterministically_by_tile_index() -> void:
|
||||
var base: float = AtlasWindowRequest._retry_delay_for(1, 0)
|
||||
for i in range(6):
|
||||
var expected: float = base + AtlasWindowRequest.STAGGER_STEP * float(i)
|
||||
assert_float(AtlasWindowRequest._retry_delay_for(1, i)).override_failure_message(
|
||||
"tile index %d's first-retry delay must be exactly base + STAGGER_STEP*%d" % [i, i]
|
||||
).is_equal_approx(expected, 0.0001)
|
||||
|
||||
|
||||
## Six tiles that all went pending in the same frame must NOT all retry at
|
||||
## the exact same instant — the anti-storm property this hardening exists
|
||||
## for, pinned directly: every tile's delay for the SAME retry count must be
|
||||
## strictly increasing with its stagger index.
|
||||
func test_retry_delay_for_six_tiles_never_collide_on_the_same_retry() -> void:
|
||||
var delays: Array = []
|
||||
for i in range(6):
|
||||
delays.append(AtlasWindowRequest._retry_delay_for(1, i))
|
||||
for i in range(1, delays.size()):
|
||||
assert_float(delays[i]).override_failure_message(
|
||||
"tile %d's delay must be strictly greater than tile %d's — a storm"
|
||||
+ " pulse means two tiles retrying at the same instant" % [i, i - 1]
|
||||
).is_greater(delays[i - 1])
|
||||
|
||||
@@ -30,6 +30,29 @@ static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
## PR #192 cold-start round 3: the WIRE-ACCURATE shape of a cold body's
|
||||
## first-ever response — server/src/atlas/layer_proxy.rs's
|
||||
## get_or_generate()/serve_district_window() on a whole-body cache MISS
|
||||
## builds `AtlasLayerResponse { status: Pending, district_window: None, ... }`
|
||||
## (confirmed directly against that source). `body_id` is the ONLY
|
||||
## identifying field — no center/n/granularity anywhere, matching the real
|
||||
## wire's total lack of per-request attribution on this specific shape.
|
||||
static func _pending_response(body_id: String) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Pending", "district_window": null}
|
||||
|
||||
|
||||
static func _not_found_response(body_id: String) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "NotFound", "district_window": null}
|
||||
|
||||
|
||||
## Matches atlas_map_protocol.gd's `_decode_status_field()` — the decoded
|
||||
## `AtlasLayerStatus::Error(String)` variant's status STRING is always just
|
||||
## "Error" (the message rides in a separate `error` field, not appended to
|
||||
## the status string), confirmed against that decoder directly.
|
||||
static func _error_response(body_id: String, message: String = "boom") -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Error", "error": message, "district_window": null}
|
||||
|
||||
|
||||
func _make_tile_set() -> Variant:
|
||||
var owner_stub := RefCounted.new()
|
||||
var ts = auto_free(AtlasWindowTileSet.new(owner_stub))
|
||||
@@ -52,6 +75,23 @@ func test_enter_produces_the_expected_tile_count_for_lendel() -> void:
|
||||
assert_bool(ts.is_multi_tile()).is_true()
|
||||
|
||||
|
||||
## PR #192 cold-start round 3 hardening: each tile's own AtlasWindowRequest
|
||||
## must get a DISTINCT, index-matching `_stagger_index` — the anti-storm
|
||||
## property (deterministic retry-delay stagger, see
|
||||
## AtlasWindowRequest._retry_delay_for()'s own doc) depends entirely on this
|
||||
## wiring; without it every tile silently staggers at index 0 and retries
|
||||
## in lockstep again, exactly the storm risk this hardening exists to close.
|
||||
func test_enter_wires_a_distinct_stagger_index_per_tile() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
for i in range(ts._tiles.size()):
|
||||
var req = ts._tiles[i]["request"]
|
||||
assert_int(req._stagger_index).override_failure_message(
|
||||
"tile %d's request must be wired with _stagger_index=%d, matching"
|
||||
+ " its own position in the tile set" % [i, i]
|
||||
).is_equal(i)
|
||||
|
||||
|
||||
## A tiny (non-tiling) body produces exactly ONE tile — the degenerate case
|
||||
## compute_tile_grid() itself already covers; this confirms the ORCHESTRATION
|
||||
## (not just the grid math) handles it without crashing or requesting zero
|
||||
@@ -142,6 +182,196 @@ func test_all_tiles_arriving_flips_fully_arrived() -> void:
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PR #192 cold-start round 3 — THE launch-shape regression: a cold body's
|
||||
# FIRST-EVER response is a wire-accurate whole-response Pending (status
|
||||
# "Pending", district_window null, body_id only — no center/n/granularity
|
||||
# anywhere, confirmed against server/src/atlas/layer_proxy.rs directly).
|
||||
# Driven through the REAL fan-out (SimBridge.atlas_layers_received.emit(),
|
||||
# reaching every tile via tile_set._on_atlas_layers_received), not a direct
|
||||
# tile.on_response() call — the coordinator's own regression-discipline ask,
|
||||
# since a direct per-tile call is implicit attribution a real wire fan-out
|
||||
# doesn't have. Before the status-gate fix, on_response()'s FIRST line
|
||||
# (`status != "Ready" -> return`) dropped this response for every tile
|
||||
# before ever reaching the retry-scheduling code — retries stayed at 0
|
||||
# forever, matching the coordinator's own live cold-server capture exactly.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The exact bug: a whole-response Pending on cold entry must increment
|
||||
## every tile's retry counter and schedule a re-poll — not be silently
|
||||
## dropped. Fails hard against the pre-fix code (retries stay 0 forever).
|
||||
func test_cold_entry_whole_response_pending_increments_every_tiles_retry_count() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
|
||||
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
||||
|
||||
for tile_dict: Dictionary in ts._tiles:
|
||||
var req = tile_dict["request"]
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"every tile must still be pending after a whole-response Pending"
|
||||
).is_true()
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"a whole-response Pending must increment the retry counter — the"
|
||||
+ " exact bug: the OLD status-gate silently dropped this before"
|
||||
+ " ever reaching the retry-scheduling code, leaving retries at 0 forever"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
## The full convergence: repeated whole-response Pendings (simulating a slow
|
||||
## cold AnalyzeBody), THEN real per-tile Ready responses for the tiles' own
|
||||
## RE-REQUESTS — every tile must eventually fill. No re-request means this
|
||||
## hangs (waiting past the retry delay for a re-poll that never happens) or
|
||||
## fails (has_pending_tiles() never flips false) — exactly the launch-shape
|
||||
## gap the coordinator named: "every unit test delivered Ready immediately."
|
||||
func test_cold_entry_converges_after_repeated_pending_then_real_readies() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var tiles: Array = ts.get_tiles()
|
||||
|
||||
# Two consecutive whole-response Pendings — a slow cold derive, not a
|
||||
# single flip. Real waits so the scheduled retry timers can actually fire.
|
||||
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
||||
await get_tree().create_timer(0.7).timeout # past the first backoff delay
|
||||
SimBridge.atlas_layers_received.emit(_pending_response("GJ380c"))
|
||||
await get_tree().create_timer(0.8).timeout # past the second (staggered) delay
|
||||
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"sanity: still pending after 2 cold cycles"
|
||||
).is_true()
|
||||
for tile_dict: Dictionary in ts._tiles:
|
||||
var req = tile_dict["request"]
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"sanity: each tile's retry counter must have actually incremented"
|
||||
+ " twice — otherwise the final assertion below would pass even if"
|
||||
+ " the retries never happened at all (a fresh Ready was never the"
|
||||
+ " broken behavior, only the retry itself was)"
|
||||
).is_equal(2)
|
||||
|
||||
for tile: Dictionary in tiles:
|
||||
var window: Dictionary = _mock_window(
|
||||
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"after the retries fire and real Readies land, every tile must have adopted its window"
|
||||
).is_false()
|
||||
assert_bool(ts.is_fully_arrived()).is_true()
|
||||
|
||||
|
||||
## NotFound must give up IMMEDIATELY, not retry — a body that doesn't exist
|
||||
## will never resolve by waiting (the coordinator's own "give up on error
|
||||
## responses, not elapsed patience" framing — this replaces the old
|
||||
## elapsed-retries-only give-up policy with a correctness-based one for the
|
||||
## cases where retrying is provably pointless).
|
||||
func test_cold_entry_not_found_gives_up_immediately_without_retry() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
|
||||
SimBridge.atlas_layers_received.emit(_not_found_response("GJ380c"))
|
||||
|
||||
for tile_dict: Dictionary in ts._tiles:
|
||||
var req = tile_dict["request"]
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"NotFound must give up immediately, not stay pending waiting for a retry"
|
||||
).is_false()
|
||||
assert_int(req._retries).override_failure_message(
|
||||
"NotFound must never increment the retry counter — retrying a"
|
||||
+ " nonexistent body is provably pointless"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
## Error must give up immediately too, same reasoning as NotFound — a
|
||||
## resolve/IO failure won't resolve itself by polling.
|
||||
func test_cold_entry_error_gives_up_immediately_without_retry() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
|
||||
SimBridge.atlas_layers_received.emit(_error_response("GJ380c"))
|
||||
|
||||
for tile_dict: Dictionary in ts._tiles:
|
||||
var req = tile_dict["request"]
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"Error must give up immediately, not stay pending waiting for a retry"
|
||||
).is_false()
|
||||
assert_int(req._retries).is_equal(0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# has_pending_tiles() / has_any_tile_arrived() — PR #192 cold-start dossier.
|
||||
# Distinct predicates (both can be true at once, mid-arrival): the viewer's
|
||||
# self-healing redraw (BUG 1) polls has_pending_tiles(); the "DERIVING
|
||||
# TERRAIN…" label (BUG 3) polls has_any_tile_arrived() to know when to drop.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_has_pending_tiles_true_immediately_after_enter() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"every tile is unarrived right after enter() — has_pending_tiles() must be true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_pending_tiles_false_once_every_tile_has_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
for tile: Dictionary in ts.get_tiles():
|
||||
var window: Dictionary = _mock_window(
|
||||
tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_pending_tiles()).is_false()
|
||||
|
||||
|
||||
func test_has_pending_tiles_true_while_only_some_tiles_have_arrived() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_tile: Dictionary = ts.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(
|
||||
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"5 of 6 tiles still unarrived — has_pending_tiles() must stay true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_any_tile_arrived_false_immediately_after_enter() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
||||
"nothing has arrived right after enter() — has_any_tile_arrived() must be false"
|
||||
).is_false()
|
||||
|
||||
|
||||
## The exact mid-arrival case both predicates must agree can coexist: one
|
||||
## tile in, five still pending — the point the "DERIVING TERRAIN…" label
|
||||
## must drop (has_any_tile_arrived() flips true) while the self-heal must
|
||||
## keep redrawing (has_pending_tiles() stays true).
|
||||
func test_has_any_tile_arrived_true_after_a_single_tile_lands() -> void:
|
||||
var ts = _make_tile_set()
|
||||
ts.enter("GJ380c", 6238.4)
|
||||
var first_tile: Dictionary = ts.get_tiles()[0]
|
||||
var window: Dictionary = _mock_window(
|
||||
first_tile["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION
|
||||
)
|
||||
SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window))
|
||||
assert_bool(ts.has_any_tile_arrived()).is_true()
|
||||
assert_bool(ts.has_pending_tiles()).override_failure_message(
|
||||
"sanity: the other 5 tiles are still pending at the same moment"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_has_any_tile_arrived_false_for_an_empty_tile_set() -> void:
|
||||
var ts = _make_tile_set()
|
||||
assert_bool(ts.has_any_tile_arrived()).override_failure_message(
|
||||
"an empty tile set (never entered) must not vacuously report arrival"
|
||||
).is_false()
|
||||
|
||||
|
||||
## A response for a body the tile set is NOT currently showing (a stale
|
||||
## response from a body the player has since navigated away from) must not
|
||||
## be adopted by any tile — the SAME body_id staleness guard every other
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
## PR #192 cold-start dossier (BUG 2): RegionalScreen.enter() tests. Split
|
||||
## into its own file rather than folded into test_atlas_zoom_ladder.gd —
|
||||
## these exercise the NAV-LAYER re-entry guard (RegionalScreen itself), not
|
||||
## AtlasWindowViewer's own zoom-ladder mechanics that suite already owns.
|
||||
class_name TestRegionalScreen
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
## Dudley's WINDOW_GRANULARITY_REGION_KEY sentinel — mirrors
|
||||
## test_atlas_zoom_ladder.gd's own constant (see that file's doc for why the
|
||||
## real wire value matters, not a convenient placeholder).
|
||||
const SERVER_LEGACY_GRANULARITY_REGION_SENTINEL: int = 4294967295
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
## BUG 2 root cause: ImplantApp._on_screen_changed() calls enter()
|
||||
## UNCONDITIONALLY on every screen_changed, including a repeat
|
||||
## nav.push("regional", ...) landing on the SAME screen already showing
|
||||
## (reachable from more than one input path — body-click, panel+Enter — and
|
||||
## plausible for a player to trigger twice on a slow cold server before the
|
||||
## first descent settles). Without RegionalScreen's own guard, a repeat
|
||||
## entry re-ran the FULL enter_orbital() teardown/rebuild — tearing down
|
||||
## every in-flight tile request node and rebuilding fresh (null-window) ones
|
||||
## — orphaning whatever had already arrived, plus refreshing the legend
|
||||
## from scratch each time (the confirmed ~10x legend stack). Proven here by
|
||||
## an ARRIVED tile's data: a teardown+rebuild resets it to null; a genuine
|
||||
## no-op leaves it exactly as it was — `is_same()` on `_tile_set` itself
|
||||
## can't tell (that Node is a fixed field, never reassigned — only its
|
||||
## INTERNAL request children get torn down and rebuilt).
|
||||
func test_repeat_enter_for_the_same_body_does_not_orphan_an_arrived_tile() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
var body: Dictionary = {"body_id": "GJ380c", "body_radius_km": 6238.4}
|
||||
screen.enter({"body": body, "system": {}})
|
||||
|
||||
var tile_set = screen._viewer.get_tile_set()
|
||||
var first_tile_center: Vector2i = tile_set.get_tiles()[0]["center"]
|
||||
var arrived_window: Dictionary = {
|
||||
"center": [first_tile_center.x, first_tile_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", arrived_window))
|
||||
assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message(
|
||||
"sanity: the first tile's response must have been adopted before the repeat enter()"
|
||||
).is_equal(arrived_window)
|
||||
|
||||
screen.enter({"body": body, "system": {}})
|
||||
|
||||
assert_that(screen._viewer.get_tile_set().get_tiles()[0]["window"]).override_failure_message(
|
||||
"a repeat enter() for the SAME body must not orphan an already-arrived"
|
||||
+ " tile — a teardown+rebuild resets every tile's window back to null,"
|
||||
+ " which is the confirmed source of the cold-start legend stacking bug"
|
||||
).is_equal(arrived_window)
|
||||
|
||||
|
||||
## A GENUINE body change (different body_id) must still enter fresh — the
|
||||
## guard is scoped to "the same body, re-entered", never a blanket "ignore
|
||||
## the second enter() call ever".
|
||||
func test_enter_for_a_different_body_still_re_enters() -> void:
|
||||
var screen: RegionalScreen = auto_free(RegionalScreen.new())
|
||||
add_child(screen)
|
||||
screen.enter({"body": {"body_id": "GJ380c", "body_radius_km": 6238.4}, "system": {}})
|
||||
|
||||
screen.enter({"body": {"body_id": "OtherBody", "body_radius_km": 100.0}, "system": {}})
|
||||
|
||||
assert_str(screen._viewer.get_body_id()).override_failure_message(
|
||||
"a genuinely different body must still re-enter — not be swallowed by"
|
||||
+ " the same-body guard"
|
||||
).is_equal("OtherBody")
|
||||
|
||||
|
||||
## get_body_id() itself: empty before any entry, the entered body's id after.
|
||||
func test_get_body_id_reflects_the_currently_held_body() -> void:
|
||||
var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new())
|
||||
add_child(v)
|
||||
assert_str(v.get_body_id()).is_equal("")
|
||||
v.enter_orbital({"body_id": "GJ380c", "body_radius_km": 6238.4}, {})
|
||||
assert_str(v.get_body_id()).is_equal("GJ380c")
|
||||
@@ -173,6 +173,7 @@ func refresh() -> void:
|
||||
clear()
|
||||
visible = not active_specs.is_empty()
|
||||
if active_specs.is_empty():
|
||||
reset_to_content_size()
|
||||
return
|
||||
|
||||
add_component(
|
||||
@@ -188,3 +189,4 @@ func refresh() -> void:
|
||||
if i < active_specs.size() - 1:
|
||||
add_component(ImplantSeparator.new())
|
||||
reposition()
|
||||
reset_to_content_size()
|
||||
|
||||
@@ -673,3 +673,16 @@ static func screen_header_content(
|
||||
var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0
|
||||
var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
|
||||
return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle}
|
||||
|
||||
|
||||
## Cold-start dossier: the baseline position draw_string() needs to render
|
||||
## `text` horizontally AND vertically centered inside `viewport_size` — pure
|
||||
## geometry, split out of AtlasWindowViewer._draw_deriving_terrain_label()
|
||||
## for file-length (gdlint max-file-lines), not a different concern. Callers
|
||||
## pass HORIZONTAL_ALIGNMENT_CENTER to draw_string() themselves (that part
|
||||
## isn't pure — it needs the real Font instance) — this only computes the Y
|
||||
## baseline offset and the X center point draw_string()'s own centering
|
||||
## then works from.
|
||||
static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2:
|
||||
var center: Vector2 = viewport_size * 0.5
|
||||
return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
|
||||
|
||||
@@ -90,6 +90,7 @@ func refresh() -> void:
|
||||
_add_toggle_section(active_id)
|
||||
|
||||
reposition()
|
||||
reset_to_content_size()
|
||||
|
||||
|
||||
func _add_morphology_section() -> void:
|
||||
|
||||
@@ -211,15 +211,6 @@ func _draw_tile_mosaic() -> void:
|
||||
for i in range(tiles.size()):
|
||||
var tile: Dictionary = tiles[i]
|
||||
var window: Variant = tile["window"]
|
||||
if not window is Dictionary:
|
||||
continue
|
||||
var w: Dictionary = window
|
||||
var morphology: Variant = w.get("morphology")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
continue
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
continue
|
||||
|
||||
var center: Vector2i = tile["center"]
|
||||
var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols)
|
||||
@@ -230,6 +221,27 @@ func _draw_tile_mosaic() -> void:
|
||||
tile_top_left, held_center, held_n, cell_px
|
||||
)
|
||||
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
|
||||
|
||||
# Coordinator ask (cold-start dossier): a bare COLOR_BG gap for an
|
||||
# unarrived tile reads as broken, not "still working" — a cold
|
||||
# server's first AnalyzeBody can take seconds, during which every
|
||||
# tile in the mosaic is exactly this state at once. Same treatment
|
||||
# the single-window path already gives its OWN no-composite-yet wait
|
||||
# (AtlasWindowViewer._draw_border_fade()) — read off the live
|
||||
# `viewer` instance rather than a preload of its script (that field
|
||||
# is deliberately untyped to avoid a cyclic ref, see its own doc; a
|
||||
# const is reachable through an instance either way).
|
||||
if not window is Dictionary:
|
||||
draw_rect(Rect2(local_origin, Vector2(extent, extent)), viewer.COLOR_BORDER_FADE)
|
||||
continue
|
||||
var w: Dictionary = window
|
||||
var morphology: Variant = w.get("morphology")
|
||||
if not (morphology is PackedByteArray or morphology is Array):
|
||||
continue
|
||||
var grid_side: int = cell_grid_side_for_window(w)
|
||||
if grid_side <= 0:
|
||||
continue
|
||||
|
||||
_draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle)
|
||||
|
||||
|
||||
|
||||
@@ -38,8 +38,34 @@ const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cach
|
||||
|
||||
const DISTRICT_WINDOW_DEFAULT_N: int = 32
|
||||
const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
|
||||
const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY
|
||||
const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES
|
||||
|
||||
## Cold-start dossier (PR #192 round 3): the retry-on-PENDING loop used to be
|
||||
## a flat RETRY_DELAY=0.5s / MAX_RETRIES=20 (~10s ceiling), copied verbatim
|
||||
## from atlas_generation_proxy.gd's Layer1 poll — a DIFFERENT, typically
|
||||
## faster derive. The real starvation bug turned out to be the status-gate
|
||||
## fix in on_response() (see that function's own doc) — this backoff/stagger
|
||||
## work is HARDENING landed alongside it, not the fix itself: once the
|
||||
## status-gate fix makes 6 independent tiles all correctly retry on a
|
||||
## whole-response Pending, they do so in perfect lockstep (all six went
|
||||
## pending at entry within the same frame, so all six retry timers fire
|
||||
## within the same frame too) — six re-requests every RETRY_DELAY, in sync,
|
||||
## is exactly the "storm" shape worth damping even though it isn't what
|
||||
## caused the starvation. Exponential backoff (INITIAL_RETRY_DELAY doubling
|
||||
## to MAX_RETRY_DELAY) plus a DETERMINISTIC per-tile stagger
|
||||
## (STAGGER_STEP * stagger_index, set once by the owning AtlasWindowTileSet
|
||||
## at construction — see `_stagger_index`) spread that pulse into a trickle:
|
||||
## tile 0 retries at 0.5s, tile 1 at 0.6s, tile 2 at 0.7s, etc. — deterministic
|
||||
## and directly assertable in a test, not a randomized jitter a test would
|
||||
## have to tolerance-check. Backoff ALSO buys a much longer wall-clock window
|
||||
## from a modest MAX_RETRIES increase (~110s at 30 retries, see
|
||||
## _retry_delay_for()'s own doc) without ever polling aggressively for that
|
||||
## whole span. A fast (already-warm) response still resolves on retry #1,
|
||||
## unaffected — backoff/stagger only matter once a request is genuinely
|
||||
## still pending past the first cycle.
|
||||
const INITIAL_RETRY_DELAY: float = 0.5 # first retry, matches the old flat RETRY_DELAY
|
||||
const MAX_RETRY_DELAY: float = 4.0 # backoff ceiling — never polls slower than this
|
||||
const STAGGER_STEP: float = 0.1 # per-tile-index offset — tile i retries STAGGER_STEP*i later
|
||||
const MAX_RETRIES: int = 30 # ~110s wall-clock at the backoff schedule above
|
||||
|
||||
## T-1150 struct/key plumbing: legacy int granularity — district is the
|
||||
## default for every caller that doesn't request quarter/Region explicitly.
|
||||
@@ -89,6 +115,12 @@ var _min_wl_m: int = DEFAULT_MIN_WL_M
|
||||
var _pending: bool = false
|
||||
var _retries: int = 0
|
||||
var _debounce_timer: Timer = null
|
||||
## Cold-start dossier round 3: deterministic per-request stagger index for
|
||||
## the retry backoff (see STAGGER_STEP's own doc) — 0 for the single-window
|
||||
## viewer's own request (no fan-out, nothing to desync from), the tile's own
|
||||
## index (0..5) for a tile-set-owned request (AtlasWindowTileSet.enter()
|
||||
## sets this once at construction, right after AtlasWindowRequest.new()).
|
||||
var _stagger_index: int = 0
|
||||
|
||||
|
||||
func _init(owner_ref = null) -> void:
|
||||
@@ -288,25 +320,44 @@ func _on_debounce_timeout() -> void:
|
||||
## real server, always) -> v2 is the ONLY granularity comparison; absent (a
|
||||
## hypothetically old, pre-T-1152 server) -> fall back to the legacy
|
||||
## comparison alone, matching this object's own pre-T-1152 behavior exactly.
|
||||
## PR #192 cold-start round 3: the coordinator's live cold-server capture
|
||||
## (retries=0, pending=true, forever) exposed that the OLD version of this
|
||||
## function returned unconditionally whenever the WHOLE response's status
|
||||
## wasn't "Ready" — treating a cold body's `status: "Pending"` (the FIRST
|
||||
## request against a whole-body cache miss, before ANY layer including the
|
||||
## window has even been queued — `serve_district_window`/`get_or_generate()`
|
||||
## in server/src/atlas/layer_proxy.rs) identically to `NotFound`/`Error`: a
|
||||
## silent no-op, never reaching the retry-scheduling code at all. Confirmed
|
||||
## server-side: `status: Ready` is set ONLY on the whole-body cache-HIT
|
||||
## branch, entirely independent of whether the WINDOW itself has resolved —
|
||||
## so a cold body's first-ever window request gets `Pending` at the OUTER
|
||||
## layer, while a body someone has already warmed (a later connection, or
|
||||
## this SAME connection's own re-request once its own AnalyzeBody has
|
||||
## landed) gets `Ready` with `district_window: null` inside it, correctly
|
||||
## reaching the retry branch below. Same "still generating" signal, two
|
||||
## different wire shapes depending on which cache warmed first — the fix is
|
||||
## to treat BOTH as the identical retry-worthy state, matching
|
||||
## atlas_generation_proxy.gd's own on_response() `match` shape exactly
|
||||
## (Ready -> handle, Pending -> retry, NotFound/Error -> give up now, not
|
||||
## after MAX_RETRIES: a real error is never going to resolve by waiting).
|
||||
func on_response(response: Dictionary) -> void:
|
||||
if str(response.get("body_id", "")) != _body_id:
|
||||
return
|
||||
if str(response.get("status", "")) != "Ready":
|
||||
return # Pending/NotFound/Error on the WHOLE response — not a window signal either way
|
||||
var status := str(response.get("status", ""))
|
||||
if status == "Pending":
|
||||
_retry_if_pending()
|
||||
return
|
||||
if status != "Ready":
|
||||
_pending = false # NotFound / Error — a real failure, not a queue wait; give up now
|
||||
return
|
||||
var window: Variant = response.get("district_window")
|
||||
if window == null:
|
||||
# §1: an as-yet-underived window rides as `district_window: None` inside
|
||||
# a Ready response — this is the "still generating" signal, not an
|
||||
# error. Re-poll until the background derive lands or the retry
|
||||
# ceiling is hit (queue-based serving, PR #185 — the response lands
|
||||
# on a LATER tick, never this same round-trip).
|
||||
if not _pending:
|
||||
return
|
||||
if _retries < MAX_RETRIES:
|
||||
_retries += 1
|
||||
_schedule_retry()
|
||||
else:
|
||||
_pending = false # gave up — caller's border-fade / empty state persists
|
||||
# §1: an as-yet-underived window rides as `district_window: None`
|
||||
# inside an OUTER-Ready response — the whole-body cache already
|
||||
# warmed, but this specific window hasn't derived yet. Same
|
||||
# "still generating" signal the outer-Pending branch above handles,
|
||||
# just the OTHER wire shape it can arrive in.
|
||||
_retry_if_pending()
|
||||
return
|
||||
|
||||
var w: Dictionary = window
|
||||
@@ -328,6 +379,21 @@ func on_response(response: Dictionary) -> void:
|
||||
window_ready.emit(w)
|
||||
|
||||
|
||||
## Shared "still generating, re-poll" logic for BOTH wire shapes on_response()
|
||||
## can see it in (outer status=="Pending", or inner district_window==null
|
||||
## inside an outer Ready) — re-request until the derive lands or the retry
|
||||
## ceiling is hit (queue-based serving, PR #185 — the response lands on a
|
||||
## LATER tick, never this same round-trip).
|
||||
func _retry_if_pending() -> void:
|
||||
if not _pending:
|
||||
return
|
||||
if _retries < MAX_RETRIES:
|
||||
_retries += 1
|
||||
_schedule_retry()
|
||||
else:
|
||||
_pending = false # gave up — caller's border-fade / empty state persists
|
||||
|
||||
|
||||
## The granularity half of on_response()'s staleness check, split out for the
|
||||
## v2-authoritative-when-present precedence rule (see on_response()'s own
|
||||
## doc for the full live-round rationale). Presence, not value, is the
|
||||
@@ -343,8 +409,23 @@ func _echoed_granularity_matches(w: Dictionary) -> bool:
|
||||
return echoed_granularity == _granularity
|
||||
|
||||
|
||||
## Pure: the exponential-backoff delay for retry attempt number `retry_count`
|
||||
## (1-indexed — the FIRST retry, right after the initial request's own
|
||||
## PENDING answer, uses `retry_count=1`), staggered by `stagger_index`
|
||||
## (STAGGER_STEP*stagger_index added on top — deterministic, not randomized,
|
||||
## so a test can assert the exact delay sequence for tile N directly). Split
|
||||
## out from _schedule_retry() as a pure function for the same reason every
|
||||
## other formula in this file is: directly unit-testable without a live
|
||||
## Timer/SceneTree.
|
||||
static func _retry_delay_for(retry_count: int, stagger_index: int) -> float:
|
||||
var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0)))
|
||||
var capped: float = minf(base, MAX_RETRY_DELAY)
|
||||
return capped + STAGGER_STEP * float(stagger_index)
|
||||
|
||||
|
||||
func _schedule_retry() -> void:
|
||||
var timer := get_tree().create_timer(RETRY_DELAY)
|
||||
var delay: float = _retry_delay_for(_retries, _stagger_index)
|
||||
var timer := get_tree().create_timer(delay)
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
if _pending:
|
||||
|
||||
@@ -97,6 +97,12 @@ func enter(body_id: String, body_radius_km: float) -> void:
|
||||
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(
|
||||
@@ -137,6 +143,37 @@ func get_tiles() -> Array:
|
||||
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
|
||||
|
||||
@@ -8,28 +8,25 @@ extends Control
|
||||
## rung) down to District/Quarter lives in ONE screen/Control, not a separate
|
||||
## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer
|
||||
## composite (or, at the orbital rest state on a large body, a MOSAIC of
|
||||
## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever
|
||||
## rung is currently held: morphology base layer lightness-modulated by
|
||||
## elev_q, three switchable climate/vegetation overlays, an always-on
|
||||
## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this
|
||||
## Control owns input, request orchestration, chrome, pan/zoom). One
|
||||
## colorizer family renders every rung unchanged (design doc §6).
|
||||
## several — see `_tile_mode`/AtlasWindowTileSet) at whichever rung is
|
||||
## currently held: morphology base lightness-modulated by elev_q, three
|
||||
## switchable climate/vegetation overlays, an always-on glaciation ice-tint
|
||||
## modifier (drawing is AtlasWindowOverlay's job — this Control owns input,
|
||||
## request orchestration, chrome, pan/zoom). One colorizer family renders
|
||||
## every rung unchanged (design doc §6).
|
||||
##
|
||||
## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153):
|
||||
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
|
||||
## = _canvas.scale.
|
||||
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but
|
||||
## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013): crossing a rung's
|
||||
## coverage ceiling (§5, AtlasWindowGeometry.select_rung()) fires a
|
||||
## background request for the new granularity while the OLD composite
|
||||
## keeps drawing — progressive refinement, no blank frame (§6). A pan
|
||||
## past the held window's edge re-requests the SAME rung at a new center.
|
||||
## = _canvas.scale. Zoom is client-side on the ALREADY-HELD composite
|
||||
## frame-to-frame, but CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013):
|
||||
## crossing a rung's coverage ceiling fires a background request for the
|
||||
## new granularity while the OLD composite keeps drawing — progressive
|
||||
## refinement, no blank frame (§6). A pan past the held window's edge
|
||||
## re-requests the SAME rung at a new center.
|
||||
## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD
|
||||
## condition, see _maybe_reset_to_canonical_frame()) — on a body needing
|
||||
## tiling, re-enters `_tile_mode` (live round 3, design doc §4).
|
||||
## - _window_request (atlas_window_request.gd) owns the single-window
|
||||
## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of
|
||||
## those for the tiled rest state — this Control decides WHICH is active.
|
||||
## condition) — on a body needing tiling, re-enters `_tile_mode` (§4).
|
||||
## - _window_request owns the single-window cache/debounce/retry; _tile_set
|
||||
## owns N of those for the tiled rest state — this Control decides WHICH.
|
||||
##
|
||||
## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click
|
||||
## semantics with future map objects, so it's removed entirely):
|
||||
@@ -45,42 +42,36 @@ 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): 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` is never itself clamped (would
|
||||
## silently show LESS than the whole body). 0.0005 covers a ~120,000 km-
|
||||
## radius body at a 3840px 4K viewport.
|
||||
## ACROSS RUNGS (D-013): crossing a rung's coverage ceiling 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 enter_orbital()'s
|
||||
## largest legal `n` COVER-fits without clamping — 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
|
||||
|
||||
## T-1145 item 2: WASD/arrow-key continuous pan speed, in CANVAS px/s at
|
||||
## zoom=1.0 — actual screen-space rate is this times CURRENT _view_zoom, so
|
||||
## panning covers the same TERRAIN per second regardless of zoom level.
|
||||
## ~6 districts/s at zoom=1.0 (96/16) — brisk, not a crawl.
|
||||
## T-1145 item 2: WASD/arrow-key pan speed, CANVAS px/s at zoom=1.0 — actual
|
||||
## screen-space rate scales by CURRENT _view_zoom, so panning covers the same
|
||||
## TERRAIN/s regardless of zoom. ~6 districts/s at zoom=1.0 (96/16) — brisk.
|
||||
const PAN_SPEED_CANVAS_PX_S: float = 96.0
|
||||
|
||||
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll —
|
||||
## Jeroen's own number ("~24px"). Uses the SAME speed as WASD (one pan feel,
|
||||
## two triggers) — no separate constant, _process() reads PAN_SPEED_CANVAS_PX_S for both.
|
||||
## T-1145 item 2: cursor-to-edge distance (px) that triggers edge-scroll
|
||||
## (Jeroen's "~24px"). Same speed as WASD, no separate constant.
|
||||
const EDGE_SCROLL_MARGIN_PX: float = 24.0
|
||||
|
||||
## Pixel size of one district cell at zoom=1.0 — a fixed on-screen scale
|
||||
## (unlike AtlasViewer's heightmap, there is no source texture dictating a
|
||||
## native pixel size; this constant IS the native size). 16px/cell at n=64
|
||||
## gives a ~1024px-wide composite before zoom, comfortably inside a
|
||||
## 1280x720+ viewport at the DEFAULT_N=32 interactive default (512px) too.
|
||||
## (no source texture dictates a native size; this constant IS it). 16px/cell
|
||||
## at n=64 gives a ~1024px composite before zoom, fits a 1280x720+ viewport.
|
||||
const CELL_PIXEL_SIZE: float = 16.0
|
||||
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
## Border-fade target (§5 "what renders during the wait"): the underlying
|
||||
## whole-body heightmap's own background tint, so the newly-exposed edge
|
||||
## reads as "real data seen through", not a placeholder block. Reuses
|
||||
## AtlasViewer's own COLOR_HEIGHTMAP_TINT-adjacent dim value — a dimmer/
|
||||
## less-certain read of the same planetary data, not a different visual language.
|
||||
## reads as "real data seen through", not a placeholder block. Also read by
|
||||
## AtlasWindowOverlay for a per-tile pending wash in the mosaic — same
|
||||
## "still working" cue, one color.
|
||||
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
|
||||
## T-1153/R6: pending-refinement wash — border-fade's referent repointed to
|
||||
@@ -89,6 +80,11 @@ const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
|
||||
## hue, lighter alpha — hints something sharper is arriving, not that the view is wrong.
|
||||
const COLOR_PENDING_REFINEMENT_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
|
||||
|
||||
## Cold-start dossier: the "DERIVING TERRAIN…" label's own text color —
|
||||
## default_implant.tres's text_dim (#667788), matching the implant chrome's
|
||||
## existing dim/secondary-text role rather than inventing a new hue.
|
||||
const COLOR_DERIVING_LABEL: Color = Color("#667788")
|
||||
|
||||
## D-243: 2,048 m per district side.
|
||||
const DISTRICT_M: float = 2048.0
|
||||
|
||||
@@ -138,38 +134,33 @@ var _held_center: Vector2i = Vector2i.ZERO
|
||||
var _held_n: int = 32
|
||||
## 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).
|
||||
## _window_request.get_granularity_v2() (most recently REQUESTED, may
|
||||
## already be in flight to a different rung). Defaults to District.
|
||||
var _held_granularity_v2: String = "District"
|
||||
|
||||
# ── Pan/zoom state ─────────────────────────────────────────────────────────
|
||||
var _view_offset: Vector2 = Vector2.ZERO
|
||||
var _view_zoom: float = 1.0
|
||||
# T-1145 item 2: last known LOCAL mouse position (this Control's coordinate
|
||||
# space), tracked from _gui_input's motion events for the edge-scroll check
|
||||
# in _process() — _process() has no InputEvent of its own to read a position
|
||||
# from, so the position has to be cached from the last motion event we DID
|
||||
# see. Starts at -ONE (an impossible in-bounds position) so edge-scroll never
|
||||
# fires before the mouse has ever moved over this Control at least once.
|
||||
# T-1145 item 2: last known LOCAL mouse position, tracked from _gui_input's
|
||||
# motion events for the edge-scroll check in _process() (which has no
|
||||
# InputEvent of its own to read a position from). Starts at -ONE (an
|
||||
# impossible in-bounds position) so edge-scroll never fires before the mouse
|
||||
# has ever moved over this Control at least once.
|
||||
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
|
||||
# T-1145 item 2: whether the OS application window currently has focus —
|
||||
# edge-scroll is suppressed while false (see _is_cursor_edge_scrolling()'s
|
||||
# doc). Defaults true: a freshly-entered screen assumes focus until told
|
||||
# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT (matches the game's own
|
||||
# window normally having focus when the player is actively navigating the
|
||||
# implant in the first place).
|
||||
# otherwise by NOTIFICATION_APPLICATION_FOCUS_OUT.
|
||||
var _app_has_focus: bool = true
|
||||
# T-1142/T-1145: true once the user has manually panned (WASD/edge-scroll,
|
||||
# T-1145) or zoomed since the last enter()/fit — auto-fit (enter, first
|
||||
# window arrival, resize) only re-fits BEFORE this flips, so it never fights
|
||||
# a player mid-interaction. Reset to
|
||||
# false on every enter() (a fresh descent always starts fitted).
|
||||
# T-1142/T-1145: true once the user has manually panned or zoomed since the
|
||||
# last enter()/fit — auto-fit (enter, first window arrival, resize) only
|
||||
# re-fits BEFORE this flips, so it never fights a player mid-interaction.
|
||||
# Reset to false on every enter() (a fresh descent always starts fitted).
|
||||
var _user_adjusted: bool = false
|
||||
# T-1142: true from enter() until the FIRST _on_window_ready() fires (the
|
||||
# actual composite's arrival re-fits once, in case the entry-time fit used a
|
||||
# not-yet-final viewport size) — false after that first arrival, so LATER
|
||||
# pan-triggered window arrivals never re-fit on their own (only entry/first-
|
||||
# arrival/resize do, per the ticket's three named events).
|
||||
# composite's arrival re-fits once, in case entry used a not-yet-final
|
||||
# viewport size) — false after, so LATER pan-triggered arrivals never re-fit
|
||||
# on their own (only entry/first-arrival/resize do).
|
||||
var _awaiting_first_window: bool = true
|
||||
|
||||
# ── Overlay visibility ─────────────────────────────────────────────────────
|
||||
@@ -185,9 +176,8 @@ var _window_request = null # AtlasWindowRequest
|
||||
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
|
||||
|
||||
## T-1153 (design doc §4): true while showing the orbital rest state as a
|
||||
## MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the single held
|
||||
## composite (`_window`). Set by `_enter_tile_mode()`; cleared the moment
|
||||
## `_maybe_reselect_rung()` crosses OUT of Region.
|
||||
## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set
|
||||
## by `_enter_tile_mode()`; cleared when `_maybe_reselect_rung()` leaves Region.
|
||||
var _tile_mode: bool = false
|
||||
|
||||
|
||||
@@ -236,11 +226,10 @@ func _exit_tree() -> void:
|
||||
|
||||
|
||||
## Enter the window screen centered on `district_center` at District
|
||||
## 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 BEFORE it becomes
|
||||
## `_held_center` — matching the server's normalize_window_center().
|
||||
## 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 BEFORE it becomes `_held_center`, matching the server's
|
||||
## normalize_window_center().
|
||||
func enter(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -261,12 +250,10 @@ func enter(
|
||||
## 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
|
||||
## circumference. Once `compute_tile_grid()` returns MORE than one tile,
|
||||
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`.
|
||||
## 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 circumference, so once compute_tile_grid()
|
||||
## returns MORE than one tile, entry goes through _enter_tile_mode() instead.
|
||||
func enter_orbital(body: Dictionary, system: Dictionary) -> void:
|
||||
var radius_km: float = float(body.get("body_radius_km", 0.0))
|
||||
if radius_km <= 0.0:
|
||||
@@ -313,11 +300,10 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) ->
|
||||
|
||||
## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center`
|
||||
## 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:** `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).
|
||||
## held/request state for a fresh descent, plus _held_granularity_v2. `n`
|
||||
## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes
|
||||
## `_held_n` (C1 clamp-mirror, one layer up) — mirroring AtlasWindowRequest.
|
||||
## request_now()'s own clamp (PR #191 Tyre C1).
|
||||
func _enter_at_rung(
|
||||
body: Dictionary,
|
||||
system: Dictionary,
|
||||
@@ -347,10 +333,10 @@ func _enter_at_rung(
|
||||
_overlay_node.queue_redraw()
|
||||
|
||||
|
||||
## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s
|
||||
## zoom/offset, then re-clamps the offset to the pole wall. Called from
|
||||
## enter(), the FIRST _on_window_ready() after entry, and
|
||||
## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted).
|
||||
## T-1142: fit-and-center — applies fit_window_view()'s zoom/offset, then
|
||||
## re-clamps to the pole wall. Called from enter(), the FIRST
|
||||
## _on_window_ready() after entry, and NOTIFICATION_RESIZED — never
|
||||
## mid-interaction (guarded by _user_adjusted).
|
||||
func _fit_and_center() -> void:
|
||||
var viewport: Vector2 = get_rect().size
|
||||
if viewport == Vector2.ZERO:
|
||||
@@ -422,10 +408,16 @@ func get_body_radius_km() -> float:
|
||||
return float(_body.get("body_radius_km", 0.0))
|
||||
|
||||
|
||||
## Cold-start dossier (BUG 2): RegionalScreen.enter() reads this to skip a
|
||||
## redundant enter_orbital() for a repeat nav.push("regional") on the SAME
|
||||
## body — see that call site's own doc for why.
|
||||
func get_body_id() -> String:
|
||||
return _dict_str(_body, "body_id", "")
|
||||
|
||||
|
||||
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
|
||||
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
|
||||
## single source of geometry truth (same "viewer owns the transform, overlay
|
||||
## only draws" split as AtlasViewer/AtlasMarkerOverlay).
|
||||
## rather than hardcoding CELL_PIXEL_SIZE, so the viewer stays the single
|
||||
## source of geometry truth ("viewer owns the transform, overlay only draws").
|
||||
func get_cell_pixel_size() -> float:
|
||||
return CELL_PIXEL_SIZE
|
||||
|
||||
@@ -462,16 +454,13 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
|
||||
## because `_window` only ever gets REPLACED, never nulled, once adopted
|
||||
## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap).
|
||||
func _on_window_ready(window: Dictionary) -> void:
|
||||
# Only adopt the window if it still matches what THIS viewer is currently
|
||||
# showing — AtlasWindowRequest already filtered by its own last-asked
|
||||
# (center, n, granularity_v2) via the echo (§2/T-1150/T-1152), but a
|
||||
# cache-hit path can fire synchronously from enter() before _held_center
|
||||
# is what the signal handler expects in a re-entrant call; comparing
|
||||
# again here is cheap and removes any ordering assumption between
|
||||
# enter()'s two calls. granularity_v2 (T-1153) is compared too — a
|
||||
# district-rung response answering a request that's SINCE moved on to a
|
||||
# region-rung request (rapid wheel-zoom) must not be adopted just because
|
||||
# center/n happen to still match.
|
||||
# Only adopt if it still matches what THIS viewer is currently showing —
|
||||
# AtlasWindowRequest already filtered by its own last-asked echo (§2/
|
||||
# T-1150/T-1152), but a cache-hit path can fire synchronously from
|
||||
# enter() before _held_center reflects a re-entrant call, so comparing
|
||||
# again removes any ordering assumption. granularity_v2 is compared too
|
||||
# — a district response answering a request since moved on to Region
|
||||
# (rapid wheel-zoom) must not be adopted just because center/n match.
|
||||
var w_center := _vec_from_center(window.get("center", [0, 0]))
|
||||
var w_n := int(window.get("n", 0))
|
||||
var w_granularity_v2 := str(
|
||||
@@ -513,11 +502,11 @@ func _on_tile_ready(_index: int) -> void:
|
||||
|
||||
# =============================================================================
|
||||
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
|
||||
# amendment): the held composite is always drawn client-side-zoomed with NO
|
||||
# re-request, but crossing a rung's spacing threshold fires a NEW request at
|
||||
# the new granularity in the background (progressive refinement — see
|
||||
# _maybe_reselect_rung()'s own doc) while the OLD composite stays on screen.
|
||||
# ANCHORED and CONTINUOUS ACROSS RUNGS, D-226 T-1143-rulings: the held
|
||||
# composite draws client-side-zoomed with NO re-request, but crossing a
|
||||
# rung's spacing threshold fires a background request at the new granularity
|
||||
# (progressive refinement, _maybe_reselect_rung()) while the OLD composite
|
||||
# stays on screen.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -533,10 +522,9 @@ func _apply_transform() -> void:
|
||||
## 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.
|
||||
# Live round 6: once at the canonical frame, zoom-OUT must not drift
|
||||
# below fit (see the reset's own doc — that caused a request storm).
|
||||
# Clamping here, not the reset guard, keeps the reset edge-triggered.
|
||||
if new_zoom < _view_zoom and _is_at_canonical_frame():
|
||||
var fit_zoom: float = _canonical_fit_zoom()
|
||||
new_zoom = maxf(new_zoom, fit_zoom)
|
||||
@@ -561,16 +549,13 @@ 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 (same formula as _maybe_refloat_window()).
|
||||
##
|
||||
## **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 what made
|
||||
## `_maybe_reset_to_canonical_frame()`'s OLD guard misfire.
|
||||
## CURRENT screen-center (same formula as _maybe_refloat_window()). `_held_n`
|
||||
## MUST be re-clamped for the TARGET rung (C1 clamp-mirror, a third layer
|
||||
## up) — a stale large `_held_n` desyncs `_on_window_ready()`'s staleness
|
||||
## check. Does NOT touch `_window`/`_held_granularity_v2` — the OLD
|
||||
## composite keeps drawing until _on_window_ready() adopts the new one (§6
|
||||
## "no mode flip"; this lag is what made the canonical-frame reset's OLD
|
||||
## guard misfire, live round 5).
|
||||
func _maybe_reselect_rung() -> void:
|
||||
if _held_n <= 0:
|
||||
return
|
||||
@@ -658,22 +643,16 @@ func _is_at_canonical_frame() -> bool:
|
||||
## Jeroen's HARD condition: "a full zoom-out resets to the original
|
||||
## 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 old guard checked only
|
||||
## `_held_center`/`_held_granularity_v2` — a LAGGING field (updated only on
|
||||
## 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 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.
|
||||
## Live round 5 fix: also requires is_tile_mode() to match (a TILING body's
|
||||
## granularity stayed stale "Region" after zooming IN left tile mode,
|
||||
## misreading "already canonical" from `_held_center`/`_held_granularity_v2`
|
||||
## alone — LAGGING fields, updated only on response adoption). Live round 6
|
||||
## fix: a per-tick zoom-equality check had made this LEVEL-triggered —
|
||||
## continued zoom-out flooded the server (889/897 wire responses in one
|
||||
## phase) via repeated enter_orbital() tearing down/rebuilding the tile set,
|
||||
## orphaning in-flight responses (nothing held -> black). Fixed by moving
|
||||
## the zoom-drift concern to `_zoom_at()`'s own floor instead — this guard
|
||||
## 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:
|
||||
@@ -711,7 +690,6 @@ func set_view(zoom: float, offset: Vector2) -> void:
|
||||
## After a pan delta (T-1145: WASD/edge-scroll), check whether the
|
||||
## screen-center now maps to a DistrictPos outside the held window's extent
|
||||
## — 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, matching the
|
||||
## server's normalize_window_center().
|
||||
@@ -767,29 +745,25 @@ func _maybe_refloat_window() -> void:
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _tile_mode:
|
||||
# T-1153: single-window border-fade/pending-wash don't apply to a
|
||||
# mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles;
|
||||
# an unarrived one is an honest gap over COLOR_BG, no separate fade.
|
||||
# T-1153: this viewer's own border-fade/pending-wash are single-window
|
||||
# concepts (one extent, one state) — a mosaic's per-tile pending wash
|
||||
# is drawn by AtlasWindowOverlay itself (see _draw_tile_mosaic()).
|
||||
if _tile_set and not _tile_set.has_any_tile_arrived():
|
||||
_draw_deriving_terrain_label()
|
||||
return
|
||||
if _window == null:
|
||||
# §5 "what renders during the wait": a border-fade to the underlying
|
||||
# whole-body context rather than black/a spinner. This viewer has no
|
||||
# resident whole-body texture of its own — the honest available
|
||||
# substitute is a dim fade wash over the held composite's last-known
|
||||
# extent, reusing the request object's own is_pending() for the
|
||||
# "still working" cue rather than new dressing.
|
||||
# whole-body context rather than black/a spinner — the honest
|
||||
# available substitute, this viewer having no resident whole-body
|
||||
# texture of its own.
|
||||
_draw_border_fade()
|
||||
elif _window_request and _window_request.is_pending():
|
||||
# T-1153/R6: the border-fade's REFERENT repointed — a rung-crossing
|
||||
# zoom (progressive refinement) leaves `_window` non-null (the OLD
|
||||
# composite is still the thing on screen, drawn by AtlasWindowOverlay
|
||||
# as always) while a NEW rung's request is in flight underneath it.
|
||||
# R6's ruling: "the mechanism survives; its target must be repointed
|
||||
# to 'the previous derived composite at this position'" — exactly
|
||||
# this case. A lighter pending wash (not the full opaque fade the
|
||||
# no-composite-at-all case uses, since there IS real data showing
|
||||
# through here, not emptiness) signals "sharper detail incoming"
|
||||
# without implying the current view is stale or wrong.
|
||||
# composite still on screen) while a NEW rung's request is in flight
|
||||
# underneath it. A lighter pending wash (not the full opaque fade the
|
||||
# no-composite-at-all case uses, since real data IS showing through)
|
||||
# signals "sharper detail incoming" without implying the view is wrong.
|
||||
_draw_pending_refinement_wash()
|
||||
|
||||
|
||||
@@ -805,6 +779,26 @@ func _draw_pending_refinement_wash() -> void:
|
||||
draw_rect(Rect2(top_left, Vector2(extent, extent)), COLOR_PENDING_REFINEMENT_WASH)
|
||||
|
||||
|
||||
## Cold-start dossier: the subtle per-tile COLOR_BORDER_FADE wash alone
|
||||
## (AtlasWindowOverlay._draw_tile_mosaic()) read as broken darkness in a
|
||||
## live cold capture (>10s, zero tiles landed), not loading. Drawn in SCREEN
|
||||
## space (unlike the per-tile washes, which live in the panned/zoomed
|
||||
## _canvas) so it always reads centered/legible regardless of zoom. Dropped
|
||||
## the instant even one tile lands (has_any_tile_arrived()) — per-tile
|
||||
## washes alone read fine once real content fills in around the gaps.
|
||||
## Positioning math lives in AtlasWindowGeometry.centered_label_baseline()
|
||||
## (file-length; a pure function either way).
|
||||
func _draw_deriving_terrain_label() -> void:
|
||||
var label := "DERIVING TERRAIN…"
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := 20
|
||||
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
var baseline: Vector2 = AtlasWindowGeometry.centered_label_baseline(get_rect().size, text_size)
|
||||
draw_string(
|
||||
font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL
|
||||
)
|
||||
|
||||
|
||||
func _build_screen_header() -> void:
|
||||
_screen_header = ImplantHeader.new()
|
||||
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
|
||||
@@ -842,10 +836,9 @@ func _location_label() -> String:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## 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.
|
||||
## No city panel / sidebar in this mode (yet) — always 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
|
||||
|
||||
@@ -885,9 +878,18 @@ func _handle_key(event: InputEventKey) -> void:
|
||||
## 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).
|
||||
## Cold-start self-heal (coordinator live repro): every queue_redraw() site
|
||||
## is already paired with _overlay_node's own, yet a cold server's slow first
|
||||
## AnalyzeBody still showed a black mosaic with every tile held/textured,
|
||||
## only resolving on an unrelated gesture. Rather than chase one signal edge
|
||||
## that might drop, redraw every frame while any tile is pending — free once
|
||||
## complete.
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _tile_mode and _tile_set and _tile_set.has_pending_tiles():
|
||||
queue_redraw()
|
||||
_overlay_node.queue_redraw()
|
||||
var direction: Vector2 = _held_pan_direction()
|
||||
if _is_cursor_edge_scrolling():
|
||||
direction += _edge_scroll_direction()
|
||||
@@ -897,10 +899,9 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and
|
||||
## this frame's delta — frame-rate independent, zoom-scaled
|
||||
## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets
|
||||
## _user_adjusted and triggers the pan-edge refetch (§4). Split from
|
||||
## _process() so a test can call it directly with a synthetic direction/delta.
|
||||
## this frame's delta — frame-rate independent, zoom-scaled, pole-wall
|
||||
## clamped (T-1142). Sets _user_adjusted and triggers the pan-edge refetch
|
||||
## (§4). Split from _process() so a test can call it with a synthetic input.
|
||||
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
|
||||
_user_adjusted = true
|
||||
@@ -911,9 +912,8 @@ func _apply_pan_delta(direction: Vector2, delta: float) -> void:
|
||||
|
||||
|
||||
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
|
||||
## (not an InputMap action) — see AtlasWindowGeometry.held_pan_direction()'s
|
||||
## doc for the full W/S/A/D-vs-gameplay-movement rationale (moved there
|
||||
## T-1153 for file-length/testability, unchanged behavior).
|
||||
## — see AtlasWindowGeometry.held_pan_direction()'s own doc for the full
|
||||
## rationale (moved there for file-length/testability).
|
||||
func _held_pan_direction() -> Vector2:
|
||||
return AtlasWindowGeometry.held_pan_direction()
|
||||
|
||||
|
||||
@@ -39,9 +39,23 @@ func _ready() -> void:
|
||||
_viewer.back_pressed.connect(_on_viewer_back)
|
||||
|
||||
|
||||
## Cold-start dossier (BUG 2, PR #192 review): ImplantApp._on_screen_changed()
|
||||
## calls enter() unconditionally on EVERY screen_changed, including a repeat
|
||||
## nav.push("regional", ...) that lands on the SAME screen already showing —
|
||||
## reachable from more than one input path (body-click, panel+Enter) and,
|
||||
## on a slow cold server, plausible for a player to trigger twice before the
|
||||
## first descent settles. Without this guard, a repeat push tore down and
|
||||
## rebuilt the whole tile set (orphaning in-flight requests, live round 6's
|
||||
## exact storm shape for a different trigger) and refreshed the legend from
|
||||
## scratch each time — confirmed source of the ~10x legend stack. Guarded
|
||||
## on body_id alone (not a deep payload compare): the same body, re-entered,
|
||||
## should always resume the SAME orbital session already in flight, never
|
||||
## restart it — an actual body CHANGE (different id) still re-enters fresh.
|
||||
func enter(payload: Dictionary) -> void:
|
||||
var body: Dictionary = payload.get("body", {})
|
||||
var system: Dictionary = payload.get("system", {})
|
||||
if str(body.get("body_id", "")) == _viewer.get_body_id():
|
||||
return
|
||||
_viewer.enter_orbital(body, system)
|
||||
|
||||
|
||||
|
||||
@@ -70,9 +70,48 @@ func get_implant_children() -> Array:
|
||||
return _vbox.get_children()
|
||||
|
||||
|
||||
## Clear all components.
|
||||
## Clear all components. Frees IMMEDIATELY (remove_child() + free()), not
|
||||
## via queue_free() — a caller that re-enters refresh()-shaped clear()+
|
||||
## add_component() more than once in the SAME frame (confirmed live: the
|
||||
## Atlas regional-window legend stacking ~10x around cold-start entry, PR
|
||||
## #192 cold-start dossier) would otherwise see get_implant_children() still
|
||||
## returning the STALE children (queue_free() only removes them at end of
|
||||
## frame), so add_component()'s new ones pile up alongside instead of
|
||||
## replacing them. Immediate removal makes clear() genuinely synchronous —
|
||||
## the panel can never observe more than one refresh() worth of content, no
|
||||
## matter how many times it's called before the next frame.
|
||||
func clear() -> void:
|
||||
if not _vbox:
|
||||
return
|
||||
for child in _vbox.get_children():
|
||||
child.queue_free()
|
||||
_vbox.remove_child(child)
|
||||
child.free()
|
||||
|
||||
|
||||
## PR #192 cold-start round 2: call once at the END of a refresh() — AFTER
|
||||
## clear() + every add_component() for the new content. A Control's `size`
|
||||
## GROWS to fit a rising minimum size automatically (normal Container
|
||||
## behavior) but never shrinks back down on its own once a taller stack of
|
||||
## content has pushed it up (confirmed live: the same stacking window that
|
||||
## caused the duplicate-children bug above ALSO left the panel's rect
|
||||
## oversized — 260x2343px, ~10 legends tall — even after the children-count
|
||||
## fix landed). This panel is manually positioned (not itself inside a
|
||||
## parent Container), so nothing else forces that re-measure.
|
||||
##
|
||||
## DEFERRED, not a direct reset_size() call — confirmed directly
|
||||
## (instrumented and reverted through several shapes before landing here):
|
||||
## get_minimum_size() is momentarily WRONG for a frame or more after
|
||||
## add_component() runs (RichTextLabel.fit_content's minimum-height
|
||||
## computation is width-dependent and the control hasn't been laid out with
|
||||
## its real width yet — the classic Godot "fit_content measures huge before
|
||||
## its first real layout pass" gap), and — critically — a DIRECT reset_size()
|
||||
## call that captures that transitional wrong value does NOT self-correct
|
||||
## later even once get_minimum_size() itself settles down on its own:
|
||||
## `size` stays stuck at whatever the premature reset_size() captured,
|
||||
## because nothing re-triggers a growth pass for an already-`_ready()`'d
|
||||
## panel afterward. call_deferred() runs at the END of the current frame's
|
||||
## idle processing — after layout has had its chance to update
|
||||
## get_minimum_size() — so by the time this actually executes, it reads the
|
||||
## real, settled value.
|
||||
func reset_to_content_size() -> void:
|
||||
call_deferred("reset_size")
|
||||
|
||||
@@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
**Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it.
|
||||
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed).
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed). **Pending-shape protocol note (T-1163, PR #193 review — Tyre):** `AtlasLayerResponse` has **two legal wire shapes for one logical "still deriving, client must re-poll" state**, and both are contract: whole-response `status: Pending` (whole-body cache cold — nothing about this body computed yet) and `status: Ready` with `district_window: null` (body warm, this window still in the derive queue). `Ready` is set **only** by the whole-body cache-hit branch, independent of the window's own derivation. Any `AtlasLayerResponse` consumer must treat BOTH shapes as retry-with-backoff and only `NotFound`/`Error` as terminal — the T-1163 cold-launch starvation (every first launch black) was precisely a client reading `status != Ready` as ignorable. A server refactor that "cleans up" the Pending/Ready-null asymmetry must migrate every consumer in the same change.
|
||||
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
|
||||
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
|
||||
- **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.
|
||||
|
||||
@@ -1396,6 +1396,503 @@ fn browse_index_empty_table_is_ready_with_empty_list_over_tcp() {
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ConnectionId(0) district_window delivery regression (live-verified bug,
|
||||
// coordinator repro 2026-07-22): a fresh server's FIRST connection (whatever
|
||||
// role) never received a district_window response over the wire, while a
|
||||
// second connection to the same (or a fresh) server was served correctly.
|
||||
// Every existing window test in layer_proxy.rs's unit-test module calls
|
||||
// `test_conn_id() -> ConnectionId(1)` — NEVER ConnectionId(0) — so this class
|
||||
// of bug had zero unit-test coverage. This test drives the REAL
|
||||
// id-assignment path (`BridgeResource::default()` + `insert_reader`, the
|
||||
// exact path `main.rs`'s first-connection handling and
|
||||
// `accept_new_connections`'s reader promotion both use) rather than a
|
||||
// hand-picked id, over the real TCP wire, through the real
|
||||
// receive->serve->drain->send tick-phase pipeline.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
mod connection_zero_window_delivery {
|
||||
use super::*;
|
||||
use bevy_app::App;
|
||||
use rusqlite::Connection;
|
||||
use settled_reach_server::atlas::body_params_reader::{
|
||||
BodyParamsReader, BodyParamsReaderResource,
|
||||
};
|
||||
use settled_reach_server::atlas::cascade::CascadeLayer;
|
||||
use settled_reach_server::atlas::layer_proxy::{
|
||||
AtlasLayerRequest, AtlasLayerStatus, WindowGranularity,
|
||||
};
|
||||
use settled_reach_server::atlas::source_resolver::{
|
||||
BodySourceResolver, BodySourceResolverResource,
|
||||
};
|
||||
use settled_reach_server::atlas::GenerationPlugin;
|
||||
use settled_reach_server::bridge::{
|
||||
BridgePlugin, ConnectionId, ConnectionListener, HandshakeState, PendingConnections,
|
||||
};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Same fixture shape as `layer_proxy.rs`'s own `write_tiny_heightmap`
|
||||
/// (not exported for cross-crate reuse — duplicated intentionally rather
|
||||
/// than widening that module's visibility for one integration test).
|
||||
fn write_tiny_heightmap(path: &Path) {
|
||||
use std::io::BufWriter;
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut enc = png::Encoder::new(BufWriter::new(file), 32, 16);
|
||||
enc.set_color(png::ColorType::Grayscale);
|
||||
enc.set_depth(png::BitDepth::Sixteen);
|
||||
let mut w = enc.write_header().unwrap();
|
||||
let data: Vec<u8> = (0..32u32 * 16)
|
||||
.flat_map(|i| (((i * 600) % 65536) as u16).to_be_bytes())
|
||||
.collect();
|
||||
w.write_image_data(&data).unwrap();
|
||||
}
|
||||
|
||||
/// Same fixture shape as `layer_proxy.rs`'s `resolver_and_params_reader`
|
||||
/// (duplicated for the same cross-crate-visibility reason as above).
|
||||
fn resolver_and_params_reader(
|
||||
body_id: &str,
|
||||
) -> (BodySourceResolver, BodyParamsReader, PathBuf) {
|
||||
const REL: &str = "wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let db = std::env::temp_dir().join(format!("sr_connzero_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&db);
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE star_systems (
|
||||
system_id TEXT PRIMARY KEY,
|
||||
spectral_class TEXT,
|
||||
star_type TEXT
|
||||
);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT,
|
||||
terrain_reference TEXT,
|
||||
hydrosphere TEXT,
|
||||
atmosphere TEXT,
|
||||
planet_class TEXT,
|
||||
body_radius_km REAL,
|
||||
orbital_period_days REAL,
|
||||
axial_tilt_deg REAL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO star_systems (system_id, spectral_class, star_type) VALUES ('GJ-1', 'G', 'main_sequence')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (body_id, system_id, terrain_reference, hydrosphere, atmosphere, planet_class, body_radius_km, orbital_period_days, axial_tilt_deg)
|
||||
VALUES (?1, 'GJ-1', ?2, 'ocean', 'breathable', 'temperate', 6371.0, 365.25, 23.5)",
|
||||
rusqlite::params![body_id, REL],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let root = std::env::temp_dir().join(format!("sr_connzeroroot_{}_{n}", std::process::id()));
|
||||
write_tiny_heightmap(&root.join(REL));
|
||||
|
||||
let resolver = BodySourceResolver::open(&db, vec![root.clone()]).unwrap();
|
||||
let params_reader = BodyParamsReader::open(&db).unwrap();
|
||||
(resolver, params_reader, root)
|
||||
}
|
||||
|
||||
/// A district-window `AtlasLayerRequest` at Region granularity — the
|
||||
/// exact rung the coordinator's live repro used (six Region tile
|
||||
/// requests on body entry).
|
||||
fn region_window_request(body_id: &str, center: (i32, i32)) -> AtlasLayerRequest {
|
||||
AtlasLayerRequest {
|
||||
body_id: body_id.to_string(),
|
||||
up_to: CascadeLayer::Topography,
|
||||
window_center: Some(center),
|
||||
window_n: 4,
|
||||
window_granularity: 0,
|
||||
window_granularity_v2: Some(WindowGranularity::Region),
|
||||
window_min_wl_m: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a real `App`, replicating `main.rs`'s ACTUAL bootstrap sequence
|
||||
/// for the first connection — not an approximation. `BridgePlugin` never
|
||||
/// `init_resource`s `BridgeResource` itself (confirmed: no
|
||||
/// `init_resource`/`insert_resource::<BridgeResource>` anywhere in
|
||||
/// `bridge/mod.rs`'s `Plugin::build`) — `main.rs` constructs it AFTER a
|
||||
/// single BLOCKING `TcpListener::accept()` + `TcpBridge::accept_on()` +
|
||||
/// handshake/startup exchange, pre-populated with that first connection
|
||||
/// already installed via `insert_player`/`insert_reader`, and only
|
||||
/// THEN starts the tick loop with a SEPARATE non-blocking listener clone
|
||||
/// for `accept_new_connections` to handle connections 2+. This function
|
||||
/// reproduces exactly that two-listener-handle, blocking-then-async
|
||||
/// split (`main.rs` lines ~104-145, ~368-377) rather than the
|
||||
/// all-non-blocking shortcut the first draft of this test used (which
|
||||
/// masked the real bootstrap entirely and hit "BridgeResource does not
|
||||
/// exist" — the WRONG failure, not the bug under investigation).
|
||||
///
|
||||
/// `role` selects Reader (the standalone Atlas companion's real role,
|
||||
/// matching the coordinator's live repro) or Player.
|
||||
fn accept_first_connection_and_build_app(
|
||||
resolver: BodySourceResolver,
|
||||
params_reader: settled_reach_server::atlas::body_params_reader::BodyParamsReader,
|
||||
role: ConnectionRole,
|
||||
) -> (App, std::net::SocketAddr, TcpStream) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||
let addr = listener.local_addr().expect("failed to get local address");
|
||||
// try_clone BEFORE any accept, mirroring main.rs exactly: one handle
|
||||
// for the blocking first accept, a second (later set non-blocking)
|
||||
// for the async per-tick accept-loop — the same underlying socket.
|
||||
let listener_for_loop = listener.try_clone().expect("failed to clone listener");
|
||||
|
||||
let client_handle = thread::spawn(move || {
|
||||
let stream = TcpStream::connect(addr).expect("failed to connect");
|
||||
client_handshake(stream, role)
|
||||
});
|
||||
|
||||
// Blocking accept — genuinely blocks this thread until the client
|
||||
// above connects, exactly like main.rs's listener.accept() call.
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept first connection");
|
||||
// main.rs's real sequence (lines ~145-161): send the protocol
|
||||
// handshake, THEN read the client's StartupMessage — BEFORE the
|
||||
// client thread's client_handshake() (which reads the handshake
|
||||
// first) can complete. Omitting these two calls was the first
|
||||
// draft's bug: both sides deadlocked waiting on each other with
|
||||
// nothing ever written first.
|
||||
bridge.send_handshake().expect("failed to send handshake");
|
||||
let _startup = bridge
|
||||
.receive_startup()
|
||||
.expect("failed to receive startup message");
|
||||
let client_stream = client_handle.join().expect("client thread panicked");
|
||||
|
||||
let mut bridge_resource = BridgeResource::default();
|
||||
let first_id = match role {
|
||||
ConnectionRole::Player => bridge_resource.insert_player(bridge),
|
||||
ConnectionRole::Reader => bridge_resource.insert_reader(bridge),
|
||||
};
|
||||
assert_eq!(
|
||||
first_id,
|
||||
ConnectionId(0),
|
||||
"sanity: the first-ever insert on a fresh BridgeResource must be id 0"
|
||||
);
|
||||
|
||||
// Full main.rs plugin composition (not a hand-picked subset) — a
|
||||
// missing resource from any of these panicked the first draft
|
||||
// (KnowledgePlugin/NpcPlugin/StorytellerPlugin/SettingsPlugin/
|
||||
// BookmarkPlugin's systems are NOT gated behind Option<Res<...>> the
|
||||
// way GenerationPlugin's atlas-reader resources are), so matching
|
||||
// main.rs exactly is the correct fix, not narrowing the plugin set.
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin { seed: 42 });
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.add_plugins(settled_reach_server::storyteller::StorytellerPlugin);
|
||||
app.add_plugins(settled_reach_server::settings::SettingsPlugin);
|
||||
app.add_plugins(settled_reach_server::bookmark::BookmarkPlugin::default());
|
||||
app.add_plugins(GenerationPlugin);
|
||||
// main.rs unconditionally calls setup_proof_room()/setup_gauntlet()
|
||||
// AFTER building every plugin above — even for a Reader-only spawn
|
||||
// (D-254 §1's own comment on that call site: "this call is NOT
|
||||
// gated on the first connection's role ... A Reader-only spawned
|
||||
// server therefore has an inert, unpiloted PlayerCharacter entity").
|
||||
// setup_proof_room isn't `pub` (private to the main.rs binary crate)
|
||||
// so it can't be called directly from an integration test; inserting
|
||||
// WalkabilityMap alone (the specific resource several PreInput/
|
||||
// Movement-phase systems require unconditionally, confirmed by this
|
||||
// test's first draft panicking with it absent) is the minimal
|
||||
// equivalent for a Reader-only session that spawns no character —
|
||||
// matching game_loop.rs's own established minimal pattern.
|
||||
app.insert_resource(
|
||||
settled_reach_server::simulation::movement::WalkabilityMap::new(32, 32, 1),
|
||||
);
|
||||
app.insert_resource(bridge_resource);
|
||||
app.insert_resource(HandshakeState::Complete);
|
||||
// Non-blocking for the tick loop's accept_new_connections, exactly
|
||||
// as main.rs's listener_for_loop.set_nonblocking(true) does.
|
||||
listener_for_loop
|
||||
.set_nonblocking(true)
|
||||
.expect("failed to set accept-loop listener non-blocking");
|
||||
app.insert_resource(ConnectionListener(Some(listener_for_loop)));
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingConnections>()
|
||||
.0
|
||||
.clear();
|
||||
app.insert_resource(BodySourceResolverResource(resolver));
|
||||
app.insert_resource(BodyParamsReaderResource(params_reader));
|
||||
(app, addr, client_stream)
|
||||
}
|
||||
|
||||
/// Drive `app.update()` (one full real tick — every registered system,
|
||||
/// in the real `TickPhase` order) repeatedly, checking the accept-loop
|
||||
/// condition after each — mirrors `drive_accept_loop_until`'s pattern but
|
||||
/// against a full `App` rather than a hand-picked system.
|
||||
fn drive_app_accept_loop_until(app: &mut App, condition: impl Fn(&App) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !condition(app) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for accept-loop condition"
|
||||
);
|
||||
app.update();
|
||||
thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `app.update()` ticks until a `Ready` `AtlasLayerResponse`
|
||||
/// carrying a populated `district_window` arrives on `stream`, or a
|
||||
/// wall-clock deadline expires — mirrors
|
||||
/// `drive_browse_tick_until_response`'s established pattern (bounded
|
||||
/// retry loop over a genuinely blocking client-side socket, not a fixed
|
||||
/// sleep/tick count) but must also tolerate intermediate `Pending`
|
||||
/// frames (the D-225 poll-and-recheck-cache contract: the FIRST response
|
||||
/// for a cold window is `Ready` with `district_window: None`, not the
|
||||
/// final answer) by reading and discarding them until the populated one
|
||||
/// lands.
|
||||
fn drive_app_until_window_response(
|
||||
app: &mut App,
|
||||
stream: &mut TcpStream,
|
||||
body_id: &str,
|
||||
center: (i32, i32),
|
||||
) -> settled_reach_server::atlas::layer_proxy::AtlasLayerResponse {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
|
||||
.expect("failed to set read timeout");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
app.update();
|
||||
match read_framed(stream) {
|
||||
Ok(Some(payload)) => {
|
||||
let resp: settled_reach_server::atlas::layer_proxy::AtlasLayerResponse =
|
||||
rmp_serde::from_slice(&payload)
|
||||
.expect("failed to decode AtlasLayerResponse");
|
||||
if resp.district_window.is_some() {
|
||||
return resp;
|
||||
}
|
||||
// Pending (cold cache) or Ready-with-None (derive still
|
||||
// in flight) — re-request and keep polling, exactly the
|
||||
// client's real re-poll behavior (atlas_window_request.gd's
|
||||
// on_response()/_schedule_retry(): the server never pushes
|
||||
// a second response on its own — D-225's poll-and-recheck-
|
||||
// cache contract requires the CLIENT to re-send). Omitting
|
||||
// this re-send was this test's own first-draft bug: the
|
||||
// request was consumed by serve_atlas_requests within the
|
||||
// SAME app.update() it arrived in (receive -> serve ->
|
||||
// send all run inside one PreInput/PostSnapshot pass), so
|
||||
// every later tick correctly had nothing left to serve —
|
||||
// not a production hang, a missing re-poll in the harness.
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for a populated district_window \
|
||||
(last status: {:?})",
|
||||
resp.status
|
||||
);
|
||||
send_region_window_request(stream, body_id, center);
|
||||
}
|
||||
Ok(None) => panic!("unexpected EOF reading atlas response"),
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for an atlas response frame at all"
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("failed to read atlas response frame: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_region_window_request(stream: &mut TcpStream, body_id: &str, center: (i32, i32)) {
|
||||
let req = region_window_request(body_id, center);
|
||||
let payload = rmp_serde::to_vec_named(&req).expect("failed to encode AtlasLayerRequest");
|
||||
write_framed(stream, &payload).expect("failed to write AtlasLayerRequest");
|
||||
}
|
||||
|
||||
/// **THE regression test.** A fresh server's FIRST connection ever
|
||||
/// accepted (Reader role, matching the standalone Atlas companion) must
|
||||
/// receive its district_window response — proven via the REAL
|
||||
/// `insert_reader`-assigned id, not `ConnectionId(1)` the way every
|
||||
/// existing window unit test does. If this test alone is added without
|
||||
/// re-requesting after the initial Pending (i.e. treats the first
|
||||
/// response as final), it would have passed even on a broken server —
|
||||
/// the assertion on `resp.status == Ready` AND `district_window.is_some()`
|
||||
/// together, reached only via `drive_window_tick_until_response`'s
|
||||
/// re-poll loop, is what actually exercises the async derive-then-cache
|
||||
/// path a live client depends on.
|
||||
#[test]
|
||||
fn first_connection_ever_accepted_receives_district_window() {
|
||||
let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||||
let (mut app, _addr, mut stream) =
|
||||
accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader);
|
||||
|
||||
// Confirm this really is the id-zero path before asserting delivery
|
||||
// — a false pass here (e.g. BridgeResource pre-seeded elsewhere)
|
||||
// would silently defeat the whole point of the test. (Also asserted
|
||||
// inside accept_first_connection_and_build_app; re-checked here at
|
||||
// the point of use for a self-contained failure message.)
|
||||
let conn_id = app
|
||||
.world()
|
||||
.resource::<BridgeResource>()
|
||||
.reader_ids()
|
||||
.first()
|
||||
.copied()
|
||||
.expect("reader must be installed");
|
||||
assert_eq!(
|
||||
conn_id,
|
||||
ConnectionId(0),
|
||||
"this test only proves what it claims to prove if the reader under \
|
||||
test genuinely got the FIRST id a fresh BridgeResource ever assigns"
|
||||
);
|
||||
|
||||
send_region_window_request(&mut stream, "GJ1c", (0, 0));
|
||||
let resp = drive_app_until_window_response(&mut app, &mut stream, "GJ1c", (0, 0));
|
||||
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Ready);
|
||||
assert_eq!(resp.body_id, "GJ1c");
|
||||
let window = resp
|
||||
.district_window
|
||||
.expect("district_window must be populated (checked above; re-asserted for clarity)");
|
||||
assert_eq!(window.granularity_v2, WindowGranularity::Region);
|
||||
assert!(
|
||||
!window.morphology.is_empty(),
|
||||
"the delivered window must carry real derived cell data, not an empty payload"
|
||||
);
|
||||
}
|
||||
|
||||
/// The EXACT shape of the coordinator's live repro: six Region tile
|
||||
/// requests fired on body entry (the standalone Atlas companion's real
|
||||
/// window-tile-set fan-out, not a single request) on connection 0, all
|
||||
/// six must be delivered — not just the first, in case a many-in-flight
|
||||
/// scenario surfaces an ordering issue a single-request test can't see
|
||||
/// (e.g. coalescing/supersede logic dropping later requests, or the
|
||||
/// per-tick MAX_READER_INBOUND_FRAMES_PER_TICK cap interacting badly
|
||||
/// with six queued frames).
|
||||
#[test]
|
||||
fn first_connection_receives_all_six_region_tiles() {
|
||||
let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||||
let (mut app, _addr, mut stream) =
|
||||
accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader);
|
||||
|
||||
let conn_id = app
|
||||
.world()
|
||||
.resource::<BridgeResource>()
|
||||
.reader_ids()
|
||||
.first()
|
||||
.copied()
|
||||
.expect("reader must be installed");
|
||||
assert_eq!(conn_id, ConnectionId(0));
|
||||
|
||||
// Six distinct region-tile centers, matching the design doc's tiled
|
||||
// orbital-view worked example shape (a 3x2 or similar tile grid
|
||||
// around the entry point) — distinct centers so each is a genuinely
|
||||
// separate cache entry, not accidental coalescing onto one.
|
||||
let centers: [(i32, i32); 6] = [
|
||||
(0, 0),
|
||||
(12739, -3200),
|
||||
(12739, 3200),
|
||||
(0, -3200),
|
||||
(0, 3200),
|
||||
(6400, -3200),
|
||||
];
|
||||
|
||||
for ¢er in ¢ers {
|
||||
send_region_window_request(&mut stream, "GJ1c", center);
|
||||
}
|
||||
|
||||
let mut received: std::collections::BTreeSet<(i32, i32)> =
|
||||
std::collections::BTreeSet::new();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
|
||||
.expect("failed to set read timeout");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while received.len() < centers.len() {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out with only {}/{} tiles delivered: {:?}",
|
||||
received.len(),
|
||||
centers.len(),
|
||||
received
|
||||
);
|
||||
app.update();
|
||||
match read_framed(&mut stream) {
|
||||
Ok(Some(payload)) => {
|
||||
let resp: settled_reach_server::atlas::layer_proxy::AtlasLayerResponse =
|
||||
rmp_serde::from_slice(&payload)
|
||||
.expect("failed to decode AtlasLayerResponse");
|
||||
if let Some(window) = resp.district_window {
|
||||
received.insert(window.center);
|
||||
} else {
|
||||
// Pending/Ready-with-None for one of the six — re-send
|
||||
// ALL not-yet-received centers (mirrors the real
|
||||
// client's per-tile independent re-poll).
|
||||
for ¢er in centers.iter().filter(|c| !received.contains(c)) {
|
||||
send_region_window_request(&mut stream, "GJ1c", center);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => panic!("unexpected EOF reading atlas response"),
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(e) => panic!("failed to read atlas response frame: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
received.len(),
|
||||
centers.len(),
|
||||
"all six region tiles must be delivered to connection 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// The delivery-order counterpart: a SECOND connection's request must
|
||||
/// ALSO be served correctly (this already passed in the coordinator's
|
||||
/// live repro — included here as a same-file regression guard so a
|
||||
/// future fix to the id-0 case can't accidentally break id-1 while
|
||||
/// fixing id-0, and so this file documents the FULL observed shape of
|
||||
/// the bug, not just half of it).
|
||||
#[test]
|
||||
fn second_connection_also_receives_district_window() {
|
||||
let (resolver, params_reader, _root) = resolver_and_params_reader("GJ1c");
|
||||
// First connection occupies ConnectionId(0) via the SAME real
|
||||
// blocking-accept bootstrap main.rs uses (see
|
||||
// accept_first_connection_and_build_app's doc) but sends no
|
||||
// requests — isolates "is it specifically the SECOND id that works"
|
||||
// from "does a second connection existing change anything for the
|
||||
// first".
|
||||
let (mut app, addr, _first_stream) =
|
||||
accept_first_connection_and_build_app(resolver, params_reader, ConnectionRole::Reader);
|
||||
|
||||
let second_handle = thread::spawn(move || {
|
||||
let stream = TcpStream::connect(addr).expect("failed to connect");
|
||||
client_handshake(stream, ConnectionRole::Reader)
|
||||
});
|
||||
drive_app_accept_loop_until(&mut app, |a| {
|
||||
a.world().resource::<BridgeResource>().reader_count() == 2
|
||||
});
|
||||
let mut second_stream = second_handle.join().expect("client thread panicked");
|
||||
|
||||
let second_id = app
|
||||
.world()
|
||||
.resource::<BridgeResource>()
|
||||
.reader_ids()
|
||||
.get(1)
|
||||
.copied()
|
||||
.expect("second reader must be installed");
|
||||
assert_eq!(second_id, ConnectionId(1));
|
||||
|
||||
send_region_window_request(&mut second_stream, "GJ1c", (0, 0));
|
||||
let resp = drive_app_until_window_response(&mut app, &mut second_stream, "GJ1c", (0, 0));
|
||||
|
||||
assert_eq!(resp.status, AtlasLayerStatus::Ready);
|
||||
assert!(resp.district_window.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal `ObserverSnapshot` for the tests above — same shape as
|
||||
/// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized
|
||||
/// only by `tick` (the one field these tests assert on).
|
||||
|
||||
Reference in New Issue
Block a user