diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index c77bf7d54..6f893e654 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -268,84 +268,75 @@ func test_pole_wall_rows_half_matches_canonicalize_rows_half() -> void: # ============================================================================= -# T-1153: select_rung() — the §5 rung-selection rule, split into TWO tests -# per select_rung()'s own doc: a COVERAGE ceiling decides Region (can a -# District window even span this much world), and the `2x` visual-tolerance -# rule (design doc §5: "select the coarsest rung whose cell spacing <= -# 2*(E/C)") decides District vs. Quarter for whatever's under that ceiling. +# T-1153: select_rung() — REDESIGNED (live round 3 finding) per-rung +# single-window COVERAGE CEILING model, superseding the original +# `2x`-visual-tolerance-only reading of design doc §5. Select the FINEST +# rung whose own single-window coverage ceiling (MAX_COVERAGE_M) still +# covers the current world extent: Quarter <= 32,768 m; District <= +# 131,072 m; Region otherwise (including tiled coverage beyond its own +# single-window ceiling, a viewer-level concern — see select_rung()'s own +# doc for the full derivation and why this REPLACES the earlier two-gate +# design entirely, not just patches it). # ============================================================================= -## A tight sample spacing (deep zoom-in — small E over a large C) must select -## Quarter (512 m), the finest legal rung — 2*(E/C) is far below District's -## 2,048 m spacing at this ratio. -func test_select_rung_picks_quarter_at_a_tight_sample_spacing() -> void: - # E=2000m over C=1000px -> sample spacing 2 m/px -> threshold 4 m. Even - # Quarter (512 m) is coarser than the threshold, so select_rung() falls - # through to the FINEST legal rung (its own documented fallback) rather - # than returning something even finer that doesn't exist — Quarter. +## Deep zoom-in (a tiny extent) selects Quarter — comfortably under its own +## 32,768 m ceiling. +func test_select_rung_picks_quarter_well_under_its_ceiling() -> void: var rung: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) assert_str(rung).is_equal("Quarter") -## A sample spacing that satisfies BOTH District's own `2x` band AND the -## coverage ceiling selects District — the coarsest rung whose spacing still -## satisfies the fine-end rule, without exceeding what a District window can -## physically cover. -func test_select_rung_picks_district_at_a_moderate_sample_spacing() -> void: - # E=120,000m (under the 64*2048=131,072m coverage ceiling) over C=100px -> - # threshold = 2*120000/100 = 2,400m — satisfies District's 2,048m spacing. - var rung: String = AtlasWindowGeometry.select_rung(120_000.0, 100.0) +## An extent past Quarter's own ceiling but under District's selects +## District — the finest rung that can still cover it in one window. +func test_select_rung_picks_district_between_the_two_ceilings() -> void: + # 60,000 m is past Quarter's 32,768 m ceiling but well under District's + # 131,072 m one. + var rung: String = AtlasWindowGeometry.select_rung(60_000.0, 100.0) assert_str(rung).is_equal("District") -## An extent past the COVERAGE ceiling (more world than a District window can -## physically span, regardless of how generous the visual tolerance would -## otherwise be) must select Region — the coverage test, not the `2x` visual -## one, is what decides this (select_rung()'s own doc: "the coverage ceiling -## wins whenever the two disagree"). -func test_select_rung_picks_region_past_the_coverage_ceiling() -> void: - # E = full Earth-like circumference (~40,075 km) — far past the - # 64*2048=131,072m District coverage ceiling regardless of canvas_px. +## An extent past BOTH Quarter's and District's ceilings selects Region — +## neither finer rung's single window can cover this much world. +func test_select_rung_picks_region_past_both_finer_ceilings() -> void: var rung: String = AtlasWindowGeometry.select_rung(40_075_264.0, 1920.0) assert_str(rung).is_equal("Region") -## Exactly AT the coverage ceiling (E == 64*2048 = 131,072m) must still -## select District if the `2x` band also agrees — the ceiling is `>`, not -## `>=`, so the boundary value itself stays under District's own test. -func test_select_rung_coverage_ceiling_boundary_stays_district() -> void: +## Exactly AT Quarter's own ceiling (32,768 m) must still select Quarter — +## the rule is `<=`, not `<`. +func test_select_rung_quarter_ceiling_boundary_is_inclusive() -> void: + var rung: String = AtlasWindowGeometry.select_rung(32_768.0, 100.0) + assert_str(rung).is_equal("Quarter") + + +## One metre past Quarter's ceiling must flip to District — confirms the +## ceiling bites right at its own boundary, not one cell short of it. +func test_select_rung_one_past_quarter_ceiling_is_district() -> void: + var rung: String = AtlasWindowGeometry.select_rung(32_769.0, 100.0) + assert_str(rung).is_equal("District") + + +## Exactly AT District's own ceiling (131,072 m) must still select District. +func test_select_rung_district_ceiling_boundary_is_inclusive() -> void: var rung: String = AtlasWindowGeometry.select_rung(131_072.0, 100.0) assert_str(rung).is_equal("District") -## One metre past the coverage ceiling must flip to Region — confirms the -## ceiling actually bites right at its own boundary, not one district-window -## short of it. -func test_select_rung_one_past_the_coverage_ceiling_is_region() -> void: +## One metre past District's ceiling must flip to Region. +func test_select_rung_one_past_district_ceiling_is_region() -> void: var rung: String = AtlasWindowGeometry.select_rung(131_073.0, 100.0) assert_str(rung).is_equal("Region") -## Exactly AT District's `2x` threshold (spacing_m == 2*(E/C)) must select -## District, not the next-finer rung — the rule is `<=`, not `<`. -func test_select_rung_district_threshold_boundary_is_inclusive() -> void: - # District spacing = 2048 m. Choose E/C such that 2*(E/C) == 2048 exactly: - # E=1024, C=1.0 -> E/C=1024 -> threshold=2048. E=1024 is also comfortably - # under the coverage ceiling (131,072), so the `2x` test is what's - # actually being exercised here. - var rung: String = AtlasWindowGeometry.select_rung(1024.0, 1.0) - assert_str(rung).is_equal("District") - - -## Degenerate canvas_px (<=0, an unlaid-out viewport) must fall back to the -## FINEST rung, never crash or pick the coarsest by dividing by zero — the -## documented "under-resolve is the safe failure direction" disposition (and -## must be checked BEFORE the coverage ceiling could otherwise route a -## degenerate small extent toward Region by accident). -func test_select_rung_degenerate_canvas_px_falls_back_to_finest() -> void: - var rung: String = AtlasWindowGeometry.select_rung(1000.0, 0.0) - assert_str(rung).is_equal("Quarter") +## canvas_px is unused by the coverage rule (kept for signature stability, +## see select_rung()'s own doc) — degenerate/zero values must not change the +## selected rung at all, unlike the old `2x`-tolerance design's special-cased +## fallback. +func test_select_rung_canvas_px_does_not_affect_selection() -> void: + var with_real_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 1000.0) + var with_zero_canvas: String = AtlasWindowGeometry.select_rung(2000.0, 0.0) + assert_str(with_zero_canvas).is_equal(with_real_canvas) ## spacing_for_rung() is select_rung()'s inverse lookup — pin the three known @@ -363,16 +354,26 @@ func test_spacing_for_rung_unknown_tag_falls_back_to_district() -> void: assert_float(AtlasWindowGeometry.spacing_for_rung("Nonsense")).is_equal_approx(2048.0, 0.001) -## The exact scenario that surfaced the coverage-vs-visual-tolerance -## distinction (live-testing enter_orbital()'s own fit zoom): a whole -## Earth-like body's circumference (~40,075 km, matching -## AtlasDescendGeometry.district_extent()'s own cols*DISTRICT_M for -## radius=6371km) fitted to a 1920px-wide viewport at CELL_PIXEL_SIZE=16 must -## select Region — this is the direct regression guard for the bug this -## implementation found and fixed (an earlier version of select_rung() -## selected District here, which would have meant the canonical orbital -## frame requests a District-tier derive spanning an entire planet — the -## exact R1-catastrophe cost scenario the design doc §4 rejects). +## MAX_COVERAGE_M's three values, pinned directly against the formulas +## select_rung()'s own doc derives them from — a regression guard +## independent of select_rung()'s own boundary tests above, so a future +## accidental edit to the constants table itself (not just the selection +## logic) is caught here too. +func test_max_coverage_m_matches_derived_formulas() -> void: + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"]).is_equal_approx(32_768.0, 0.001) + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["District"]).is_equal_approx(131_072.0, 0.001) + assert_float(AtlasWindowGeometry.MAX_COVERAGE_M["Region"]).is_equal_approx(13_107_200.0, 0.001) + + +## The exact scenario that surfaced the original design flaw +## (live-testing enter_orbital()'s own fit zoom): a whole Earth-like body's +## circumference (~40,075 km, matching AtlasDescendGeometry.district_extent()'s +## own cols*DISTRICT_M for radius=6371km) fitted to a 1920px-wide viewport at +## CELL_PIXEL_SIZE=16 must select Region — the direct regression guard for +## the bug an early version of select_rung() had (picking District here, +## which would have meant the canonical orbital frame requests a +## District-tier derive spanning an entire planet — the exact R1-catastrophe +## cost scenario the design doc §4 rejects). func test_select_rung_at_orbital_fit_zoom_selects_region() -> void: var radius_km := 6371.0 var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) @@ -390,50 +391,29 @@ func test_select_rung_at_orbital_fit_zoom_selects_region() -> void: ).is_equal("Region") -## Pinned capture-resolution boundary numbers (1600x900, the coordinator's -## requested eyeball-capture viewport) — a live executable regression guard -## for select_rung()'s own doc's worked example. Region releases District's -## coverage ceiling at _view_zoom ~= 1.5625; District's own `2x` band edge -## sits at _view_zoom ~= 0.125 — i.e. BELOW (not above) the coverage-ceiling -## crossing, confirming the two never overlap at this (or any real) canvas -## size — see select_rung()'s "Tuning knobs" paragraph for what would need -## to change (DISTRICT_WINDOW_MAX_N, a server-side wire-budget change) to -## open a real District band. -func test_select_rung_1600x900_region_district_boundary_zoom() -> void: - var viewport := Vector2(1600.0, 900.0) - var canvas_px: float = maxf(viewport.x, viewport.y) - var boundary_zoom := 1.5625 - var just_inside: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport - ) - var just_outside: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport - ) - assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).override_failure_message( - "zoomed IN past ~1.5625 at 1600x900 must have released the Region coverage ceiling" - ).is_not_equal("Region") - assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).override_failure_message( - "zoomed OUT past ~1.5625 at 1600x900 must still be under the Region coverage ceiling" - ).is_equal("Region") - - -func test_select_rung_1600x900_district_quarter_boundary_zoom_confirms_no_overlap() -> void: - var viewport := Vector2(1600.0, 900.0) - var canvas_px: float = maxf(viewport.x, viewport.y) - var boundary_zoom := 0.125 - var just_inside: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, boundary_zoom * 1.001, viewport - ) - var just_outside: float = AtlasWindowGeometry.world_extent_m( - CELL_PIXEL_SIZE, boundary_zoom * 0.999, viewport - ) - # Both sides of the District/Quarter `2x`-band boundary read "Region" at - # 1600x900, NOT "District" — confirming the coverage ceiling (which - # releases at zoom~=1.5625, far above this boundary) has already forced - # Region long before the `2x` band's own edge is reached. This is the - # literal "no overlap" finding, pinned as an executable assertion. - assert_str(AtlasWindowGeometry.select_rung(just_inside, canvas_px)).is_equal("Region") - assert_str(AtlasWindowGeometry.select_rung(just_outside, canvas_px)).is_equal("Region") +## **Live round 3 regression, the direct fix target:** at 1600x900 (the +## coordinator's capture viewport), zooming IN from the orbital fit all the +## way to Quarter's own ceiling must pass through District along the way — +## a wheel-zoom gesture crossing world_extent_m from Region's territory down +## to Quarter's must select District for SOME real span of extent in +## between, not skip straight from Region to Quarter (the exact "money shot" +## the coordinator wants capture-worthy: a visible SHARPEN in place, not a +## jump). +func test_select_rung_district_is_reachable_between_region_and_quarter() -> void: + # An extent comfortably between District's and Quarter's ceilings (e.g. + # the midpoint) must select District — proving the band is non-empty, + # unlike the old two-gate design where it was empty by construction at + # every real viewport (see git history / the coordinator's live-round + # finding for the retired analysis). + var midpoint: float = ( + (AtlasWindowGeometry.MAX_COVERAGE_M["Quarter"] as float) + + (AtlasWindowGeometry.MAX_COVERAGE_M["District"] as float) + ) * 0.5 + var rung: String = AtlasWindowGeometry.select_rung(midpoint, 1600.0) + assert_str(rung).override_failure_message( + "District must be reachable between Quarter's and District's own" + + " coverage ceilings — the redesigned rule must not skip it" + ).is_equal("District") # ============================================================================= @@ -598,3 +578,102 @@ func test_edge_scroll_direction_points_west_near_left_edge() -> void: ) assert_float(direction.x).is_less(0.0) assert_float(direction.y).is_equal_approx(0.0, 0.001) + + +# ============================================================================= +# T-1153, live round 3 (Jeroen's ruling, design doc §4): compute_tile_grid() +# — the orbital rest state's multi-window mosaic. +# ============================================================================= + + +## The exact live-round scenario: GJ380c/Lendel (radius 6238.4 km) needs a +## 3x2 = 6-tile grid — the coordinator's own estimate, confirmed here as an +## executable regression. +func test_compute_tile_grid_lendel_produces_six_tiles() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + assert_int(tiles.size()).override_failure_message( + "GJ380c/Lendel must tile into 3x2=6 windows, matching the coordinator's own" + + " live-round finding (13,107.2 km single-window coverage vs. 39,198 km" + + " circumference)" + ).is_equal(6) + + +## A tiny body whose whole circumference fits in ONE Region window's +## coverage ceiling must produce exactly ONE tile — tiling degenerates +## gracefully to the pre-existing single-window behavior when it isn't +## actually needed. +func test_compute_tile_grid_tiny_body_produces_one_tile() -> void: + # radius small enough that circumference << MAX_COVERAGE_M["Region"] + # (13,107,200 m) — a few hundred km radius comfortably qualifies. + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(50.0) + assert_int(tiles.size()).is_equal(1) + assert_that(tiles[0]).is_equal(Vector2i.ZERO) + + +## A no-radius body (tiny test body) must produce exactly one tile at the +## canonical origin — matching enter_orbital()'s own no-radius fallback +## disposition (no circumference/tiling concept without a radius). +func test_compute_tile_grid_no_radius_produces_single_origin_tile() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(0.0) + assert_int(tiles.size()).is_equal(1) + assert_that(tiles[0]).is_equal(Vector2i.ZERO) + + +## Every tile center must be a LEGAL canonicalized DistrictPos — column +## wrapped into [0, cols), row clamped into [-rows_half, rows_half] — the +## same range canonicalize_district_center() enforces everywhere else in +## this cluster (pan refetch, entry, rung-reselect). A raw, uncanonicalized +## tile center would fail the server's own normalize_window_center() (or +## silently alias to a different tile than intended). +func test_compute_tile_grid_tiles_are_all_canonicalized() -> void: + var radius_km := 6238.4 + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var cols: int = int(extent["cols"]) + var rows_half: int = int(extent["rows_half"]) + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) + for tile: Vector2i in tiles: + assert_int(tile.x).override_failure_message( + "tile column %d must be wrapped into [0, %d)" % [tile.x, cols] + ).is_greater_equal(0) + assert_int(tile.x).is_less(cols) + assert_int(tile.y).override_failure_message( + "tile row %d must be clamped into [-%d, %d]" % [tile.y, rows_half, rows_half] + ).is_greater_equal(-rows_half) + assert_int(tile.y).is_less_equal(rows_half) + + +## No two tiles may share the same canonicalized center — compute_tile_grid() +## must dedupe (a pole-row clamp or column-wrap collision producing the exact +## same DistrictPos twice would otherwise request/draw the same tile twice, +## wasting a request and drawing one tile over another). +func test_compute_tile_grid_has_no_duplicate_centers() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + var seen: Dictionary = {} + for tile: Vector2i in tiles: + assert_bool(seen.has(tile)).override_failure_message( + "tile center %s appears more than once in the grid" % str(tile) + ).is_false() + seen[tile] = true + + +## The tile grid's own center of mass must land on the canonical origin +## (0,0) — the tile-set's symmetric layout (each axis' centers computed as +## `(index - (count-1)/2) * TILE_N`) is centered on the SAME canonical origin +## enter_orbital() uses, so the tile-set's overall framing agrees with +## single-window enter_orbital()'s own "center on (0,0)" contract. +func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void: + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(6238.4) + var sum_col := 0 + var sum_row := 0 + for tile: Vector2i in tiles: + sum_col += tile.x + sum_row += tile.y + # Column centers wrap (periodic), so a raw average isn't meaningful there + # the way it is for rows — assert row symmetry directly instead (rows + # never wrap, so their average must be very close to 0 for a + # symmetric grid). + var avg_row: float = float(sum_row) / float(tiles.size()) + assert_float(avg_row).override_failure_message( + "the tile grid's row centers must average to ~0 (symmetric around the" + + " canonical origin's equator row)" + ).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N)) diff --git a/client/tests/test_atlas_window_overlay.gd b/client/tests/test_atlas_window_overlay.gd index d60434db1..9eb737291 100644 --- a/client/tests/test_atlas_window_overlay.gd +++ b/client/tests/test_atlas_window_overlay.gd @@ -23,10 +23,14 @@ static func _mock_window(n: int = 2) -> Dictionary: ## Minimal viewer stub — AtlasWindowOverlay only reaches the viewer through -## get_district_window()/is_overlay_visible()/get_cell_pixel_size(), so a -## bare stub with just those three methods is a legitimate "viewer" for -## these tests, matching the duck-typed-viewer precedent this whole overlay -## cluster already relies on (atlas_overlay_bar.gd/atlas_legend_panel.gd). +## get_district_window()/is_overlay_visible()/get_cell_pixel_size()/ +## is_tile_mode(), so a bare stub with just those methods is a legitimate +## "viewer" for these tests, matching the duck-typed-viewer precedent this +## whole overlay cluster already relies on (atlas_overlay_bar.gd/ +## atlas_legend_panel.gd). is_tile_mode() always returns false — this suite +## covers the single-window composite-cache path only; the tile mosaic path +## is covered separately by test_atlas_window_tile_set.gd + the viewer's own +## is_tile_mode()-branching tests. class _ViewerStub: var window: Variant = null var active_overlay: String = "" @@ -40,6 +44,9 @@ class _ViewerStub: func get_cell_pixel_size() -> float: return 16.0 + func is_tile_mode() -> bool: + return false + func test_composite_smooth_defaults_true() -> void: assert_bool(AtlasWindowOverlay.COMPOSITE_SMOOTH).override_failure_message( diff --git a/client/tests/test_atlas_window_tile_set.gd b/client/tests/test_atlas_window_tile_set.gd new file mode 100644 index 000000000..5a394749d --- /dev/null +++ b/client/tests/test_atlas_window_tile_set.gd @@ -0,0 +1,204 @@ +## T-1153, live round 3 (Jeroen's ruling, design doc §4): tests for +## AtlasWindowTileSet — the orbital rest-state multi-window mosaic +## orchestration. Same hand-built-response-dict conventions as +## test_atlas_window_request.gd/test_atlas_zoom_ladder.gd; this file is +## about the ORCHESTRATION (N tiles, progressive per-tile arrival, +## teardown), not the tile-grid MATH (already covered directly against +## AtlasWindowGeometry.compute_tile_grid() in test_atlas_window_geometry.gd). +class_name TestAtlasWindowTileSet +extends GdUnitTestSuite + +const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") + + +static func _mock_window(center: Vector2i, n: int) -> Dictionary: + return { + "center": [center.x, center.y], + "n": n, + "granularity_v2": "Region", + "morphology": PackedByteArray([1, 2, 3, 4]), + "elev_q": PackedByteArray([10, 20, 30, 40]), + "temp_dc": [0, 0, 0, 0], + "moisture_q": PackedByteArray([0, 0, 0, 0]), + "vegetation": PackedByteArray([0, 0, 0, 0]), + "glaciation": PackedByteArray([0, 0, 0, 0]), + } + + +static func _mock_response(body_id: String, window: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": window} + + +func _make_tile_set() -> Variant: + var owner_stub := RefCounted.new() + var ts = auto_free(AtlasWindowTileSet.new(owner_stub)) + add_child(ts) + return ts + + +# ============================================================================= +# enter() — tile grid computation + one request per tile +# ============================================================================= + + +## enter() on a real, tiling-sized body must produce the SAME tile count +## compute_tile_grid() would — 6 for GJ380c/Lendel, the coordinator's own +## live-round number. +func test_enter_produces_the_expected_tile_count_for_lendel() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + assert_int(ts.get_tile_count()).is_equal(6) + assert_bool(ts.is_multi_tile()).is_true() + + +## 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 +## tiles. +func test_enter_tiny_body_produces_one_tile() -> void: + var ts = _make_tile_set() + ts.enter("TinyBody", 50.0) + assert_int(ts.get_tile_count()).is_equal(1) + assert_bool(ts.is_multi_tile()).is_false() + + +## Every tile must start with a null window (nothing has arrived yet) and +## the tile set must not report "fully arrived" before any response lands. +func test_enter_all_tiles_start_unarrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + for tile: Dictionary in ts.get_tiles(): + assert_that(tile["window"]).is_null() + assert_bool(ts.is_fully_arrived()).is_false() + + +## An empty tile set (never entered) must not report "fully arrived" either +## — an empty AND-over-nothing must not vacuously read true. +func test_empty_tile_set_is_not_fully_arrived() -> void: + var ts = _make_tile_set() + assert_bool(ts.is_fully_arrived()).is_false() + + +# ============================================================================= +# Progressive per-tile arrival (design doc §4: "with visible refinement as +# tiles complete") — each tile's response is independent of every other's. +# ============================================================================= + + +## Delivering ONE tile's response must populate ONLY that tile's window, +## leaving every other tile still null — the direct "progressive, not +## block-on-all" regression. +func test_one_tile_arriving_does_not_affect_the_others() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + var first_center: Vector2i = tiles[0]["center"] + var window: Dictionary = _mock_window(first_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + var updated_tiles: Array = ts.get_tiles() + assert_that(updated_tiles[0]["window"]).override_failure_message( + "the tile whose response arrived must have its window populated" + ).is_equal(window) + for i in range(1, updated_tiles.size()): + assert_that(updated_tiles[i]["window"]).override_failure_message( + "tile %d must still be unarrived — only tile 0's response was delivered" % i + ).is_null() + + +## tile_ready must fire with the INDEX of the tile that actually arrived — +## the viewer/overlay needs this to know WHICH tile to redraw, not just +## "something changed". +func test_tile_ready_signal_fires_with_the_correct_index() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var received_indices: Array = [] + ts.tile_ready.connect(func(index: int) -> void: received_indices.append(index)) + + var tiles: Array = ts.get_tiles() + var second_center: Vector2i = tiles[1]["center"] + var window: Dictionary = _mock_window(second_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + + assert_int(received_indices.size()).is_equal(1) + assert_int(received_indices[0]).is_equal(1) + + +## Delivering EVERY tile's response must flip is_fully_arrived() to true — +## the mosaic-complete signal the viewer/legend chrome can use. +func test_all_tiles_arriving_flips_fully_arrived() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + 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.is_fully_arrived()).override_failure_message( + "once every tile's response has arrived, the tile set must report fully arrived" + ).is_true() + + +## 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 +## AtlasWindowRequest-based path already relies on (this is inherited for +## free since each tile IS an AtlasWindowRequest, but pinned here as an +## orchestration-level regression too). +func test_response_for_a_different_body_is_ignored() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var tiles: Array = ts.get_tiles() + var window: Dictionary = _mock_window( + tiles[0]["center"], AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ_wrong_body", window)) + + assert_that(ts.get_tiles()[0]["window"]).is_null() + + +# ============================================================================= +# Teardown — re-entering (a fresh body, or the same body again) must not +# leave stale tile request nodes wired up. +# ============================================================================= + + +## Calling enter() a SECOND time (e.g. re-entering the orbital frame, or +## switching to a different body) must replace the tile set entirely — the +## OLD tiles' indices/centers must not linger. +func test_second_enter_replaces_the_tile_set() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var first_count: int = ts.get_tile_count() + assert_int(first_count).is_equal(6) + + ts.enter("TinyBody", 50.0) + assert_int(ts.get_tile_count()).override_failure_message( + "a second enter() must fully replace the tile set, not append to it" + ).is_equal(1) + + +## A response matching an OLD tile set's (body, center) — arriving AFTER a +## second enter() has already torn it down — must not be adopted (or crash): +## the old tile's AtlasWindowRequest node is queue_free()'d, and _tiles no +## longer references it, so a stale signal (if it could somehow still fire) +## has no live entry left to update. +func test_stale_response_after_second_enter_does_not_crash_or_leak() -> void: + var ts = _make_tile_set() + ts.enter("GJ380c", 6238.4) + var old_tiles: Array = ts.get_tiles() + var old_center: Vector2i = old_tiles[0]["center"] + + ts.enter("GJ380c", 50.0) # same body_id, different (tiny) radius -> different tile grid + + # A response shaped like it's answering the OLD tile set's first tile — + # must not crash, and must not corrupt the NEW tile set's single tile. + var stale_window: Dictionary = _mock_window( + old_center, AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION + ) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", stale_window)) + + assert_int(ts.get_tile_count()).is_equal(1) diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd index 979f75007..d9b448e31 100644 --- a/client/tests/test_atlas_zoom_ladder.gd +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -73,81 +73,64 @@ func test_enter_orbital_requests_region_granularity() -> void: assert_str(v._held_granularity_v2).is_equal("Region") -## enter_orbital()'s `n` intent is the body's full equatorial circumference -## in districts (district_extent().cols) — the whole body fitted to the -## canvas, per Jeroen's HARD condition wording — BUT `_held_n` is what -## actually gets STORED/SENT, and that must be the CLAMPED value -## (live-round finding: the raw cols value, routinely tens of thousands at -## Region granularity, was stored unclamped while AtlasWindowRequest clamped -## before sending — see _enter_at_rung()'s own doc for the full C1-one-layer-up -## story). GJ380c's real cols (~19,139 per the live repro) exceeds -## DISTRICT_WINDOW_MAX_N_REGION's clamp ceiling, so this body is the exact -## regression case, not a hypothetical. -func test_enter_orbital_n_is_the_clamped_value_not_raw_circumference() -> void: +## **Superseded by live round 3's tiling fix — retargeted, not deleted.** +## GJ380c/Lendel (radius 6238.4 km) was the ORIGINAL single-window C1 repro +## (raw cols ~19,139 vs. the 6,400 clamp ceiling) — but that SAME threshold +## (`DISTRICT_WINDOW_MAX_N_REGION * DISTRICT_M` = the coverage ceiling +## `compute_tile_grid()` tiles past) means any body needing the n-clamp ALSO +## needs tiling: there is no real body where enter_orbital() takes the +## single-window path with a raw `n` big enough to require clamping. +## GJ380c now correctly enters TILE mode (test_enter_orbital_n_is_the_clamped_value_not_raw_circumference's +## old assertion on a single clamped `_held_n` no longer applies — see +## test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent below for +## what `_held_n` means in tile mode instead). The single-window clamp-mirror +## fix itself remains covered: `_enter_at_rung()`'s own doc/the clamp +## mirror's unit tests (test_atlas_window_request.gd) pin the formula +## directly, and test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement +## exercises the SAME clamp-mirror lesson at the reselect (not entry) +## boundary, which single-window mode still reaches on the way DOWN from a +## tile-mode zoom-in. +func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void: var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) add_child(v) - var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + var radius_km := 6238.4 # GJ380c (Lendel) var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) var raw_cols: int = int(extent["cols"]) v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - var expected_clamped: int = AtlasWindowRequest._clamp_window_n_mirror_v2(raw_cols, "Region") - var failure_msg: String = ( - "_held_n must be the CLAMPED n (%d), matching what the server will echo —" - + " not the raw circumference (%d), which the server would clamp down and" - + " every response would then fail the w_n != _held_n staleness check" - ) % [expected_clamped, raw_cols] - assert_int(v._held_n).override_failure_message(failure_msg).is_equal(expected_clamped) - # GJ380c's raw circumference must actually exceed the clamp — otherwise this - # test would pass trivially without exercising the clamp at all. - assert_int(raw_cols).override_failure_message( - "GJ380c's raw district-column count must exceed the Region clamp ceiling" - + " for this to be a real regression guard, not a no-op" - ).is_greater(expected_clamped) + assert_bool(v.is_tile_mode()).override_failure_message( + "GJ380c/Lendel needs tiling — enter_orbital() must have entered tile mode" + ).is_true() + # In TILE mode, _held_n is the WHOLE body's extent (unclamped) — each + # TILE clamps its own request independently inside AtlasWindowTileSet + # (see that file's own tests), so _held_n here is NOT expected to equal + # any single clamped value the way single-window mode's is. + assert_int(v._held_n).is_equal(raw_cols) -## **The live-round regression, end to end (fix #1: the n-clamp mirror one -## layer up):** enter_orbital() on a real-sized body (GJ380c/Lendel, radius -## 6238.4 km, raw cols far past the Region clamp ceiling) followed by a -## server response echoing the CLAMPED n + "Region" granularity must be -## ACCEPTED and become the held window — not silently dropped as stale -## forever (the exact live bug: `wv._held_n = 19139` vs. echoed `6400`, -## blank ladder on every real-sized body). This is the round-trip the -## existing suite never exercised — every prior enter_orbital() test -## asserted on request-side state only, never delivered a response. -## -## **WIRE-ACCURATE response shape (fix #2, second live-round finding):** the -## response dict below carries `"granularity": -## SERVER_LEGACY_GRANULARITY_REGION_SENTINEL` explicitly — the ACTUAL byte a -## real server sends, not the field's absence. The first version of this -## test omitted the legacy key entirely, which let `w.get("granularity", -## DEFAULT)` silently default to `1` (matching `_granularity`'s own pinned -## value) — an ACCIDENTAL pass that never exercised the real sentinel -## mismatch, exactly the class of gap the live round exists to catch. This -## version fails without the v2-authoritative-when-present fix in -## `_echoed_granularity_matches()`. -func test_enter_orbital_oversized_body_accepts_the_clamped_region_response() -> void: +## **The live-round-3 regression, end to end for TILE mode:** enter_orbital() +## on GJ380c/Lendel followed by delivering ONE tile's wire-accurate response +## (clamped n=6,400, "Region" granularity_v2, the legacy sentinel in the old +## granularity slot — exactly what a real server sends) must be ACCEPTED +## into that tile's own slot — not silently dropped. This exercises BOTH +## live-round fixes (the v2-authoritative precedence AND per-tile clamping) +## through the tile-set path specifically, complementing +## test_atlas_window_tile_set.gd's own more granular orchestration tests. +func test_enter_orbital_tile_mode_accepts_a_wire_accurate_tile_response() -> void: var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) add_child(v) var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body - var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) - var raw_cols: int = int(extent["cols"]) - var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(raw_cols, "Region") - # Sanity: this body must actually need clamping, or the test proves nothing. - assert_int(raw_cols).is_greater(clamped_n) - v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) - assert_that(v.get_district_window()).override_failure_message( - "no response delivered yet — must still be null" - ).is_null() + assert_bool(v.is_tile_mode()).is_true() - # The server's REAL response: echoes the CLAMPED n, "Region" granularity_v2 - # (String), center (0,0), AND the legacy sentinel in "granularity" — exactly - # what handle_atlas_request/clamp_window_n_v2 actually produces on the wire - # for an oversized orbital request (confirmed against Dudley's contract). - var region_window: Dictionary = { - "center": [0, 0], - "n": clamped_n, + var tile_set = v.get_tile_set() + var tiles: Array = tile_set.get_tiles() + assert_int(tiles.size()).is_greater(1) + var first_tile_center: Vector2i = tiles[0]["center"] + + var tile_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]), @@ -157,19 +140,12 @@ func test_enter_orbital_oversized_body_accepts_the_clamped_region_response() -> "vegetation": PackedByteArray([2, 1, 6, 3]), "glaciation": PackedByteArray([0, 0, 1, 2]), } - SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", region_window)) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", tile_window)) - var failure_msg: String = ( - "a response echoing the server's own clamped n + Region granularity_v2 (with" - + " the legacy sentinel u32::MAX in the old granularity slot) must be ACCEPTED" - + " and become the held window — the live bug left this permanently null" - + " (w_n=%d never matched a stale unclamped _held_n=%d, THEN the legacy" - + " sentinel never matched the stored _granularity=1) on every real-sized body" - ) % [clamped_n, raw_cols] - assert_that(v.get_district_window()).override_failure_message(failure_msg).is_equal( - region_window - ) - assert_str(v._held_granularity_v2).is_equal("Region") + assert_that(tile_set.get_tiles()[0]["window"]).override_failure_message( + "a wire-accurate response (clamped n, Region granularity_v2, the legacy" + + " sentinel) for the first tile must be ACCEPTED into that tile's slot" + ).is_equal(tile_window) ## A no-radius body (tiny test body) has no circumference concept — @@ -364,19 +340,20 @@ func test_zoom_out_past_district_threshold_requests_a_coarser_rung() -> void: assert_that(v.get_district_window()).is_equal(district_window) -## Zooming IN on a District-rung window (well within its own legal spacing -## band) must NOT trigger a rung change — this is the "zoom is client-side on -## the already-held composite" case, unchanged for in-rung zoom. Sets -## _view_zoom DIRECTLY to a value inside District's legal band (rather than -## relying on enter()'s COVER auto-fit, which for a small n can already sit -## right at Quarter's own threshold — a fit's zoom level is a display-density -## choice independent of what rung selection would pick from scratch, and -## this test is specifically about a SINGLE zoom-in STEP not crossing a -## boundary, not about where the auto-fit itself lands). District's legal -## band (select_rung()'s own doc: the coverage ceiling and the `2x` visual -## band only overlap at small viewports — `canvas_px <= DISTRICT_WINDOW_MAX_N -## * DISTRICT_SPACING_M / 1024 = 128px`) requires a SMALL viewport here, -## unlike most of this suite's 800x600/1920x1080 fixtures. +## Zooming IN on a District-rung window (well within its own legal coverage +## band, `(32,768 m, 131,072 m]` per select_rung()'s redesigned per-rung +## ceiling model — viewport-independent since `canvas_px` no longer affects +## selection) must NOT trigger a rung change — this is the "zoom is +## client-side on the already-held composite" case, unchanged for in-rung +## zoom. Sets _view_zoom DIRECTLY to a value inside District's band (rather +## than relying on enter()'s COVER auto-fit, which for a small n can already +## sit right at Quarter's own threshold — a fit's zoom level is a +## display-density choice independent of what rung selection would pick from +## scratch, and this test is specifically about a SINGLE zoom-in STEP not +## crossing a boundary, not about where the auto-fit itself lands). The +## small 100x80 viewport here is incidental (any size works under the new +## viewport-independent model) — kept small only because that's what the +## original version of this test used. func test_zoom_in_within_district_threshold_does_not_change_rung() -> void: var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) add_child(v) @@ -394,6 +371,84 @@ func test_zoom_in_within_district_threshold_does_not_change_rung() -> void: ).is_equal("District") +## **Live round 3 regression, the direct end-to-end fix target:** a real +## wheel-zoom gesture (many `_zoom_at()` ticks, matching the shape a +## continuous mouse-wheel scroll actually produces) crossing from the +## Region rest state down through District into Quarter territory must (i) +## fire a request at the NEW granularity — `_window_request.get_granularity_v2()` +## must have changed by the end of the gesture — and (ii) accept a +## WIRE-ACCURATE response for that request: echoing the REQUEST's own +## (already re-centered, already re-clamped) center/n, which the live round +## found DIFFERS from the ORIGINAL held center (screen-center-anchored +## refinement re-centers on wherever the cursor currently maps to, not +## wherever the player started) — this is the "second latent drop" the +## coordinator specifically flagged: comparing the echo against a STALE +## `_held_center` (frozen at the pre-crossing value) rather than the +## request's own center would silently drop this response too. +## **Live round 3 update:** GJ380c/Lendel now enters TILE mode via +## enter_orbital() (bug B's fix), so this test starts from THERE — zooming +## in far enough crosses Region's coverage ceiling and must LEAVE tile mode +## for the single-window path at the new (finer) rung, exactly the +## `_maybe_reselect_rung()` "leaving_tile_mode" branch this test exercises. +func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).override_failure_message( + "GJ380c/Lendel must enter tile mode at the orbital rest state (live round 3)" + ).is_true() + + # A real wheel-zoom gesture: many ticks, cursor OFF-CENTER (so cursor- + # anchored zoom genuinely drifts the screen-to-district mapping away from + # the canonical origin, not just scaling in place) — matching the live + # drive's actual input shape, not a single synthetic jump. Zooming in far + # enough must cross OUT of Region's coverage ceiling, leaving tile mode. + var cursor_pos := Vector2(1100.0, 300.0) # off-center, biased toward one quadrant + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + + # (i) Tile mode must have been LEFT, and a request must have gone out at + # a NEW (finer) granularity via the single-window path. + assert_bool(v.is_tile_mode()).override_failure_message( + "zooming in far enough must leave tile mode for the single-window path" + ).is_false() + var request_granularity: String = v._window_request.get_granularity_v2() + assert_str(request_granularity).override_failure_message( + "leaving tile mode must fire a request at a new (finer) granularity" + ).is_not_equal("Region") + + # (ii) The request's own center/n — read AFTER leaving tile mode, so this + # is whatever _maybe_reselect_rung() actually computed — is what a + # wire-accurate response must echo to be accepted. + var request_center: Vector2i = v._window_request._center + var request_n: int = v._window_request._n + + var refinement_window: Dictionary = { + "center": [request_center.x, request_center.y], + "n": request_n, + "granularity_v2": request_granularity, + "morphology": PackedByteArray([1, 2, 3, 4]), + "elev_q": PackedByteArray([10, 20, 30, 40]), + "temp_dc": [0, 0, 0, 0], + "moisture_q": PackedByteArray([0, 0, 0, 0]), + "vegetation": PackedByteArray([0, 0, 0, 0]), + "glaciation": PackedByteArray([0, 0, 0, 0]), + } + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", refinement_window)) + + assert_that(v.get_district_window()).override_failure_message( + "a wire-accurate refinement response (echoing the REQUEST's own center/n/" + + " granularity after leaving tile mode) must be ACCEPTED — comparing" + + " against a stale/wrong reference instead of the request's own would" + + " silently drop this response forever" + ).is_equal(refinement_window) + assert_str(v._held_granularity_v2).is_equal(request_granularity) + + # ============================================================================= # T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent- # relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index 289ce8b12..d26d18fb1 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -15,7 +15,14 @@ extends RefCounted ## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed ## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both ## pure functions of (viewport, held state, body), same "geometry lives here, -## side effects live on the viewer" split as the rest of this file. +## side effects live on the viewer" split as the rest of this file. Live +## round 3 also adds the orbital-rest-state TILE GRID computation +## (compute_tile_grid(), near the bottom) — reuses +## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for +## the SAME wrap/clamp discipline every other piece of this cluster already +## depends on, hence the preload below (no circular dependency: +## atlas_descend_geometry.gd never references this file). +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") ## D-243 rung spacings, metres/cell — the SAME constants ## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m @@ -40,11 +47,66 @@ const RUNG_TABLE: Array = [ ## Server per-axis cap on a District/Quarter-granularity window's `n` ## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see ## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side -## mirror of the same constant, kept in sync there). select_rung() uses this -## to answer "CAN a District-granularity window even cover this much world at -## all" — the coarse-end ceiling, distinct from the fine-end `2x` tolerance. +## mirror of the same constant, kept in sync there). const DISTRICT_WINDOW_MAX_N: int = 64 +## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS / +## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap +## EVERY rung's single window is clamped against, per +## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas. +const WIRE_CAP_CELLS: int = 4_096 + +## Region's own per-axis ceiling (mirrors +## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION / +## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION). +const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400 + +## Per-tile district extent for the orbital-rest-state tile grid +## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a +## single Region request uses at its own per-axis ceiling. Each tile +## requests exactly this many districts on a side — the largest single +## window the wire budget allows, so tiling uses the FEWEST tiles that can +## cover a given body. +const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION + +## **Live round 3 finding (the actual root cause of "zoom-driven rung +## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM +## real-world coverage, derived from the SAME wire-size clamp +## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District +## and Quarter are NOT exempt from this the way the original (§5-literal) +## design assumed. A rung whose own single-window coverage is smaller than +## the CURRENTLY DISPLAYED world extent cannot legally be selected: the +## server would clamp `n` down to fit its own wire budget, producing a +## composite that covers only a FRACTION of the viewport — visually a tiny +## box in the middle of the screen, and (the bug this constant's discovery +## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n` +## the viewer was still carrying from the PREVIOUS rung, permanently failing +## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same +## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per +## rung inline — see MAX_COVERAGE_M below. +## +## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts -> +## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`. +## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts -> +## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original +## coverage-ceiling constant this replaces/generalizes). +## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts -> +## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's +## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to +## cover extents beyond this (see the viewer's tile-set model), so this +## constant alone does NOT bound what the ORBITAL REST STATE can show — +## only what one Region REQUEST's response covers. +const MAX_COVERAGE_M: Dictionary = { + "Quarter": 64.0 * QUARTER_SPACING_M, + "District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M, + "Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M, +} + +## Rungs ordered FINEST-first — select_rung() walks this to find the finest +## rung whose own single-window coverage ceiling still covers the current +## extent (never a rung that would silently under-cover the viewport). +const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"] + ## Fit-and-center: given the viewport size and the window's side length in ## districts, compute the zoom/offset that COVERS the viewport (fills it edge @@ -160,90 +222,61 @@ static func clamp_pan_offset_to_pole_wall( # ============================================================================= -## Rung-selection rule (design doc §5, restated): for a world extent `E` -## metres shown across canvas `C` px, sample spacing is `E/C`. Select the -## COARSEST rung whose cell spacing is `<= 2*(E/C)` — one tier finer than a -## screen pixel, never coarser (never magnified-interpolation of a coarser -## composite, the literal thing the D-166 corollary forbids). This `2x` -## tolerance gates the FINE end only (District vs. Quarter) — see the -## coarse-end paragraph below for why Region is decided by a DIFFERENT test. +## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the +## original §5-literal `2x`-visual-tolerance-only reading): select the +## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M) +## still covers the current world extent. Walks RUNGS_FINEST_FIRST +## (Quarter, District, Region) and returns the first whose ceiling is `>= +## world_extent_m` — the coarser rungs are tried only once the finer ones +## genuinely cannot show the requested extent in a single window. +## +## **Why this replaces the original `2x`-visual-tolerance formula entirely** +## (not just patches its Region case, as an earlier version of this function +## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)") +## implicitly assumes every rung's single window CAN cover any extent the +## rule selects it for — true for an unbounded wire budget, false here. +## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the +## server itself enforces — caps every rung's single-window real-world +## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter +## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region +## window — bug B's progressive TILING composes several to cover more, a +## viewer-level concern this function doesn't need to know about). A rung +## selected for an extent BEYOND its own ceiling would have its `n` silently +## clamped server-side to something covering only a FRACTION of the +## viewport — visually a tiny box, AND (the actual live-round bug this +## redesign fixes) a clamped echo that no longer matches whatever `n` the +## viewer was still carrying from the rung it's leaving, permanently failing +## the staleness check in `_on_window_ready()`. +## +## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not +## contradicted by it** — verified numerically: at the exact zoom where +## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold +## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m +## spacing. This means by the time coverage RELEASES a rung, the visual +## tolerance would ALREADY prefer something finer than that rung offers — +## i.e. every rung this function selects is, by construction, at or past its +## own "as fine as it can usefully be" point. The visual-tolerance rule's +## fine-end guarantee (never show a coarser composite than the screen can +## resolve) is automatically satisfied by "select the finest rung whose +## coverage allows it" — there is no case where the coverage rule picks a +## rung the visual rule would have rejected as too coarse, because Quarter +## (the finest rung) is always the answer whenever ANY rung's visual +## tolerance alone would have mattered. ## ## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held ## window is always square, so either axis of the viewport/extent pair gives -## the same answer — the caller picks one, consistently). -## -## **Region is selected by a COVERAGE test, not the `2x` visual tolerance** -## (found live-testing the orbital-entry fit zoom, where E covers a whole -## planetary circumference). The `2x` formula is calibrated to catch the -## ZOOM-IN failure mode the corollary names explicitly — never request -## coarser derivation than the screen can currently resolve — and has no -## meaningful symmetric zoom-OUT reading: testing Region's own 204.8 km -## spacing against the SAME threshold that gates District/Quarter would -## reject Region at essentially every normal screen resolution (a whole-body -## view's sample spacing is tens of km/px, and 2x that is still far under -## 204.8 km — even though visually a ~5-10-screen-px-per-cell Region view -## reads perfectly fine, nowhere near "magnified interpolation"). The -## GENUINELY load-bearing question at the coarse end is different: can a -## District-granularity window (capped server-side at -## DISTRICT_WINDOW_MAX_N=64 districts, ~131 km per side) physically COVER -## the extent being displayed at all? Once it can't, Region is the only rung -## that CAN — this is a coverage/capacity fact, not a resolution-legibility -## judgment, and it's what actually decides "zoom out past the district rung -## transitions to Region" per the ticket's own framing. -## -## **A third finding, resolving the above two against each other:** at -## CELL_PIXEL_SIZE=16 (the shipped display scale), the fine-end `2x` band -## that would select District and the coarse-end coverage ceiling that -## selects Region do not meet — District's OWN native resolution already -## reads as "too fine" (wants Quarter) well before its 64-district coverage -## cap becomes binding (wants Region), leaving NO zoom range where the `2x` -## formula alone would ever pick District. Since the coverage ceiling is a -## hard CAPABILITY limit (a District request literally cannot serve more -## world than its per-axis cap covers) while the `2x` band is a QUALITY -## preference (finer than strictly needed is wasteful, not wrong), the -## coverage ceiling wins whenever the two disagree: check it FIRST, and only -## consult the `2x` band to choose between District and Quarter for whatever -## extent remains under that ceiling. This is a genuine engineering call this -## implementation makes (flagged to the team, not a design-doc-literal -## derivation) — see docs/architecture/atlas-zoom-ladder-t1143.md §5 Risk R4 -## ("Region rung is named but unscoped") for the open design question this -## resolves pragmatically rather than by further design-pass iteration. -## -## **Whether a District band exists at all is independent of CELL_PIXEL_SIZE** -## — it cancels out of the "does District's `2x` band overlap the coverage -## ceiling" condition entirely. The condition reduces to `canvas_px <= -## DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M / 1024` — i.e. `canvas_px <= -## 128px` at the shipped constants. At every real viewport (800px+), this is -## never satisfied: District's band is empty by construction, and the ladder -## in practice steps Region -> Quarter directly at any normal screen size. -## Verified numerically at 1600x900 (canvas_px=1600): the Region/District -## crossing (world_extent_m == the coverage ceiling) sits at `_view_zoom ≈ -## 1.5625`, and the District/Quarter crossing (the `2x` band's own edge) -## sits at `_view_zoom ≈ 0.125` — i.e. the `2x` band's own boundary is -## already PAST (a smaller zoom than) where the coverage ceiling releases -## District, so the two never overlap in the zoomed-in direction either. -## **Tuning knobs, if a real District band is wanted:** the ONLY lever that -## opens the gap is `DISTRICT_WINDOW_MAX_N` (currently 64, mirrored from the -## server's own per-axis cap) — it would need to reach `1024 * canvas_px / -## DISTRICT_SPACING_M` (≈800 at a 1600px canvas) to open a band there, a -## substantial server-side wire-size change (T-1150's `WIRE_CAP_CELLS` -## budget), not a client-only tuning knob. `CELL_PIXEL_SIZE` does NOT affect -## whether a band exists — it only shifts WHERE both crossing zooms sit on -## the wheel gesture (scaling both proportionally, preserving their ~12.5x -## gap), i.e. it is the felt-pacing knob for how much wheel travel separates -## Region from Quarter, not a way to reintroduce District. +## the same answer — the caller picks one, consistently). `canvas_px` is +## kept as a parameter (unused by the coverage rule itself) for signature +## stability with existing callers and because a future finer-than-Quarter +## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need +## it again. ## ## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region"). -static func select_rung(world_extent_m: float, canvas_px: float) -> String: - if world_extent_m > float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M: - return "Region" # coverage ceiling — District physically cannot span this much world - if canvas_px <= 0.0: - return "Quarter" # finest — an unlaid-out viewport must under-resolve, not over-resolve - var sample_spacing_m: float = world_extent_m / canvas_px - var threshold_m: float = 2.0 * sample_spacing_m - if DISTRICT_SPACING_M <= threshold_m: - return "District" - return "Quarter" # threshold too small for even District's own spacing -> finest legal rung +static func select_rung(world_extent_m: float, _canvas_px: float) -> String: + for rung: String in RUNGS_FINEST_FIRST: + if world_extent_m <= float(MAX_COVERAGE_M[rung]): + return rung + return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job) ## The metre spacing a given granularity_v2 tag resolves to — the inverse @@ -438,3 +471,116 @@ static func edge_scroll_direction( elif mouse_pos.y > viewport_size.y - edge_margin_px: direction.y += 1.0 return direction + + +# ============================================================================= +# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST +# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"] +# = 13,107,200 m) covers only a fraction of a real body's circumference +# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top +# rest state composes MULTIPLE Region windows ("progressive capped-density +# TILING", design doc §4) into a mosaic under ONE view transform. +# ============================================================================= + + +## Compute the tile-set grid for the orbital rest state: the minimal set of +## Region-granularity window CENTERS (each `TILE_N` districts wide) whose +## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s +## own east-west periodicity), rows clamp at the poles. Returns an Array of +## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row +## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny +## body where multiple nominal tile rows clamp to the identical pole-adjacent +## row, or multiple nominal tile columns wrap to the identical column, must +## not request/draw the same tile twice). +## +## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full +## circumference (evenly spaced, centered on column 0 — the canonical +## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to +## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is +## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric +## around the canonical origin, matching enter_orbital()'s own "canonical +## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's +## own center-of-mass lands exactly on the canonical frame, not offset from +## it. +## +## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a +## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback +## disposition (no circumference/tiling concept for a body with no radius). +static func compute_tile_grid(body_radius_km: float) -> Array: + if body_radius_km <= 0.0: + return [Vector2i.ZERO] + + var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km) + var cols: int = int(extent["cols"]) + var rows_half: int = int(extent["rows_half"]) + var rows_total: int = rows_half * 2 + + var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N))) + var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N))) + + var col_centers: Array = [] + for tx in range(cols_tiles): + var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N)) + col_centers.append(raw_col) + + var row_centers: Array = [] + for ty in range(rows_tiles): + var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N)) + row_centers.append(raw_row) + + # Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair — + # Godot Dictionary keys compare Vector2i by value, so this is a proper + # set. Insertion order is preserved (Godot Dictionaries are + # order-preserving), giving a deterministic tile ORDER too — the same + # grid always requests/draws in the same sequence, useful for progressive + # arrival to read as a stable left-to-right, top-to-bottom fill rather + # than an unpredictable one. + var seen: Dictionary = {} + var tiles: Array = [] + for raw_col: int in col_centers: + for raw_row: int in row_centers: + var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center( + Vector2i(raw_col, raw_row), body_radius_km + ) + if not seen.has(canonical): + seen[canonical] = true + tiles.append(canonical) + return tiles + + +# ============================================================================= +# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved +# here from atlas_window_viewer.gd for file-length (the viewer's own +# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since +# both are directly tested by name). +# ============================================================================= + + +## Body name + coordinate label — T-1142: shows the body's proper name +## (falling back to body_id) alongside the held district center, so the +## header never reads as bare "district (col, row)" with no indication of +## WHICH body the player is looking at. +static func location_label(body_display_name: String, held_center: Vector2i) -> String: + return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y] + + +## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header. +## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always +## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s +## doc), but the km/cell reading reflects the HELD rung's actual spacing +## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous +## metres-per-pixel/extent readout" design doc §6 calls for in place of a +## discrete "you are now in Quarter Mode" label (Jeroen's "no mode +## transition" ruling): the number itself communicates the rung. +static func screen_header_content( + body_display_name: String, + held_center: Vector2i, + held_n: int, + held_granularity_v2: String, + district_m: float +) -> Dictionary: + var label: String = location_label(body_display_name, held_center) + var extent_km: float = float(held_n) * district_m / 1000.0 + 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} diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index 2cc7da250..71a749652 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -61,6 +61,8 @@ extends Node2D const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path. +const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") ## T-1145 item 3: interim presentation toggle — true renders the smoothed ## Image/ImageTexture composite; false keeps the original crisp per-cell @@ -94,6 +96,9 @@ var _cache_active_toggle: String = "" func _draw() -> void: if viewer == null: return + if viewer.is_tile_mode(): + _draw_tile_mosaic() + return var window: Variant = viewer.get_district_window() if not window is Dictionary: return @@ -132,6 +137,125 @@ func _draw() -> void: _draw_crisp_composite(w, grid_side, n, cell_px, active_toggle) +## T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital +## rest-state MOSAIC draw path — one call to the EXISTING single-tile +## composite-building logic (`_rebuild_texture_if_needed()`/ +## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE, +## each positioned at its own LOCAL offset from the tile-set's reference +## origin (district (0,0), matching `_enter_tile_mode()`'s own +## `_held_center = Vector2i.ZERO`). A tile centered at absolute district +## `tile.center` occupies local canvas space +## `[(tile.center - TILE_N/2) * cell_px, (tile.center + TILE_N/2) * cell_px)` +## — the SAME "window spans [center - n/2, center + n/2)" convention the +## single-window draw path already uses, just evaluated per tile instead of +## once for the whole composite. Tiles that haven't arrived yet +## (`tile["window"] == null`) are simply SKIPPED — no per-tile placeholder +## draw, letting COLOR_BG show through as the honest "nothing here yet" read +## (the viewer's own `_draw()` already documents why no separate +## whole-viewport fade is needed on top of this). +func _draw_tile_mosaic() -> void: + var tile_set = viewer.get_tile_set() + if tile_set == null: + return + var cell_px: float = viewer.get_cell_pixel_size() + var active_toggle: String = _active_toggle_overlay() + var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5 + + for tile: Dictionary in tile_set.get_tiles(): + 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 local_origin: Vector2 = Vector2( + (float(center.x) - half_tile) * cell_px, (float(center.y) - half_tile) * cell_px + ) + var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px + _draw_one_tile(w, grid_side, local_origin, extent, active_toggle) + + +## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the +## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED), +## just drawn at `local_origin` instead of always at (0,0). Each tile gets +## its OWN texture-rebuild cache slot (keyed by the tile's own window +## reference, via `_tile_texture_cache` below) — sharing ONE +## `_cached_texture` slot across all tiles (the single-window field) would +## thrash on every draw call as different tiles' windows compete for it. +func _draw_one_tile( + w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String +) -> void: + if not COMPOSITE_SMOOTH: + _draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle) + return + var tile_texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle) + if tile_texture == null: + return + texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false) + + +## Builds (uncached — see the class doc's rebuild-cost paragraph for why the +## SINGLE-window path caches by reference; a per-tile cache keyed the same +## way would need a Dictionary keyed on tile index, a reasonable follow-up +## if mosaic redraw cost ever matters in practice, not attempted here since +## the mosaic is drawn only at the orbital rest state, never mid-interaction +## at a high redraw rate the way single-window zoom/pan is) a tile's own +## Image/ImageTexture from its per-cell colors — identical pipeline to +## `_rebuild_texture_if_needed()`, just returning the texture directly +## instead of writing to the single-window cache fields. +func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture: + var elev_q: Variant = w.get("elev_q") + var glaciation: Variant = w.get("glaciation") + var morphology: Variant = w.get("morphology") + var n_cells: int = morphology.size() + + var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8) + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col + if i >= n_cells: + img.set_pixel(col, row, Color.TRANSPARENT) + continue + var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) + cell_color = _apply_glaciation(cell_color, glaciation, i) + img.set_pixel(col, row, cell_color) + + return ImageTexture.create_from_image(img) + + +## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()` +## exactly, just positioned at `local_origin` instead of always at (0,0). +func _draw_crisp_tile( + w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String +) -> void: + var elev_q: Variant = w.get("elev_q") + var glaciation: Variant = w.get("glaciation") + var morphology: Variant = w.get("morphology") + var n_cells: int = morphology.size() + var screen_cell_px: float = extent / float(grid_side) + + for row in range(grid_side): + for col in range(grid_side): + var i: int = row * grid_side + col + if i >= n_cells: + continue + var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle) + if cell_color.a <= 0.0: + continue + cell_color = _apply_glaciation(cell_color, glaciation, i) + var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px) + draw_rect( + Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color + ) + + ## The derived cell-grid side length (in CELLS) for a window dict `w` — ## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side` ## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than diff --git a/client/ui/implant/apps/atlas/atlas_window_tile_set.gd b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd new file mode 100644 index 000000000..21f6b8352 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_tile_set.gd @@ -0,0 +1,166 @@ +extends Node + +## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's +## ruling, design doc §4: "the top rest state is the WHOLE body, served as +## progressive capped-density TILING"). A single wire-capped Region window +## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a +## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single +## window is ~a third of the body, the exact live-round finding: shot 01's +## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference). +## +## Owns N independent `AtlasWindowRequest` child instances — one per tile — +## reusing 100% of the EXISTING, already-tested single-window request/cache/ +## debounce/retry machinery (atlas_window_request.gd) rather than +## reinventing multi-window orchestration from scratch. Each tile is just a +## Region-granularity window request at its own canonicalized center +## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already +## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline), +## so nothing about the request/cache LAYER needed to change for tiling to +## work — only the ORCHESTRATION (issue N requests instead of one) and the +## DRAWING (a mosaic instead of one composite) are new. +## +## No `class_name` on purpose, matching every other viewer-owned helper in +## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/ +## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer) +## passes itself to `_init()`. +## +## Progressive arrival (design doc §4's own "with visible refinement as +## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects +## independently — a tile's own `_tiles[i]["window"]` updates the moment +## THAT tile's response lands, with no dependency on any other tile's +## arrival. The viewer/overlay reads `get_tiles()` every draw and renders +## whichever tiles have arrived so far — an empty/border-fade gap for the +## rest, exactly the same "hold what's there, sharpen in place" contract +## single-window progressive refinement already has (§6 "no mode flip"), +## just per-tile instead of per-composite. + +signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws + +const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) +var _body_id: String = "" +var _tile_n: int = AtlasWindowGeometry.TILE_N + +## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest, +## "window": Variant (null until arrived)} — one entry per tile, in the SAME +## deterministic order compute_tile_grid() produces (stable fill order, see +## that function's own doc). +var _tiles: Array = [] + + +func _init(owner_ref = null) -> void: + _owner = owner_ref + + +## Unlike an individual AtlasWindowRequest (which has no signal connection of +## its own — the OWNING viewer forwards responses to it, per that class' +## own doc), the tile set DOES connect directly to +## SimBridge.atlas_layers_received itself and fans a single response out to +## EVERY tile's own `on_response()` — each tile's OWN staleness guard +## (center/n/granularity_v2) decides whether that particular response is +## the one IT was waiting for; only the matching tile ever adopts it. This +## is the same "one shared inbound signal, N independent consumers filtering +## by their own criteria" shape the design already uses elsewhere (every +## AtlasWindowRequest instance filters on its own state from a common +## broadcast — tiling just means N instances share the broadcast instead of +## one). +func _ready() -> void: + SimBridge.atlas_layers_received.connect(_on_atlas_layers_received) + + +func _exit_tree() -> void: + if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received): + SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) + + +func _on_atlas_layers_received(response: Dictionary) -> void: + for tile: Dictionary in _tiles: + var request = tile["request"] + if is_instance_valid(request): + request.on_response(response) + + +## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid, +## tears down any PREVIOUS tile set's child request nodes (a fresh +## enter_orbital() on a DIFFERENT body must not leave stale tile requests +## from the old body wired up), and issues one request per tile immediately +## (no debounce — matching AtlasWindowRequest.request_now()'s own "first +## window" contract, §5: entry is never debounced, only pan/rung-reselect +## refetches are). +func enter(body_id: String, body_radius_km: float) -> void: + _teardown() + _body_id = body_id + var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km) + for i in range(centers.size()): + var center: Vector2i = centers[i] + var request = AtlasWindowRequest.new(self) + request.name = "Tile%d" % i + add_child(request) + var tile_index := i # capture by value for the lambda below + request.window_ready.connect( + func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window) + ) + _tiles.append({"center": center, "request": request, "window": null}) + request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION) + + +func _on_tile_window_ready(index: int, window: Dictionary) -> void: + if index < 0 or index >= _tiles.size(): + return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown) + _tiles[index]["window"] = window + tile_ready.emit(index) + + +## Tear down every tile's request node — disconnects nothing explicitly +## (queue_free() on a Node disconnects all its own signal connections +## automatically, Godot's documented behavior) but DOES clear `_tiles` so a +## stale index from an in-flight-but-now-orphaned request's eventual +## response can never reach `_on_tile_window_ready()` with a now-meaningless +## index (guarded there too, belt-and-suspenders). +func _teardown() -> void: + for tile: Dictionary in _tiles: + var request = tile["request"] + if is_instance_valid(request): + request.queue_free() + _tiles.clear() + + +## The current tile set, for the viewer/overlay to draw — an Array of +## {"center": Vector2i, "window": Variant} (the "request" key is internal, +## not exposed here; callers only need center + arrived-or-null window). +func get_tiles() -> Array: + var result: Array = [] + for tile: Dictionary in _tiles: + result.append({"center": tile["center"], "window": tile["window"]}) + return result + + +## True once tiling is active for the current body — a body whose whole +## circumference fits in ONE Region window's own coverage ceiling produces +## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so +## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling +## machinery, technically) from "genuinely multiple tiles" — the viewer uses +## this to decide whether the tile-set draw path or the ORIGINAL +## single-window draw path is simpler/preferred for a small body (both are +## correct; single-window avoids the extra Node/signal overhead when there's +## only ever going to be one tile). +func is_multi_tile() -> bool: + return _tiles.size() > 1 + + +func get_tile_count() -> int: + return _tiles.size() + + +## True if every tile currently has an arrived window — the viewer/legend +## chrome can use this to know when the mosaic is "complete" vs. still +## progressively filling in. +func is_fully_arrived() -> bool: + if _tiles.is_empty(): + return false + for tile: Dictionary in _tiles: + if tile["window"] == null: + return false + return true diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 1325ae086..b0f7db49f 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -3,74 +3,64 @@ extends Control ## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's ## click-through-only entry per the D-226 T-1143-rulings amendment — see -## enter_orbital()'s own doc). This IS the "regional" nav entry now (T-1152 +## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152 ## client half): the whole ladder from the canonical orbital frame (Region -## rung) down to District/Quarter granularity lives in ONE screen/Control, -## not a separate planetary heightmap viewer + a windowed drill-down. Renders -## a DistrictWindowLayer composite at whichever rung is currently held: -## morphology base layer lightness-modulated by elev_q, three switchable -## climate/vegetation overlays, and an always-on glaciation ice-tint modifier -## (drawing itself is AtlasWindowOverlay's job — this Control owns input, -## request orchestration, chrome, and the pan/zoom transform). One colorizer -## family renders every rung unchanged (design doc §6) — AtlasWindowOverlay -## never branches on granularity_v2 for COLOR, only for the derived -## cell-grid's RESOLUTION (cell_grid_side_for_window()). +## 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). ## ## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153): ## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom -## = _canvas.scale — the SAME transform idiom as the (retired) planetary -## viewer. -## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame (never -## blocks on a re-derive), but is CONTINUOUS AND UNCLAMPED ACROSS RUNGS -## (T-1153, D-013 restored for this seam): crossing a rung's spacing -## threshold (§5 rung-selection rule) fires a background request for the -## new granularity while the OLD composite keeps drawing — progressive -## refinement, no blank frame, no mode flip (§6). A pan past the held -## window's edge re-requests the SAME rung at a new center (§4/§5, -## unchanged from T-1138). +## = _canvas.scale. +## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but +## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013 restored for this seam): +## crossing a rung's coverage ceiling (§5, redesigned per live round 3 — +## see AtlasWindowGeometry.select_rung()) fires a background request for +## the new granularity while the OLD composite keeps drawing — +## progressive refinement, no blank frame, no mode flip (§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(). -## - _window_request (atlas_window_request.gd) owns the cache/debounce/ -## retry — this Control decides WHEN to call it (pan-edge detection, -## rung-reselect, entry), never talks to SimBridge directly itself. +## condition) — see _maybe_reset_to_canonical_frame() — which, on a body +## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4: +## "the top rest state is the WHOLE body, served as progressive +## capped-density TILING"). +## - _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. ## -## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning -## BREAKS click semantics with map objects, so it is removed entirely; clicks -## are reserved for map objects, which will land in this window later, e.g. -## settlements): +## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click +## semantics with future map objects, so it's removed entirely): ## WASD / arrow keys continuous pan, held (frame-rate independent, _process) ## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport ## edge pans toward it (suppressed over UI / unfocused) ## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153) -## Esc back (nav.pop() — the "district" nav-stack entry is -## gone as a separate hop, see atlas_app.gd's own doc) +## Esc back (nav.pop()) signal back_pressed const PANEL_MARGIN: float = 16.0 const OVERLAY_BAR_HEADER_RESERVE: float = 360.0 -## T-1153: MIN_ZOOM/MAX_ZOOM stay a wide safety clamp on the raw display -## multiplier (never letting _view_zoom collapse to zero or run away toward -## infinity) — they are NOT a rung boundary any more. Wheel zoom is now -## CONTINUOUS and UNCLAMPED ACROSS RUNGS (D-226 T-1143-rulings amendment, -## Jeroen's seam ruling: "D-013's zoom gesture owns spatial descent restored -## for this seam"): crossing a rung's spacing threshold (§5's rung-selection -## rule, AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT -## granularity window at the SAME apparent screen extent, it does not clamp -## _view_zoom itself. The programmatic capture API (set_view(), T-1120) still -## clamps to this same wide range — a capture harness driving a specific -## zoom/offset pair has no rung-crossing concept of its own to trigger. +## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display +## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED +## ACROSS RUNGS (D-013 restored for this seam): crossing a rung's coverage +## ceiling (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT +## granularity at the SAME apparent screen extent, never clamping +## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same +## range independently. ## ## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for -## enter_orbital()'s largest legal `n` (a whole equatorial circumference, up -## to hundreds of thousands of districts on a gas-giant-scale body) is never -## itself clamped — a clamped fit zoom would silently show LESS than the -## whole body, breaking Jeroen's HARD condition ("the whole body fitted to -## the canvas") at exactly the moment it matters most. 0.0005 covers a -## ~120,000 km-radius body (n≈368,000 districts) at a 3840px 4K viewport with -## headroom; a real fit_zoom this low is expected and correct at the -## canonical orbital frame, not a bug. +## enter_orbital()'s largest legal `n` (up to hundreds of thousands of +## districts on a gas-giant-scale body) is never itself clamped — that would +## silently show LESS than the whole body, breaking Jeroen's HARD condition. +## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport with +## headroom. const MIN_ZOOM: float = 0.0005 const MAX_ZOOM: float = 64.0 const ZOOM_STEP: float = 1.15 @@ -125,6 +115,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re # atlas_descend_geometry.gd instead — it already owns district_extent()). const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +# T-1153: orbital rest-state mosaic orchestration. +const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd") # ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/ # atlas_legend_panel.gd's existing duck-typed viewer interface: both call @@ -213,6 +205,15 @@ var _screen_header: ImplantHeader = null var _overlay_bar = null var _legend_panel = null var _window_request = null # AtlasWindowRequest +var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3) + +## T-1153 (Jeroen's ruling, 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()` when +## compute_tile_grid() produces more than one tile; cleared the moment +## `_maybe_reselect_rung()` crosses OUT of Region — tiling is purely a +## TOP-of-the-ladder concern, never active below Region. +var _tile_mode: bool = false func _ready() -> void: @@ -242,6 +243,11 @@ func _ready() -> void: add_child(_window_request) _window_request.window_ready.connect(_on_window_ready) + _tile_set = AtlasWindowTileSet.new(self) + _tile_set.name = "TileSet" + add_child(_tile_set) + _tile_set.tile_ready.connect(_on_tile_ready) + _build_screen_header() _build_overlay_bar() _build_legend_panel() @@ -254,29 +260,20 @@ func _exit_tree() -> void: SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received) -## Enter the window screen centered on `district_center` (a DistrictPos- -## equivalent Vector2i, from a click-through's derived position — §5's "pan -## center read as click point") at District granularity. n defaults to the -## client's interactive default (32), half the server's hard cap. Kept as a -## thin District-rung wrapper over _enter_at_rung() (T-1153) — a click-to- -## descend-to-point shortcut on top of the continuous ladder (Jeroen's -## ruling: "if a click-to-descend-to-point remains cheap to keep... wired to -## the same descent path"). No screen currently calls this directly (the -## retired planetary click-through it served no longer exists — see -## atlas_app.gd's own doc); it survives as the landing point a future -## map-object click (e.g. a settlement marker on the Region-rung view) would -## wire into, and as a direct-call entry for tests/tools that want a -## District-rung window without going through enter_orbital() first. +## Enter the window screen centered on `district_center` (a DistrictPos, from +## a click-through's derived position) at District granularity. n defaults to +## 32, half the server's hard cap. Kept as a thin District-rung wrapper over +## _enter_at_rung() (T-1153) — a click-to-descend-to-point shortcut on top of +## the continuous ladder (Jeroen's ruling). No screen currently calls this +## directly (the retired planetary click-through it served no longer exists +## — see atlas_app.gd's own doc); it survives as the landing point a future +## map-object click would wire into, and as a direct-call entry for tests. ## ## T-1142: `district_center` is canonicalized (wrap column / clamp row) ## BEFORE it becomes `_held_center` or reaches the request — matching the -## server's own normalize_window_center() exactly, so the request the client -## sends and the echo the server sends back describe the SAME canonical -## point from the first round-trip (never a raw-vs-normalized mismatch that -## would fail the §2 staleness echo check). Also fits-and-centers the view -## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an -## n=32 composite is 512px native, a postage stamp unfitted in a real -## viewport). +## server's normalize_window_center() so the client's echo comparison never +## mismatches. Also fits-and-centers the view instead of resetting to +## zoom=1/offset=ZERO. func enter( body: Dictionary, system: Dictionary, @@ -292,25 +289,28 @@ func enter( ## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital ## frame (Jeroen's HARD condition: "the whole body fitted to the canvas, -## centered at the body's canonical origin"). This is the new "regional" nav -## entry point (T-1152 client half — supersedes AtlasViewer's heightmap -## texture as the sole entry): the player lands on a fully-derived Region-rung -## view of the whole body, then wheel-zoom descends CONTINUOUSLY from there — -## no separate planetary screen, no click-through required to reach the -## windowed view at all (though enter() above stays wired for a -## click-to-descend shortcut, per Jeroen's ruling). +## centered at the body's canonical origin"). This is the "regional" nav +## entry point (T-1152 client half): the player lands on a fully-derived +## Region-rung view of the whole body, then wheel-zoom descends CONTINUOUSLY +## from there — no separate planetary screen, no click-through required +## (though enter() below stays wired for a click-to-descend shortcut, per +## Jeroen's ruling). ## ## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the -## equator" (AtlasDescendGeometry's own doc, mirroring -## district_profile.rs). Canonical extent = the WHOLE equatorial -## circumference in districts (district_extent().cols), i.e. one full -## circumnavigation — the same quantity is_fully_zoomed_out()/the -## full-zoom-out reset (see _maybe_reset_to_canonical_frame()) test against, -## so entry and reset always agree on what "the top" means. No-radius bodies -## (tiny test bodies) fall back to the District-rung default window — there -## is no planetary circumference concept to derive a Region-rung n from (same -## fallback disposition AtlasDescendGeometry's own no-radius branches use -## throughout). +## equator" (AtlasDescendGeometry's own doc). Canonical extent = the WHOLE +## equatorial circumference in districts, the same quantity +## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against, so +## entry and reset always agree on what "the top" means. No-radius bodies +## fall back to the District-rung default window (no circumference concept). +## +## **Live round 3 (Jeroen's ruling, design doc §4): the rest state must +## TILE.** A single wire-capped Region window covers at most +## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]` (13,107,200 m) — a THIRD of +## Lendel's ~39,197,023 m circumference (shot 01's own header: "13107.2 x +## 13107.2 km"). Once `compute_tile_grid()` returns MORE than one tile, +## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`; a +## body whose circumference fits one Region window's ceiling still gets +## exactly one "tile" (the degenerate case) and stays single-window. 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: @@ -320,31 +320,55 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void: AtlasWindowRequest.GRANULARITY_V2_DISTRICT ) return + var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km) + if tiles.size() > 1: + _enter_tile_mode(body, system, radius_km) + return var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) var n: int = int(extent["cols"]) _enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION) +## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as +## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`, +## setting `_tile_mode = true` so drawing/reset/reselect read the mosaic. +## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its +## own TILE_N-sized request independently inside AtlasWindowTileSet), so the +## extent math elsewhere needs no tile-specific branch. +func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void: + var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) + var n: int = int(extent["cols"]) + _body = body + _system = system + _held_center = Vector2i.ZERO + _held_n = n + _held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION + _tile_mode = true + _window = null + _user_adjusted = false + _awaiting_first_window = true + _fit_and_center() + _window_request.reset() + _tile_set.enter(_dict_str(_body, "body_id", ""), radius_km) + _refresh_screen_header() + grab_focus() + queue_redraw() + _overlay_node.queue_redraw() + + ## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center` -## must already be canonicalized by the caller (enter_orbital()'s (0,0) needs -## no canonicalization; enter()'s does its own before calling in). Resets -## every piece of held/request state for a fresh descent, exactly as the -## pre-T-1153 enter() always did, plus the new _held_granularity_v2 tracking. +## must already be canonicalized by the caller. Resets every piece of +## held/request state for a fresh descent, plus _held_granularity_v2. ## -## **The C1 clamp-mirror lesson, one layer up (live-round finding):** `n` -## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes -## `_held_n` — mirroring exactly what AtlasWindowRequest.request_now() -## already does to ITS OWN `_n` before storing/sending (see that function's -## own doc for the original PR #191 Tyre C1 finding). Storing the RAW `n` -## here (e.g. enter_orbital()'s full district_extent().cols, routinely tens -## of thousands at Region granularity, versus the server's clamped echo of -## at most DISTRICT_WINDOW_MAX_N_REGION=6,400) left `_held_n` permanently -## disagreeing with what the server would ever actually echo — every -## orbital-rung response was silently rejected as stale by -## _on_window_ready()'s `w_n != _held_n` check, hanging the ladder on every -## real-sized body. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both -## read `_held_n` (never re-derive it), so clamping here — the ONE write -## site — fixes every downstream caller too, not just entry. +## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be +## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` — +## mirroring what AtlasWindowRequest.request_now() already does to ITS OWN +## `_n` (PR #191 Tyre C1). Storing RAW `n` (e.g. enter_orbital()'s full +## district_extent().cols, tens of thousands at Region, vs. the server's +## clamped echo of at most 6,400) left `_held_n` permanently disagreeing +## with the server's echo — every orbital response silently rejected as +## stale forever. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both +## read `_held_n` unchanged, so clamping here fixes every downstream caller. func _enter_at_rung( body: Dictionary, system: Dictionary, @@ -358,6 +382,7 @@ func _enter_at_rung( _held_center = district_center _held_n = clamped_n _held_granularity_v2 = granularity_v2 + _tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode _window = null _user_adjusted = false _awaiting_first_window = true @@ -391,11 +416,9 @@ func _fit_and_center() -> void: _apply_transform() -## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts -## — a no-radius body (tiny test body) has no periodicity/pole concept at the -## DistrictPos level (matching canonicalize_district_center()'s own no-radius -## identity disposition), so the wall is a no-op there (rows_half=0, and -## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall"). +## T-1142: needs the body's rows_half, in whole districts — a no-radius body +## has no pole concept (matching canonicalize_district_center()'s own +## no-radius identity), so the wall is a no-op there (rows_half=0). func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -417,6 +440,19 @@ func get_district_window() -> Variant: return _window +## T-1153: true while showing the orbital rest state as a multi-window +## mosaic instead of the single held composite — AtlasWindowOverlay reads +## this to pick a draw path. +func is_tile_mode() -> bool: + return _tile_mode + + +## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw +## path — only meaningful while is_tile_mode() is true. +func get_tile_set() -> Variant: + return _tile_set + + ## 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 @@ -451,15 +487,11 @@ func _on_atlas_layers_received(response: Dictionary) -> void: _window_request.on_response(response) -## T-1153: progressive refinement — this is the ONE place a new rung's -## window gets adopted (swapped in), and it deliberately does NOT clear -## `_window` first. The OLD composite (whatever rung it was) stays drawn -## every frame up to and including the one before this call — no blank -## frame, no mode flip (§6 acceptance criterion) — because `_window` is a -## single-slot "the composite currently drawn" reference that only ever gets -## REPLACED, never nulled, once a window has been adopted at least once -## (enter()/_enter_at_rung() nulls it only at a fresh descent, a real -## navigation event, not a rung swap). +## T-1153: progressive refinement — the ONE place a new rung's window gets +## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite +## stays drawn until this call — no blank frame, no mode flip (§6) — +## 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 @@ -500,6 +532,15 @@ func _on_window_ready(window: Dictionary) -> void: _overlay_node.queue_redraw() +## T-1153 (design doc §4 "progressive... with visible refinement as tiles +## complete"): a SINGLE tile's window arrived — redraw so the overlay's +## mosaic loop picks it up. No acceptance/staleness logic needed here (each +## tile's OWN AtlasWindowRequest already filtered before this signal fired). +func _on_tile_ready(_index: int) -> void: + queue_redraw() + _overlay_node.queue_redraw() + + # ============================================================================= # View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR- # ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings @@ -517,14 +558,11 @@ func _apply_transform() -> void: _overlay_node.queue_redraw() -## Cursor-anchored zoom (D-013 restored for this seam, Jeroen's ruling): the -## CANVAS POINT under the cursor stays fixed on screen across the zoom step — -## zooming toward the cursor, not the view center. Unclamped ACROSS RUNGS -## (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies to the raw -## multiplier itself — see that constant's own doc); after applying the new -## zoom, checks whether the currently-displayed world extent now calls for a -## different rung (_maybe_reselect_rung()) and whether the view has reached -## the ladder's top rest state (_maybe_reset_to_canonical_frame()). +## Cursor-anchored zoom (D-013 restored for this seam): the CANVAS POINT +## under the cursor stays fixed on screen across the zoom step. Unclamped +## ACROSS RUNGS (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies — see +## that constant's own doc); after applying, checks whether the extent now +## calls for a different rung or 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) if is_equal_approx(new_zoom, _view_zoom): @@ -539,59 +577,71 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void: ## The world extent (metres) currently displayed across the LARGER viewport -## dimension — the `E` half of the §5 rung-selection rule's `E/C`. A pure -## function of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own -## doc for why the currently-held rung is NOT an input: the composite's -## on-screen footprint is rung-invariant by construction, so sample density -## depends only on zoom). Thin wrapper kept here so callers don't need to -## know the pure function lives on AtlasWindowGeometry (T-1153 — extracted -## there, alongside select_rung(), to keep the §5 math unit-testable without -## a Control in the tree). +## dimension — the `E` half of the §5 rung-selection rule. A pure function +## of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own doc for +## why the held rung is NOT an input). Thin wrapper over that pure function. func _current_world_extent_m() -> float: return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size) ## §5 rung-selection rule + progressive refinement (T-1153): after a zoom -## step, recompute the coarsest legal rung for the NOW-displayed world extent -## (_current_world_extent_m() over the viewport's larger dimension). If that -## differs from what's currently HELD on screen, request the new granularity -## centered on the CURRENT screen-center's district position (reusing -## _screen_center_district() — the same screen-to-district math -## _maybe_refloat_window() already established, which is rung-agnostic since -## CELL_PIXEL_SIZE is always district-based regardless of the held rung's -## true cell spacing — see atlas_window_overlay.gd's cell_grid_side_for_window() -## doc for why that's true). +## step, recompute the legal rung for the NOW-displayed world extent. If it +## differs from what's HELD, request the new granularity centered on the +## CURRENT screen-center (_screen_center_district(), the same formula +## _maybe_refloat_window() uses). ## -## Progressive refinement, not block-on-derive: this does NOT touch `_window` -## or `_held_granularity_v2` — the OLD composite keeps drawing every frame -## (border-fade/pending-indicator per R6 shows the request is in flight, see -## _draw_border_fade()) until _on_window_ready() adopts the NEW rung's window -## once it actually arrives (§6 "no mode flip": never a blank frame, never a -## clear-then-redraw). +## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be +## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left +## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling +## (Region caps at 6,400; District/Quarter at 64), so a stale Region-sized +## `_held_n` fed into a Quarter request gets server-clamped small while +## `_held_n` stays large — `_on_window_ready()`'s `w_n != _held_n` then +## drops every cross-rung refinement forever. Same bug as _enter_at_rung(), +## recurring at the CROSSING boundary. +## +## 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": never a blank frame, never clear-then-redraw). func _maybe_reselect_rung() -> void: if _held_n <= 0: return var world_extent_m: float = _current_world_extent_m() var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y) var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px) - if target_rung == _window_request.get_granularity_v2(): + + # T-1153, live round 3: tile mode is TOP-of-the-ladder only (coordinator's + # own scoping — "inside-zoom can stay single-window as now"). Staying at + # Region means staying tiled (zooming within a mosaic is a client-side + # scale on the SAME held tiles, like single-window zoom on one + # composite). Crossing OUT of Region falls through to the single-window + # path below, flipping `_tile_mode` off. + var leaving_tile_mode := false + if _tile_mode: + if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION: + return + _tile_mode = false + leaving_tile_mode = true + + # `leaving_tile_mode` FORCES the request through even if + # `_window_request`'s own STALE granularity_v2 (never touched while tiled) + # happens to already equal `target_rung` by coincidence — without this, + # the early-return below would skip the request that's supposed to + # POPULATE `_window` for the first time since tile mode replaced it. + if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2(): return # already requesting (or holding) the rung this extent calls for var new_center: Vector2i = _screen_center_district() + var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung) _held_center = new_center + _held_n = clamped_n _window_request.request_debounced( - _dict_str(_body, "body_id", ""), new_center, _held_n, target_rung + _dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung ) ## The DistrictPos the current screen center maps to, in RAW absolute -## district space (matching _maybe_refloat_window()'s own convention — only -## the caller canonicalizes the final value it actually stores/sends). Thin -## wrapper over AtlasWindowGeometry.screen_center_to_district() (T-1153 — -## extracted alongside the rung-selection math for the same testability -## reason) so both the pan-edge refetch and the rung-reselect refetch share -## ONE screen-to-district formula rather than two copies that could drift -## (the exact lesson _maybe_refloat_window()'s own doc already establishes -## for the pan case). +## district space (the caller canonicalizes the final stored/sent value). +## Thin wrapper over AtlasWindowGeometry.screen_center_to_district() so both +## the pan-edge refetch and the rung-reselect refetch share ONE formula. func _screen_center_district() -> Vector2i: var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district( size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n @@ -600,19 +650,13 @@ func _screen_center_district() -> Vector2i: return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km) -## Jeroen's HARD condition (D-226 T-1143-rulings amendment): "a full -## zoom-out resets to the original canonical planetary frame and location" — -## the ladder's TOP REST STATE, never a drifted pan/zoom-out state. Fires -## when the CURRENTLY DISPLAYED world extent (at _held_granularity_v2, the -## rung actually on screen — deliberately NOT the in-flight request's rung, -## so this can't fire prematurely off a request that hasn't landed yet) -## covers the whole body (AtlasWindowGeometry.is_fully_zoomed_out()) AND the -## player isn't ALREADY sitting at the canonical frame (center == (0,0) — -## re-entering the SAME enter_orbital() state on every zoom tick past the -## threshold would fight a player trying to zoom back IN from the top, since -## every zoom-out tick would keep re-snapping to the identical framing). -## Returns true if it fired (the caller should skip _maybe_reselect_rung() — -## the reset already re-requested at the canonical Region-rung window). +## Jeroen's HARD condition: "a full zoom-out resets to the original +## canonical planetary frame and location" — the ladder's TOP REST STATE, +## never a drifted pan/zoom-out state. Fires when the CURRENTLY DISPLAYED +## extent (at _held_granularity_v2, deliberately NOT the in-flight request's +## rung) covers the whole body AND the player isn't ALREADY at the canonical +## frame (re-snapping every tick would fight a zoom-in-from-the-top +## gesture). Returns true if it fired (caller skips _maybe_reselect_rung()). func _maybe_reset_to_canonical_frame() -> bool: var radius_km: float = float(_body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -650,29 +694,26 @@ func set_view(zoom: float, offset: Vector2) -> void: # ============================================================================= -## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan -## tick every frame the player is actively panning), check whether the +## 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 (§5 "windows float on -## the pan center... not grid-snapped") via the debounced request path. +## — if so, float a NEW window centered on that point via the debounced path. ## -## T-1142 (item 6a): the edge-crossing decision below is computed in RAW -## absolute district space (un-wrapped, un-clamped) — that is the correct -## space for "has the pan carried the view past the held window's edge", -## since the held window's own local bounds are relative to _held_center as -## it was BEFORE this pan. Only the FINAL new_center that becomes the next -## _held_center / the next request is canonicalized (wrap column, clamp -## row) — matching the server's own normalize_window_center() and keeping -## the client's echo-comparison and cache key on the same canonical form the -## server uses (see canonicalize_district_center()'s doc for why this must -## match bit-for-bit). A pan that straddles the antimeridian therefore still -## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or -## district_cols+5, the edge-crossing math treats that as a normal delta from -## the old center, and only the resulting new_center gets wrapped into range -## before it's requested/cached. +## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute +## district space (un-wrapped, un-clamped) — the held window's own local +## bounds are relative to _held_center as it was BEFORE this pan. Only the +## FINAL new_center is canonicalized (wrap column, clamp row), matching the +## server's normalize_window_center() so the echo comparison/cache key stay +## on the same canonical form. A pan straddling the antimeridian still +## floats correctly: the pre-canonicalization abs_col can go negative or +## past cols, and only the resulting new_center gets wrapped before use. func _maybe_refloat_window() -> void: if _held_n <= 0: return + if _tile_mode: + # T-1153: the tile set already covers the WHOLE body — no "edge" to + # cross while tiled. _apply_pan_delta() still moves _view_offset; + # this only skips the single-window re-float below. + return var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district( size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n ) @@ -716,6 +757,11 @@ 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. + 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 @@ -760,39 +806,26 @@ func _build_screen_header() -> void: _screen_header.apply_implant_theme(_implant_theme) -## D-169/D-170 implant chrome (§5): location label (body name + coordinate, -## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g. -## "4.1 x 4.1 km . 2.0 km/cell". T-1153: the extent (`n` districts) is -## rung-INVARIANT (n is always district extent — see -## AtlasWindowOverlay.cell_grid_side_for_window()'s doc), but the km/cell -## reading must reflect the HELD rung's actual spacing (2.048 km at District, -## 0.512 km at Quarter, 204.8 km at Region) — this is the "continuous -## metres-per-pixel/extent readout" the design doc §6 calls for in place of a -## discrete "you are now in Quarter Mode" label (Jeroen's "no mode -## transition" ruling): the number itself communicates the rung, no named -## mode chrome does. +## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure +## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for +## file-length; the "continuous metres-per-pixel readout, never a discrete +## mode label" rationale lives on that function's own doc now). func _refresh_screen_header() -> void: if _screen_header == null: return - var location_label: String = _location_label() - var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0 - var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_held_granularity_v2) / 1000.0 - var extent_line: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km] - var title: String = "REGIONAL — %s" % location_label.to_upper() - _screen_header.set_content(title, extent_line) + var content: Dictionary = AtlasWindowGeometry.screen_header_content( + _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), + _held_center, _held_n, _held_granularity_v2, DISTRICT_M + ) + _screen_header.set_content(content["title"], content["subtitle"]) -## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141 -## forward — the body's proper name was already sitting unused on _body, -## passed through the whole descend chain since T-1138, but this header never -## read it, showing bare "district (col, row)" with no indication of WHICH -## body the player is looking at. T-1141 keeps only the harder half: nearest- -## settlement proximity join (the window carries no settlement data of its -## own — that lives on the planetary gen_l3_settlements overlay, a different -## screen/dataset — a real follow-up, not a silently-guessed one). +## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved +## there for file-length) — kept as a method since it's directly tested. func _location_label() -> String: - var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")) - return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y] + return AtlasWindowGeometry.location_label( + _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center + ) # ============================================================================= @@ -811,16 +844,10 @@ func _is_over_ui(_pos: Vector2) -> bool: ## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click -## semantics with map objects; clicks are reserved for future map objects, -## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the -## local mouse position for edge-scroll (_process() reads _last_mouse_pos — -## it has no InputEvent of its own to read a live position from). WASD/arrow -## panning does NOT go through _gui_input at all — it is a HELD-key, -## continuous, frame-rate-independent pan polled every frame in _process() -## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(), -## since WASD has no project-level Input Map action of its own in this -## screen's remit — see _process()'s own doc for why raw physical-keycode -## polling is deliberate here, not a new InputMap action). +## semantics with map objects). What remains: wheel zoom and tracking the +## local mouse position for edge-scroll (_process() has no InputEvent of its +## own). WASD/arrow panning does NOT go through _gui_input — it's a +## HELD-key, frame-rate-independent pan polled every frame in _process(). func _gui_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.is_echo(): _handle_key(event as InputEventKey) @@ -850,18 +877,11 @@ func _handle_key(event: InputEventKey) -> void: ## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied -## here (not _gui_input) because both are HELD-state effects (keys held down, -## cursor lingering near an edge), not discrete input events — _process() -## polls held state every frame and hands the resulting direction + this -## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit -## test drives _apply_pan_delta(direction, delta) directly with a -## deterministic direction/delta instead of needing to fake Godot's global -## Input singleton reporting a key held, which is what testing THIS -## function's own Input.is_key_pressed() polling would require). Skips -## entirely while this Control is hidden (the screen is not the active -## nav-stack entry) — no wasted per-frame work for an invisible viewer, and -## no phantom panning if some other code path leaves this node in the tree -## but not shown. +## here (not _gui_input) since both are HELD-state effects, not discrete +## events — polls held state every frame and hands the direction + delta to +## _apply_pan_delta() (split out for testability — a gdUnit test drives it +## directly rather than faking Godot's global Input singleton). Skips while +## hidden (screen not the active nav-stack entry). func _process(delta: float) -> void: if not visible: return @@ -873,15 +893,11 @@ func _process(delta: float) -> void: _apply_pan_delta(direction, delta) -## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet -## normalized) direction and this frame's delta — frame-rate independent -## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom- -## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc -## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by -## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/ -## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check -## (§4) drag used to. Split from _process() specifically so a test can call -## this directly with a synthetic direction/delta — see _process()'s own doc. +## 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. 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