diff --git a/client/tests/test_atlas_window_geometry_nature.gd b/client/tests/test_atlas_window_geometry_nature.gd new file mode 100644 index 000000000..3c7d63315 --- /dev/null +++ b/client/tests/test_atlas_window_geometry_nature.gd @@ -0,0 +1,283 @@ +## T-1156 wave 1: pure-function tests for AtlasWindowGeometry's Layer-1 +## nature-overlay pixel mapping (layer1_pixel_to_world_m/world_m_to_district/ +## layer1_pixel_to_canvas_local) and per-rung visibility/filter policy +## (river_class_visible_at_rung/confluences_visible_at_rung/ +## mouths_visible_at_rung/basins_visible_at_rung/attractors_visible_at_rung). +## Split from test_atlas_window_geometry.gd (already close to the gdlint +## max-file-lines cap) — same file-per-concern precedent as +## test_atlas_window_colors.gd being separate from test_atlas_window_overlay.gd. +class_name TestAtlasWindowGeometryNature +extends GdUnitTestSuite + +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +const CELL_PIXEL_SIZE: float = 16.0 +const DISTRICT_M: float = 2048.0 + + +# ============================================================================= +# layer1_pixel_to_world_m — the forward mirror of +# server/src/atlas/district_profile.rs's pixel_to_world_m(), verified against +# that function's source directly (not assumed). +# ============================================================================= + + +## Column 0 is world/longitude 0 on every body — no -0.5 centering, unlike +## rows (longitude wraps and has no "half" concept the way latitude does). +func test_layer1_pixel_to_world_m_col_zero_is_world_x_zero() -> void: + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, 0.0, 256.0, 128.0, 6371.0) + assert_float(w.x).is_equal_approx(0.0, 0.001) + + +## Row 0 is the NORTH POLE — server's own comment: `lat_frac = -0.5 = N pole` +## — which the forward map resolves to the MOST NEGATIVE wy (world Y +## increases southward, matching AtlasDescendGeometry.district_pos_at()'s own +## row-increases-southward convention on the inverse side of this mapping). +func test_layer1_pixel_to_world_m_row_zero_is_north_pole_negative_wy() -> void: + var radius_km := 6371.0 + var grid_h := 128.0 + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, 0.0, 256.0, grid_h, radius_km) + var meridian_m: float = PI * radius_km * 1000.0 + assert_float(w.y).is_equal_approx(-0.5 * meridian_m, 1.0) + + +## Row (grid_h - 1) is the SOUTH POLE — `lat_frac = +0.5 = S` — the most +## POSITIVE wy, the opposite extreme from row 0. +func test_layer1_pixel_to_world_m_last_row_is_south_pole_positive_wy() -> void: + var radius_km := 6371.0 + var grid_h := 128.0 + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m( + grid_h - 1.0, 0.0, 256.0, grid_h, radius_km + ) + var meridian_m: float = PI * radius_km * 1000.0 + assert_float(w.y).is_equal_approx(0.5 * meridian_m, 1.0) + + +## The equator row (grid_h / 2, approximately — the exact half-height pixel) +## is world Y ~0 — halfway between the two poles. Not EXACT (the denominator +## is grid_h - 1 = 127, not 128), so the tolerance is loose (200km). +func test_layer1_pixel_to_world_m_mid_row_is_near_equator() -> void: + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(64.0, 0.0, 256.0, 128.0, 6371.0) + assert_float(w.y).is_equal_approx(0.0, 200_000.0) + + +## Column at grid_w (a full wrap) must equal the FULL circumference — the +## wrap point, matching longitude's periodic (not clamped) treatment. +func test_layer1_pixel_to_world_m_full_width_col_is_full_circumference() -> void: + var radius_km := 6371.0 + var grid_w := 256.0 + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(0.0, grid_w, grid_w, 128.0, radius_km) + var circumference_m: float = TAU * radius_km * 1000.0 + assert_float(w.x).is_equal_approx(circumference_m, 5.0) + + +## No-radius (tiny test body): 1 heightmap pixel = 1 DISTRICT_M metre exactly +## — matching pixel_to_world_m()'s own no-radius fallback and +## AtlasDescendGeometry.district_pos_at()'s no-radius branch on the inverse side. +func test_layer1_pixel_to_world_m_no_radius_is_one_pixel_one_district_m() -> void: + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(3.0, 5.0, 64.0, 64.0, 0.0) + assert_that(w).is_equal(Vector2(5.0 * DISTRICT_M, 3.0 * DISTRICT_M)) + + +## Degenerate grid dims (grid_w/grid_h <= 0) must not divide-by-zero or crash. +func test_layer1_pixel_to_world_m_zero_grid_dims_returns_zero() -> void: + var w: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m(1.0, 1.0, 0.0, 0.0, 6371.0) + assert_that(w).is_equal(Vector2.ZERO) + + +# ============================================================================= +# world_m_to_district — one division by DISTRICT_M, sub-district precision +# preserved (not rounded). +# ============================================================================= + + +func test_world_m_to_district_divides_by_district_m() -> void: + var d: Vector2 = AtlasWindowGeometry.world_m_to_district(Vector2(DISTRICT_M * 3.5, DISTRICT_M * -2.25)) + assert_that(d).is_equal_approx(Vector2(3.5, -2.25), Vector2.ONE * 0.001) + + +# ============================================================================= +# layer1_pixel_to_canvas_local — the full composition, cross-checked against +# district_to_canvas_local() called manually with the same intermediate value. +# ============================================================================= + + +## A river pixel at the held window's own center district must land at +## canvas-local half-extent — same invariant +## test_district_to_canvas_local_center_district_lands_at_half_extent() +## pins for the district-space function this one wraps. +func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: + var radius_km := 6371.0 + var grid_w := 256.0 + var grid_h := 128.0 + var held_center := Vector2i(10, 20) + var held_n := 64 + var row := 40.0 + var col := 80.0 + + var result: Vector2 = AtlasWindowGeometry.layer1_pixel_to_canvas_local( + row, col, grid_w, grid_h, radius_km, held_center, held_n, CELL_PIXEL_SIZE + ) + + var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m( + row, col, grid_w, grid_h, radius_km + ) + var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m) + var expected: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + district, held_center, held_n, CELL_PIXEL_SIZE + ) + assert_that(result).is_equal_approx(expected, Vector2.ONE * 0.001) + + +# ============================================================================= +# Per-rung river-class visibility (Araminta's ruling, 2026-07-23) — +# river_class_visible_at_rung() +# ============================================================================= + + +func test_river_class_visible_at_rung_region_shows_every_class() -> void: + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Region") + ).is_true() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung( + AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "Region" + ) + ).is_true() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Region") + ).is_true() + + +func test_river_class_visible_at_rung_district_shows_trunk_only() -> void: + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung( + AtlasWindowGeometry.RIVER_CLASS_STREAM, "District" + ) + ).is_false() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung( + AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "District" + ) + ).is_false() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "District") + ).is_true() + + +func test_river_class_visible_at_rung_quarter_shows_nothing() -> void: + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Quarter") + ).is_false() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung( + AtlasWindowGeometry.RIVER_CLASS_TRIBUTARY, "Quarter" + ) + ).is_false() + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Quarter") + ).is_false() + + +## An unrecognized rung tag falls back to Region's fullest visibility set — +## the cluster's existing "unrecognized -> safest/most permissive already- +## shipped behavior" posture. +func test_river_class_visible_at_rung_unknown_tag_falls_back_to_region() -> void: + assert_bool( + AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Bogus") + ).is_true() + + +# ============================================================================= +# Feature-group per-rung gates — confluences/mouths/basins/attractors. +# ============================================================================= + + +func test_confluences_visible_at_rung_region_true_others_false() -> void: + assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometry.confluences_visible_at_rung("Quarter")).is_false() + + +## Mouths get the one rung-based EXCEPTION in the whole table: District keeps +## them visible (a mouth is always a landmark, per the ruling) — the only +## feature group where District differs from Region's disposition. +func test_mouths_visible_at_rung_region_and_district_true_quarter_false() -> void: + assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("District")).is_true() + assert_bool(AtlasWindowGeometry.mouths_visible_at_rung("Quarter")).is_false() + + +func test_basins_visible_at_rung_region_only() -> void: + assert_bool(AtlasWindowGeometry.basins_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometry.basins_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometry.basins_visible_at_rung("Quarter")).is_false() + + +func test_attractors_visible_at_rung_region_only() -> void: + assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometry.attractors_visible_at_rung("Quarter")).is_false() + + +# ============================================================================= +# Coordinator live-eyeball finding (2026-07-23): zoom_compensated_size() — +# marker sizes must stay CONSTANT on screen regardless of _view_zoom +# (Araminta's ruling), but draw calls execute inside a Node2D whose .scale IS +# _view_zoom — a raw constant gets multiplied by that transform at render +# time. This function pre-divides so the transform's multiply cancels back +# out to the literal screen-space value. +# ============================================================================= + + +## At zoom=1.0 (the canvas transform's identity scale) the compensated size +## must equal the input unchanged — no over/under-correction at the one zoom +## level where compensation is a no-op by construction. +func test_zoom_compensated_size_at_zoom_one_is_unchanged() -> void: + assert_float(AtlasWindowGeometry.zoom_compensated_size(2.2, 1.0)).is_equal_approx(2.2, 0.0001) + + +## The exact regression shape: at Lendel's real orbital fit zoom (~0.0063, +## live drive script), the compensated size must be much LARGER than the +## raw screen-space constant — inversely proportional to zoom — so that once +## the canvas transform re-multiplies it by view_zoom at render time, the +## EFFECTIVE on-screen size lands back at the literal ruling value, not a +## sub-pixel sliver. +func test_zoom_compensated_size_at_orbital_zoom_scales_up_inversely() -> void: + var view_zoom := 0.0063 + var screen_space_size := 2.2 + var compensated: float = AtlasWindowGeometry.zoom_compensated_size(screen_space_size, view_zoom) + # Round-trip: compensated * view_zoom must reconstruct the original + # screen-space size — this IS the property that makes the on-screen + # result zoom-invariant (the canvas transform performs exactly this + # multiply at render time). + assert_float(compensated * view_zoom).is_equal_approx(screen_space_size, 0.001) + assert_float(compensated).override_failure_message( + "at a tiny orbital zoom, the compensated size must be dramatically LARGER" + + " than the raw screen-space constant — that's the whole point of the fix" + ).is_greater(screen_space_size * 10.0) + + +## The exact BUG this fix closes, pinned as a regression: an UNCOMPENSATED +## radius (screen_space_size used directly, the pre-fix behavior) multiplied +## by Lendel's real orbital zoom produces a sub-pixel effective size — this +## is the "the ruling's px value, at orbital fit zoom, is invisible" claim +## from the coordinator's diagnosis, verified numerically rather than just +## asserted. +func test_uncompensated_radius_at_orbital_zoom_would_be_sub_pixel() -> void: + var view_zoom := 0.0063 + var raw_screen_space_radius := 2.2 # RIVER_DOT_RADIUS_BY_CLASS_REGION[TRUNK] + var effective_size_if_uncompensated: float = raw_screen_space_radius * view_zoom + assert_float(effective_size_if_uncompensated).override_failure_message( + "an uncompensated radius at orbital zoom must be sub-pixel — pinning the" + + " numeric magnitude of the bug this fix closes, not just its existence" + ).is_less(0.02) + + +## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce +## infinity/NaN — the floor guard keeps this function total. +func test_zoom_compensated_size_zero_zoom_does_not_blow_up() -> void: + var result: float = AtlasWindowGeometry.zoom_compensated_size(2.2, 0.0) + assert_bool(is_finite(result)).override_failure_message( + "a degenerate zero view_zoom must not produce inf/NaN" + ).is_true() diff --git a/client/tests/test_atlas_window_nature_overlay.gd b/client/tests/test_atlas_window_nature_overlay.gd new file mode 100644 index 000000000..10354871e --- /dev/null +++ b/client/tests/test_atlas_window_nature_overlay.gd @@ -0,0 +1,255 @@ +## T-1156 wave 1: tests for AtlasWindowNatureOverlay — the whole-body +## Layer-1 (river/basin/attractor) draw node on the zoom ladder. Covers +## request/response lifecycle (idempotent-per-body, staleness guard, decode +## tolerance for a missing river_class array) and the draw-gate wiring +## (rung/overlay-bar double-gate), NOT pixel-level draw output — the +## coordinate math itself is covered directly in +## test_atlas_window_geometry_nature.gd, matching test_atlas_window_overlay.gd's +## own "cache/lifecycle here, colorizer pixels elsewhere" split. +class_name TestAtlasWindowNatureOverlay +extends GdUnitTestSuite + +const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") + + +## Minimal viewer stub — AtlasWindowNatureOverlay only reaches the viewer +## through get_held_granularity_v2()/get_body_radius_km()/get_held_center()/ +## get_held_n()/get_cell_pixel_size()/get_view_zoom()/is_overlay_visible(), +## the same duck-typed-viewer precedent test_atlas_window_overlay.gd's +## _ViewerStub already establishes for AtlasWindowOverlay. get_view_zoom() +## added post-live-eyeball (coordinator finding, 2026-07-23): _draw() now +## reads it for the zoom-compensated marker-size fix. +class _ViewerStub: + var held_granularity_v2: String = "Region" + var body_radius_km: float = 6371.0 + var held_center: Vector2i = Vector2i.ZERO + var held_n: int = 64 + var view_zoom: float = 1.0 + var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": false, "gen_attractors": false} + + func get_held_granularity_v2() -> String: + return held_granularity_v2 + + func get_body_radius_km() -> float: + return body_radius_km + + func get_held_center() -> Vector2i: + return held_center + + func get_held_n() -> int: + return held_n + + func get_cell_pixel_size() -> float: + return 16.0 + + func get_view_zoom() -> float: + return view_zoom + + func is_overlay_visible(overlay_id: String) -> bool: + return bool(overlay_visibility.get(overlay_id, false)) + + +static func _mock_layer1(river_class: Variant = null) -> Dictionary: + var rn: Dictionary = { + "river_cells": [[10, 10], [20, 20], [30, 30]], + "confluences": [[15, 15]], + "mouths": [[40, 40]], + } + if river_class != null: + rn["river_class"] = river_class + return { + "river_network": rn, + "drainage_basins": [{"basin_id": 1, "boundary": [[0, 0], [0, 10], [10, 10], [10, 0]]}], + "attractors": [{"position": [10, 10], "strength": 0.5, "attractor_type": "Oasis", "sub_biome": ""}], + "grid_w": 256, + "grid_h": 128, + } + + +static func _mock_response(body_id: String, layer1: Variant) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "layer1": layer1} + + +## Window-only responses (the OTHER shape SimBridge.atlas_layers_received +## carries, per AtlasWindowRequest's own test conventions) must be ignored — +## `layer1` is null on that envelope, matching atlas_response_from_raw()'s +## "only one of layer1/district_window populated per response" contract. +static func _mock_window_response(body_id: String) -> Dictionary: + return {"body_id": body_id, "status": "Ready", "district_window": {"n": 32}, "layer1": null} + + +func _make_overlay(viewer: Variant = null) -> Variant: + var o = auto_free(AtlasWindowNatureOverlay.new(viewer if viewer != null else _ViewerStub.new())) + add_child(o) + return o + + +# ============================================================================= +# Request lifecycle +# ============================================================================= + + +func test_no_layer1_before_any_request() -> void: + var o = _make_overlay() + assert_that(o.get_layer1()).is_null() + + +func test_response_for_requested_body_is_adopted() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + var layer1: Dictionary = _mock_layer1() + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) + assert_that(o.get_layer1()).is_equal(layer1) + + +## Staleness guard: a response for a body this node never asked for (or +## navigated away from) must be ignored — same posture +## AtlasGenerationProxy.on_response()'s own body_id guard establishes. +func test_response_for_a_different_body_is_ignored() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + SimBridge.atlas_layers_received.emit(_mock_response("SomeOtherBody", _mock_layer1())) + assert_that(o.get_layer1()).is_null() + + +## A window-shaped response (the windowed DistrictWindowLayer envelope, +## `layer1` null) must be ignored outright — this node only ever adopts the +## whole-body Layer-1 envelope. +func test_window_only_response_is_ignored() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + SimBridge.atlas_layers_received.emit(_mock_window_response("GJ380c")) + assert_that(o.get_layer1()).is_null() + + +## request_layer1() for the SAME body id, after data has already arrived, +## must NOT clear the held data — a re-entrant enter_orbital() on the body +## already showing keeps drawing rivers instead of flashing them away. +func test_request_layer1_same_body_after_arrival_keeps_held_data() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + var layer1: Dictionary = _mock_layer1() + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) + o.request_layer1("GJ380c") + assert_that(o.get_layer1()).is_equal(layer1) + + +## request_layer1() for a DIFFERENT body id must clear the previous body's +## held data immediately — the old body's rivers must never draw over the +## new body's terrain during the in-flight gap. +func test_request_layer1_different_body_clears_stale_data() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", _mock_layer1())) + o.request_layer1("AnotherBody") + assert_that(o.get_layer1()).override_failure_message( + "switching bodies must clear the previous body's layer1 data immediately," + + " not just leave it drawn until the new response arrives" + ).is_null() + + +func test_request_layer1_empty_body_id_is_a_noop() -> void: + var o = _make_overlay() + o.request_layer1("") + SimBridge.atlas_layers_received.emit(_mock_response("", _mock_layer1())) + assert_that(o.get_layer1()).override_failure_message( + "an empty body_id must never be requested/adopted" + ).is_null() + + +# ============================================================================= +# Decode tolerance — layer1 without river_class (pre-T-1156 payload / the +# graceful-fallback empty-array case, Dudley's #[serde(default)] contract). +# ============================================================================= + + +## A response with NO river_class key at all (river_network dict omits it — +## the msgpack-decode equivalent of Dudley's serde default producing an +## empty Vec) must still be adopted without error; per-cell class then falls +## back to RIVER_CLASS_FALLBACK (TRUNK) at draw time, not a crash/decode failure. +func test_layer1_without_river_class_key_is_still_adopted() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + var layer1: Dictionary = _mock_layer1() # no river_class arg -> key absent + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) + assert_that(o.get_layer1()).is_equal(layer1) + assert_bool((o.get_layer1()["river_network"] as Dictionary).has("river_class")).is_false() + + +## An explicitly EMPTY river_class array (the actual wire shape Dudley's +## `#[serde(default)]` produces for a pre-T-1156 payload) must also decode +## without error and be adopted — the per-cell RIVER_CLASS_FALLBACK +## resolution this enables is exercised at draw time by +## test_atlas_window_nature_overlay_draw_smoke.gd (real-render smoke suite; +## draw_circle()/draw_rect() calls require a live render pass under this +## engine version — confirmed directly, matching +## test_atlas_window_overlay_draw_smoke.gd's own header doc on why a plain +## unit test cannot call `_draw()` outside one). +func test_layer1_with_empty_river_class_array_is_adopted() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + var layer1: Dictionary = _mock_layer1(PackedByteArray([])) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) + assert_that(o.get_layer1()).is_equal(layer1) + + +## No viewer at all (viewer == null, matching AtlasWindowOverlay's own +## "viewer == null -> return" guard convention) is a legal, inert state — +## _draw()'s null-viewer early-return (BEFORE any draw_*() call) is safe to +## call directly since it never reaches the engine's draw-context requirement. +func test_draw_with_null_viewer_is_a_noop() -> void: + var o = auto_free(AtlasWindowNatureOverlay.new(null)) + add_child(o) + o._draw() + assert_that(o.get_layer1()).is_null() + + +## grid_w/grid_h missing or zero (a malformed/degenerate layer1) must decode +## and adopt cleanly — _draw()'s own grid_w<=0/grid_h<=0 early-return (also +## BEFORE any draw_*() call) is exercised the same direct way. +func test_draw_with_zero_grid_dims_returns_before_any_draw_call() -> void: + var o = _make_overlay() + o.request_layer1("GJ380c") + var layer1: Dictionary = _mock_layer1() + layer1["grid_w"] = 0 + layer1["grid_h"] = 0 + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", layer1)) + o._draw() # grid_w<=0 -> returns before touching the canvas — safe to call directly + assert_that(o.get_layer1()).is_not_null() + + +## PR #195 review (Tyre I1) regression pin: every stroke-WIDTH argument in +## _draw_attractor_shape() must route through the pre-compensated `px_w` +## param, never a raw numeric literal — Godot multiplies stroke widths by the +## canvas scale exactly like radii, so a raw `2.0` rasterizes at ~0.01px at +## the Region orbital fit zoom (the identical sub-pixel failure the dot/ring +## zoom compensation fixed, missed on glyph outlines in the first pass). The +## draw-smoke suite cannot gate this (its own header documents the vacuous- +## pass mode under X11 BadMatch), so this is a SOURCE-SCAN pin: parse the +## overlay script's _draw_attractor_shape body and assert no draw_arc/ +## draw_line call carries a bare numeric width literal. Crude but +## environment-independent, and it pins the exact regression class (someone +## reintroducing a literal width in a new glyph arm). +func test_attractor_shape_stroke_widths_are_never_raw_literals() -> void: + var src: String = ( + FileAccess.get_file_as_string("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") + ) + var fn_start := src.find("func _draw_attractor_shape(") + assert_that(fn_start).override_failure_message( + "_draw_attractor_shape must exist in atlas_window_nature_overlay.gd" + ).is_not_equal(-1) + var next_fn := src.find("\nfunc ", fn_start + 1) + var body := src.substr(fn_start, (next_fn - fn_start) if next_fn != -1 else -1) + var stroke_re := RegEx.new() + # A draw_arc/draw_line call whose FINAL (width) argument is a bare numeric + # literal: `, )` at call end. px_w-scaled forms + # (`px_w`, `2.0 * px_w`) do not match. + stroke_re.compile("draw_(arc|line)\\([^\\n]*,\\s*\\d+(\\.\\d+)?\\s*\\)") + var hits := stroke_re.search_all(body) + var offenders: Array[String] = [] + for hit in hits: + offenders.append(hit.get_string()) + assert_array(offenders).override_failure_message( + "raw numeric stroke width(s) in _draw_attractor_shape — route through" + + " px_w (PR #195 Tyre I1): %s" % [offenders] + ).is_empty() diff --git a/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd b/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd new file mode 100644 index 000000000..bb11b35d2 --- /dev/null +++ b/client/tests/test_atlas_window_nature_overlay_draw_smoke.gd @@ -0,0 +1,281 @@ +## T-1156 wave 1: a REAL draw smoke test for AtlasWindowNatureOverlay, +## matching test_atlas_window_overlay_draw_smoke.gd's established pattern — +## `draw_circle()`/`draw_rect()`/`draw_arc()`/`draw_colored_polygon()` calls +## require a live render pass under this engine version (confirmed directly: +## a plain unit test calling `_draw()` outside one throws "Drawing is only +## allowed inside this node's `_draw()`..."). Render into a REAL SubViewport, +## force a settle wait, and assert visible non-background pixels — proving +## the river-dot/mouth-ring/basin-polygon/attractor-glyph draw calls actually +## paint, not just that the state feeding them is correct (that half is +## test_atlas_window_nature_overlay.gd's job). +## +## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under +## `tests/run-godot`'s hardcoded `--headless`**, same posture/rationale as +## test_atlas_window_overlay_draw_smoke.gd's own header doc (dummy driver, +## no GPU texture output — SubViewport.get_texture().get_image() returns an +## all-zero/unusable image under it). +## +## To actually exercise this file's assertions, run it with a real driver: +## godot4 --display-driver x11 --rendering-driver opengl3 \ +## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ +## -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd +## +## **Known limitation, verified directly (2026-07-23 zoom-compensation fix +## verification):** the shared _BackgroundRect/_render_to_image() harness +## (copied from test_atlas_window_overlay_draw_smoke.gd) occasionally fails +## to composite the COLOR_BG fill at all under a real X11/opengl3 run in this +## environment (sampled pixels read (0,0,0,0), not COLOR_BG — an unrelated, +## intermittent X11/SubViewport timing issue, confirmed via an XServer +## "BadMatch" warning in that same run's log). When that happens EVERY pixel +## reads as "non-background" regardless of what this overlay actually draws, +## making a low MIN_NON_BACKGROUND_FRACTION threshold pass VACUOUSLY (it +## would pass even with zoom-compensation deliberately broken — confirmed by +## direct revert-test). The terrain sibling suite is accidentally immune to +## this (its checkerboard composite covers most of the frame regardless of +## background correctness); this suite's SPARSE markers are not. **The +## reliable, environment-independent regression gate for the zoom- +## compensation fix is therefore the pure-function suite in +## test_atlas_window_geometry_nature.gd** (zoom_compensated_size()'s own +## tests) — this smoke suite is a supplementary "does it actually paint" +## check when the harness cooperates, not the primary gate. +class_name TestAtlasWindowNatureOverlayDrawSmoke +extends GdUnitTestSuite + +const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") + +const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const) +const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512) +const MIN_NON_BACKGROUND_FRACTION: float = 0.0005 # river dots are sparse — a low bar is honest here + +const SKIP_REASON: String = ( + "no real rendering driver (dummy/headless) — run with e.g." + + " `godot4 --display-driver x11 --rendering-driver opengl3" + + " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c" + + " -a res://tests/test_atlas_window_nature_overlay_draw_smoke.gd` to exercise this file" +) + + +## Matches test_atlas_window_overlay_draw_smoke.gd's own detection exactly. +static func _dummy_renderer_active() -> bool: + return DisplayServer.get_name() == "headless" + + +## Same duck-typed viewer contract as test_atlas_window_nature_overlay.gd's +## _ViewerStub, minus the SimBridge-signal machinery this smoke test doesn't +## need (layer1 is injected directly via _layer1, not through a response). +## view_zoom MUST be kept in sync with whatever zoom _render_to_image() is +## called with — the whole point of this smoke suite (post-live-eyeball, +## coordinator finding 2026-07-23) is proving markers stay visible at the +## REAL orbital fit zoom, not an artificially large test zoom that would +## mask the zoom-compensation bug the fix addresses. +class _ViewerStub: + var held_granularity_v2: String = "Region" + var body_radius_km: float = 6371.0 + var held_center: Vector2i = Vector2i.ZERO + var held_n: int = 64 + var view_zoom: float = 1.0 + var overlay_visibility: Dictionary = {"gen_rivers": true, "gen_basins": true, "gen_attractors": true} + + func get_held_granularity_v2() -> String: + return held_granularity_v2 + + func get_body_radius_km() -> float: + return body_radius_km + + func get_held_center() -> Vector2i: + return held_center + + func get_held_n() -> int: + return held_n + + func get_cell_pixel_size() -> float: + return 16.0 + + func get_view_zoom() -> float: + return view_zoom + + func is_overlay_visible(overlay_id: String) -> bool: + return bool(overlay_visibility.get(overlay_id, false)) + + +## A dense scatter of river cells spanning the whole held window's canvas +## footprint (not clustered in one corner) — a genuine "does the composite +## draw something visible across the frame" check, same intent as the +## terrain smoke test's checkerboard morphology spread. +static func _mock_layer1_dense() -> Dictionary: + var river_cells: Array = [] + var river_class: PackedByteArray = PackedByteArray() + for i in range(40): + river_cells.append([i * 3, i * 3]) + river_class.append(2) # trunk — visible at every rung this suite exercises + return { + "river_network": { + "river_cells": river_cells, + "river_class": river_class, + "confluences": [[60, 60]], + "mouths": [[120, 120]], + }, + "drainage_basins": [ + {"basin_id": 1, "boundary": [[0, 0], [0, 40], [40, 40], [40, 0]]}, + ], + "attractors": [ + {"position": [80, 80], "strength": 0.9, "attractor_type": "Oasis", "sub_biome": ""}, + ], + "grid_w": 256, + "grid_h": 128, + } + + +## Same _BackgroundRect/_render_to_image/_non_background_fraction shared +## rendering infrastructure as test_atlas_window_overlay_draw_smoke.gd — +## duplicated rather than imported since gdUnit4 test suites are not +## typically composed via inheritance in this codebase (no precedent for a +## shared test-infra base class in client/tests/), and the block is small. +class _BackgroundRect extends Node2D: + var fill_color: Color = Color.BLACK + var fill_size: Vector2 = Vector2.ZERO + + func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color) + + +func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image: + var sub_viewport := SubViewport.new() + sub_viewport.size = VIEWPORT_SIZE + sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + sub_viewport.transparent_bg = false + add_child(sub_viewport) + auto_free(sub_viewport) + + var bg := _BackgroundRect.new() + bg.fill_color = COLOR_BG + bg.fill_size = Vector2(VIEWPORT_SIZE) + sub_viewport.add_child(bg) + bg.queue_redraw() + + var canvas := Node2D.new() + canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 + canvas.scale = Vector2(zoom, zoom) + sub_viewport.add_child(canvas) + canvas.add_child(overlay) + overlay.queue_redraw() + + for _i in range(6): + await get_tree().process_frame + + return sub_viewport.get_texture().get_image() + + +static func _non_background_fraction(image: Image) -> float: + var w: int = image.get_width() + var h: int = image.get_height() + if w <= 0 or h <= 0: + return 0.0 + var total: int = w * h + var differing: int = 0 + var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0) + for y in range(h): + for x in range(w): + var px: Color = image.get_pixel(x, y) + var px_rgb: Color = Color(px.r, px.g, px.b, 1.0) + if not px_rgb.is_equal_approx(bg_rgb): + differing += 1 + return float(differing) / float(total) + + +## Region rung, every toggle on: rivers + confluence + mouth + basin fill + +## attractor glyph must all draw SOMETHING — the "does the composite actually +## paint" proof the state-only unit suite cannot provide. +func test_region_rung_draws_visible_pixels() -> void: + if _dummy_renderer_active(): + print(SKIP_REASON) + return + var overlay := AtlasWindowNatureOverlay.new() + var stub := _ViewerStub.new() + overlay.viewer = stub + overlay._layer1 = _mock_layer1_dense() + overlay._requested_body_id = "SmokeBody" + + # Zoom chosen so the held window's canvas footprint (held_n * cell_px = + # 64 * 16 = 1024 units) comfortably fills the 512px viewport. + var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size()) + stub.view_zoom = zoom + var image: Image = await _render_to_image(overlay, zoom) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "the Region-rung nature composite (rivers + confluence + mouth + basin +" + + " attractor, every toggle on) must render VISIBLE non-background pixels —" + + " got only %.4f%% of the frame differing from COLOR_BG" + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) + + +## District rung: only trunk rivers (class 2, all cells in the dense fixture +## are trunk) + the mouth carve-out draw; basins/attractors/confluences are +## rung-gated off. Still must produce visible pixels — proving the "fade +## down, don't vanish" posture actually leaves SOMETHING on screen. +func test_district_rung_still_draws_visible_pixels() -> void: + if _dummy_renderer_active(): + print(SKIP_REASON) + return + var overlay := AtlasWindowNatureOverlay.new() + var stub := _ViewerStub.new() + stub.held_granularity_v2 = "District" + overlay.viewer = stub + overlay._layer1 = _mock_layer1_dense() + overlay._requested_body_id = "SmokeBody" + + var zoom: float = float(VIEWPORT_SIZE.x) / (float(stub.held_n) * stub.get_cell_pixel_size()) + stub.view_zoom = zoom + var image: Image = await _render_to_image(overlay, zoom) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "the District-rung composite (trunk rivers + mouth carve-out only) must" + + " still render VISIBLE non-background pixels — got only %.4f%% of the" + + " frame differing from COLOR_BG" + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) + + +## Coordinator live-eyeball regression (2026-07-23): the ORBITAL TILE MOSAIC +## rest state's REAL fit zoom on a Lendel-scale body (~0.0063, confirmed +## directly via a live drive script — held_n=19139 districts, +## cell_px=16, a ~1600px viewport) — NOT the comfortable ~0.5 zoom the +## sibling test above uses. Before the zoom-compensation fix, this exact +## zoom magnitude produced ZERO visible river/mouth pixels (a 2.2px trunk +## dot rasterized at ~0.014 screen px) despite RVR being on and the policy +## table correctly returning full Region visibility — the captures showed +## terrain-only with literally nothing drawn. Pins the regression at +## production scale, not a toy zoom that could accidentally still pass. +func test_orbital_scale_zoom_still_draws_visible_pixels() -> void: + if _dummy_renderer_active(): + print(SKIP_REASON) + return + var overlay := AtlasWindowNatureOverlay.new() + var stub := _ViewerStub.new() + stub.held_n = 19139 # Lendel's own raw circumference, live drive script + stub.view_zoom = 0.0063 # Lendel's own live orbital fit zoom + overlay.viewer = stub + overlay._layer1 = _mock_layer1_dense() + overlay._requested_body_id = "SmokeBody" + + var image: Image = await _render_to_image(overlay, stub.view_zoom) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "at Lendel's REAL orbital fit zoom (~0.0063), the Region-rung nature" + + " composite must still render VISIBLE non-background pixels — got only" + + " %.4f%% of the frame differing from COLOR_BG. This is exactly the" + + " coordinator's live-eyeball finding: uncompensated screen-space marker" + + " sizes get multiplied by the canvas's own zoom transform, vanishing" + + " sub-pixel at the orbital rest state's tiny fit zoom." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd index 127efedfe..94fc603f5 100644 --- a/client/tests/test_atlas_window_viewer.gd +++ b/client/tests/test_atlas_window_viewer.gd @@ -16,6 +16,11 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re # T-1142: district_extent()/canonicalize_district_center() — used to derive # real (cols/rows_half) bounds for the wrap/pole-wall tests below. const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +# T-1156 wave 1 round 3: AtlasWindowNatureOverlay has no class_name (matching +# atlas_overlay_bar.gd/atlas_window_request.gd's own no-class_name precedent, +# review #8) — subclassing it (the _CountingNatureOverlay spy below) needs +# the preloaded script's PATH via `extends`, not a global class name. +const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") ## Build a hand-authored DistrictWindowLayer dict (n=2, matching the shape @@ -131,6 +136,103 @@ func test_set_overlay_visible_unknown_id_is_a_noop() -> void: assert_bool(v.is_overlay_visible("not_a_real_overlay")).is_false() +## PR #195 review (Hoshe): named default-visibility pins for the T-1156 +## nature overlays at fresh-viewer construction, per Araminta's ruling — +## RVR ON (rivers are load-bearing geography, a first-time Atlas viewer sees +## them without hunting for a toggle: the single most player-visible behavior +## the feature ships), BAS and ATR OFF (secondary/dev-facing analytical +## layers, opt-in). Without these, a refactor of the _ready() visibility-init +## loop could silently flip the defaults and only a live capture would +## notice — gen_basins was previously covered only incidentally by the +## toggle-redraw spy test above. +func test_nature_overlay_defaults_rivers_on_basins_and_attractors_off() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + assert_bool(v.is_overlay_visible("gen_rivers")).override_failure_message( + "gen_rivers must default ON (Araminta's RVR-on ruling, T-1156 wave 1)" + ).is_true() + assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( + "gen_basins must default OFF (opt-in analytical layer)" + ).is_false() + assert_bool(v.is_overlay_visible("gen_attractors")).override_failure_message( + "gen_attractors must default OFF (dev-facing detail, opt-in)" + ).is_false() + + +## Counts real _draw() invocations on AtlasWindowNatureOverlay — CanvasItem +## exposes no public "is a redraw pending" query in this Godot version +## (confirmed directly: is_queued_for_redraw() does not exist on Node2D here +## — an earlier version of this test assumed it did and failed with +## "Invalid call. Nonexistent function"), so the only reliable signal that +## queue_redraw() actually had an effect is the engine calling _draw() again +## on a subsequent frame. Matches test_atlas_cold_start.gd's own +## _CountingOverlay precedent exactly (same file's own doc: "the only +## reliable signal... is the engine calling _draw() again") — subclasses the +## REAL AtlasWindowNatureOverlay so drawing still runs through genuine +## production code, this spy only adds counting. +class _CountingNatureOverlay extends AtlasWindowNatureOverlay: + var draw_count := 0 + + func _draw() -> void: + draw_count += 1 + super._draw() + + +## Coordinator live-eyeball round 3 (2026-07-23): R1/R2 captures were +## byte-identical because a scratch drive script called +## set_overlay_visible("BAS", true) — the button LABEL, not the overlay id +## ("gen_basins") — which set_overlay_visible()'s own `not +## _overlay_visibility.has(overlay_id): push_warning(...); return` guard +## silently no-ops on. Real callers (atlas_overlay_bar.gd's +## _on_toggle_changed()) always pass def["id"], never the label, so product +## code was never actually broken — but this pins the EXACT gate the +## coordinator asked to verify: toggling gen_basins via the real viewer API +## must (a) flip is_overlay_visible("gen_basins") — the nature overlay's OWN +## draw gate, read live via viewer.is_overlay_visible() at _draw() time, not +## a stale copy — AND (b) actually cause the NATURE overlay (not just the +## terrain overlay/viewer) to redraw on the next frame, proven by swapping in +## a _draw()-counting spy (matching test_atlas_cold_start.gd's +## _CountingOverlay pattern) and confirming draw_count advances past a +## settled baseline after the toggle, with no other gesture. +func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( + "gen_basins must default to OFF (Araminta's ruling — BAS defaults off)" + ).is_false() + + # Swap in the counting spy (matching _CountingOverlay's own swap-after- + # construction shape) so entry's own queue_redraw() calls don't pollute + # the baseline, then let it settle before touching the toggle. + var spy := _CountingNatureOverlay.new(v) + v._nature_overlay.queue_free() + v._nature_overlay = spy + v._canvas.add_child(spy) + + await get_tree().process_frame + await get_tree().process_frame + var baseline: int = spy.draw_count + assert_int(baseline).override_failure_message( + "sanity: the spy must have drawn at least once before the toggle, or" + + " this test can't distinguish 'redrawn BY the toggle' from 'never" + + " drawn at all'" + ).is_greater(0) + + v.set_overlay_visible("gen_basins", true) + await get_tree().process_frame + + assert_bool(v.is_overlay_visible("gen_basins")).override_failure_message( + "the toggle must flip the draw gate is_overlay_visible() reads live" + ).is_true() + assert_int(spy.draw_count).override_failure_message( + "the toggle must queue_redraw() the NATURE overlay specifically —" + + " queue_redraw() on the viewer/terrain overlay alone leaves the" + + " nature node's last frame cached (a Node2D child does not redraw" + + " because its sibling did) — draw_count must have advanced past the" + + " baseline (%d)" % baseline + ).is_greater(baseline) + + # ============================================================================= # T-1120 capture-API parity (the ticket's explicit note: must survive here too) # ============================================================================= diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd index c6b775cfe..05d45623f 100644 --- a/client/tests/test_atlas_zoom_ladder.gd +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -109,6 +109,38 @@ func test_enter_orbital_tile_mode_held_n_is_the_whole_body_extent() -> void: assert_int(v._held_n).is_equal(raw_cols) +## Coordinator live-eyeball dossier (2026-07-23, suspect 2 — DIAGNOSED FALSE +## but pinned as a regression guard anyway per the coordinator's own +## instruction): AtlasWindowNatureOverlay's _draw() reads +## viewer.get_held_granularity_v2() to key its per-rung policy tables +## (RIVER_CLASS_VISIBLE_BY_RUNG etc.) — if that accessor returned anything +## other than the EXACT string "Region" while is_tile_mode() is true (an +## empty string, a stale District default, a different-cased tag...), the +## visibility tables would silently return their empty/default disposition +## and NOTHING would draw, indistinguishable from the live captures' +## "literally zero river dots" symptom. Live drive-script evidence +## (NATURE_DEBUG print, since removed) confirmed this was NOT the actual bug +## — get_held_granularity_v2() already correctly returns "Region" in tile +## mode — but this test makes that fact load-bearing instead of merely +## observed once, so a future refactor of _enter_tile_mode()'s +## _held_granularity_v2 assignment trips a named failure here. +func test_get_held_granularity_v2_is_exactly_region_string_in_tile_mode() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + var radius_km := 6238.4 # GJ380c (Lendel) — needs tiling + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + + assert_bool(v.is_tile_mode()).override_failure_message( + "this test's premise requires tile mode — Lendel must still need tiling" + ).is_true() + assert_str(v.get_held_granularity_v2()).override_failure_message( + "AtlasWindowNatureOverlay's _draw() keys its ENTIRE per-rung policy off" + + " this exact string — anything other than the literal 'Region' silently" + + " empties every visibility table and draws nothing, indistinguishable" + + " from the live-capture symptom (zero river dots at the orbital rest state)" + ).is_equal("Region") + + ## **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 diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index 3dcbd5385..649458607 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -108,6 +108,93 @@ const MAX_COVERAGE_M: Dictionary = { const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"] +# ============================================================================= +# T-1156 wave 1: per-rung nature-overlay visibility/styling policy (Araminta's +# presentation ruling, 2026-07-23 — supersedes Tyre's provisional +# add-detail-as-you-descend mapping the ticket brief originally carried). The +# ladder INVERTS: Region/orbital shows the FULL skeleton (the rung whose data +# density actually supports a "river system" read at 76 km/dot spacing); +# District and Quarter fade the read DOWN, not up, because a single-heightmap- +# pixel river course has nothing finer to reveal as the player descends until +# T-1170 invents real sub-heightmap courses. This table is the one place that +# posture lives — revisit here, and only here, when T-1170 lands. (Consts only +# — the READER functions that consult these tables live further down, grouped +# with the other layer1_* pixel-mapping functions per class-definitions-order.) +# ============================================================================= + +## River class ids — mirrors server/src/atlas/body_world_state.rs +## RiverNetwork.river_class's own doc exactly (0=stream, 1=tributary, +## 2=trunk). A `river_class` array shorter than `river_cells` (pre-T-1156 +## payload, or the graceful-fallback empty-array case) has no per-cell class +## to read — RIVER_CLASS_FALLBACK is what a missing entry resolves to: TRUNK, +## so an old/absent river_class array still shows something at every rung +## rather than silently vanishing (Dudley's `#[serde(default)]` empty-array +## contract makes "index out of range" the normal case for a pre-T-1156 +## response, not an edge case to special-case away). +const RIVER_CLASS_STREAM: int = 0 +const RIVER_CLASS_TRIBUTARY: int = 1 +const RIVER_CLASS_TRUNK: int = 2 +const RIVER_CLASS_FALLBACK: int = RIVER_CLASS_TRUNK + +## Per-rung river-class visibility — which RIVER_CLASS_* ids draw at all, at +## each granularity_v2 tag. Region shows every class (the full skeleton); +## District shows trunk only; Quarter shows none (rivers off entirely at that +## rung per the ruling). +const RIVER_CLASS_VISIBLE_BY_RUNG: Dictionary = { + "Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], + "District": [RIVER_CLASS_TRUNK], + "Quarter": [], +} + +## Per-rung feature-group toggles beyond river-cell class filtering — whether +## confluences/mouths/basins/attractors draw at all at a given rung (each +## still additionally gated by its own overlay-bar toggle, RVR/BAS/ATR, where +## applicable — this table is the RUNG gate, the overlay bar is the PLAYER +## gate, both must pass). Mouths get the one rung-based exception in the whole +## table: District keeps them at full Region styling/opacity (a mouth is +## always a landmark, per the ruling) while every other District river feature +## is suppressed or de-emphasized. +const CONFLUENCES_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} +const MOUTHS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": true, "Quarter": false} +const BASINS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} +const ATTRACTORS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false} + +## Per-rung river dot styling (screen-space px, at zoom=1.0 — the same +## "canvas-local px" domain every other drawn feature in this cluster already +## uses, scaled by the caller's own view zoom like everything else in +## `_canvas`). District trunk dots are smaller AND drawn at reduced opacity +## (80% — raised from the ruling's initial 60% in Araminta's PR #195 capture +## review: at 1.6px/60% the dot was "essentially invisible without knowing +## where to look", underselling the 'a major river crosses near here' intent; +## 2.0px/80% keeps the fade-down ladder vs Region's 2.2px/100% without +## reading as accidentally-erased) — the "fade down" the ruling describes; +## Region dots are full-strength +## opacity (alpha baked into the reused COLOR_GEN_RIVER/COLOR_GEN_MOUTH +## constants themselves, alpha 1.0). Quarter has no entry — rivers don't draw +## there at all, so no radius/opacity is ever looked up for that rung. +const RIVER_DOT_RADIUS_BY_CLASS_REGION: Dictionary = { + RIVER_CLASS_STREAM: 0.9, + RIVER_CLASS_TRIBUTARY: 1.4, + RIVER_CLASS_TRUNK: 2.2, +} +const RIVER_CONFLUENCE_RADIUS_REGION: float = 3.5 +const RIVER_DOT_RADIUS_DISTRICT_TRUNK: float = 2.0 +const RIVER_DOT_OPACITY_DISTRICT_TRUNK: float = 0.8 + +## Mouth double-ring geometry (Region AND District — mouths never de-emphasize, +## per the ruling) — verbatim from the retired atlas_marker_overlay.gd +## _draw_gen_rivers() (:537-539), reused exactly, not re-tuned. +const MOUTH_RING_RADIUS: float = 5.0 +const MOUTH_HALO_RADIUS: float = 8.0 +const MOUTH_HALO_ALPHA: float = 0.30 + +## Attractor minimum-strength gate — verbatim from the retired +## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per +## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering +## at any other rung to gate. +const ATTRACTOR_MIN_STRENGTH: float = 0.15 + + ## 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 ## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's @@ -686,3 +773,152 @@ static func screen_header_content( static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2: var center: Vector2 = viewport_size * 0.5 return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5) + + +# ============================================================================= +# T-1156 wave 1: whole-body Layer-1 (river/basin/attractor) pixel-space -> +# canvas-local mapping for the zoom ladder — the nature-overlay counterpart to +# the district/canvas machinery above. Layer-1's `river_network`/ +# `drainage_basins`/`attractors` positions are (row, col) heightmap-pixel +# coordinates in a `grid_w`(cols) x `grid_h`(rows) working grid (Rust +# `Layer1Output.grid_w/grid_h` = `BodyHeightmap.width/height` = the SAME +# `TerrainAnalysis.w/h` river/attractor extraction ran against — +# server/src/atlas/layer1.rs, features.rs `TerrainAnalysis::analyze`). This is +# NOT the atlas_marker_overlay.gd `_gen_pos()` texture-fraction mapping (that +# maps onto a DISPLAYED heightmap texture on the retired planetary screen) — +# the ladder has no resident heightmap texture at all, so pixel positions must +# go all the way to WORLD METRES -> DISTRICT space -> canvas-local, the same +# frame district_to_canvas_local() already establishes for every other drawn +# feature on this screen. +# ============================================================================= + + +## Heightmap pixel (row, col) -> absolute world metres (wx east, wy south), +## mirroring server/src/atlas/district_profile.rs's `pixel_to_world_m()` +## EXACTLY (verified against that function's source, not assumed): longitude +## WRAPS and is addressed by the plain column fraction (`col / grid_w`) against +## the full circumference — column 0 sits at world/longitude 0, no -0.5 +## centering unlike latitude. Latitude CLAMPS at the poles and is addressed by +## `row / (grid_h - 1) - 0.5`, i.e. row 0 is exactly the pole (lat_frac -0.5 = +## north pole = wy negative-most) and row (grid_h - 1) is exactly the opposite +## pole (lat_frac +0.5 = south pole = wy positive-most) — the SAME "row +## increases southward" convention AtlasDescendGeometry.district_pos_at() +## already assumes for its own (inverse-direction) pixel<->district mapping, +## confirmed here to be the same convention layer1's grid uses, not a +## different one that happens to share variable names. +## +## No-radius bodies (body_radius_km <= 0, tiny test bodies): 1 heightmap pixel +## = 1 district-spacing metre, matching pixel_to_world_m()'s own no-radius +## fallback (`px * scale::DISTRICT_M`) and district_pos_at()'s no-radius +## branch on the other side of this mapping. Uses this file's own +## DISTRICT_SPACING_M (the same 2,048 m/district constant, this file's +## existing name for it — NOT a re-derivation). +static func layer1_pixel_to_world_m( + row: float, col: float, grid_w: float, grid_h: float, body_radius_km: float +) -> Vector2: + if grid_w <= 0.0 or grid_h <= 0.0: + return Vector2.ZERO + if body_radius_km <= 0.0: + return Vector2(col * DISTRICT_SPACING_M, row * DISTRICT_SPACING_M) + var circumference_m: float = TAU * body_radius_km * 1000.0 + var meridian_m: float = PI * body_radius_km * 1000.0 + var wx: float = (col / grid_w) * circumference_m + var lat_frac: float = (row / (grid_h - 1.0) - 0.5) if grid_h > 1.0 else 0.0 + var wy: float = lat_frac * meridian_m + return Vector2(wx, wy) + + +## World metres -> fractional DistrictPos (NOT rounded to an integer district +## — a river dot's true position is sub-district-precise even though the +## window grid itself is district-granular; rounding here would visibly snap +## every river pixel onto a district lattice). DISTRICT_SPACING_M is this +## file's own existing constant (2,048 m/district, D-243) — one division, no +## re-derivation. +static func world_m_to_district(world_m: Vector2) -> Vector2: + return world_m / DISTRICT_SPACING_M + + +## The full pixel(row,col) -> canvas-local composition a nature-overlay draw +## call needs in one step: heightmap pixel -> world metres -> fractional +## district -> canvas-local (via the EXISTING district_to_canvas_local(), +## reused verbatim so a river dot lands in exactly the same coordinate frame +## every other drawn feature on this screen already agrees on — pan/zoom/rung +## crossings all move the SAME transform under everything drawn into +## `_canvas`). Wrap resolution (nearest_wrap_image()) is the CALLER's job, same +## split the tile mosaic draw path already uses — this function's `district` +## output is the RAW (un-wrapped) fractional position; a caller iterating +## river cells against a specific held window picks the nearest wrap-image of +## the COLUMN only (rows never wrap, matching every other wrap-aware caller in +## this cluster). +static func layer1_pixel_to_canvas_local( + row: float, + col: float, + grid_w: float, + grid_h: float, + body_radius_km: float, + held_center: Vector2i, + held_n: int, + cell_pixel_size: float +) -> Vector2: + var world_m: Vector2 = layer1_pixel_to_world_m(row, col, grid_w, grid_h, body_radius_km) + var district: Vector2 = world_m_to_district(world_m) + return district_to_canvas_local(district, held_center, held_n, cell_pixel_size) + + +# ============================================================================= +# T-1156 wave 1: per-rung nature-overlay visibility policy READERS. The policy +# TABLES themselves (RIVER_CLASS_VISIBLE_BY_RUNG etc.) live up in the +# top-of-file const block per class-definitions-order (Araminta's ruling, +# 2026-07-23, is documented there). +# ============================================================================= + + +## Whether a river cell of `river_class` should draw at `granularity_v2`. An +## unrecognized rung tag falls back to Region's (fullest) visibility set — +## matching this cluster's existing "unrecognized -> most permissive/safest +## already-shipped behavior" posture (see AtlasWindowOverlay._filter_for_ +## granularity_v2()'s own doc for the same fallback shape, there choosing the +## safer LINEAR filter for an unknown tag). +static func river_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: + var visible: Array = RIVER_CLASS_VISIBLE_BY_RUNG.get( + granularity_v2, RIVER_CLASS_VISIBLE_BY_RUNG["Region"] + ) + return visible.has(river_class) + + +static func confluences_visible_at_rung(granularity_v2: String) -> bool: + return bool(CONFLUENCES_VISIBLE_BY_RUNG.get(granularity_v2, true)) + + +static func mouths_visible_at_rung(granularity_v2: String) -> bool: + return bool(MOUTHS_VISIBLE_BY_RUNG.get(granularity_v2, true)) + + +static func basins_visible_at_rung(granularity_v2: String) -> bool: + return bool(BASINS_VISIBLE_BY_RUNG.get(granularity_v2, true)) + + +static func attractors_visible_at_rung(granularity_v2: String) -> bool: + return bool(ATTRACTORS_VISIBLE_BY_RUNG.get(granularity_v2, true)) + + +## Coordinator live-eyeball finding (2026-07-23): Araminta's ruling specifies +## nature-overlay marker sizes as SCREEN-SPACE px, constant regardless of +## zoom — but every draw call in this cluster (river dots, mouth rings, basin +## line widths) executes inside `_canvas`, a Node2D whose `.scale` IS +## `_view_zoom` (AtlasWindowViewer._apply_transform()). A raw radius/width +## constant handed to draw_circle()/draw_arc()/draw_polyline() therefore gets +## multiplied by `_view_zoom` at render time — invisible at the Region +## orbital tile mosaic's fit zoom (~0.0063 for Lendel: a 2.2px trunk-river +## dot rasterizes at ~0.014 screen px, sub-pixel), even though the SAME +## drawing code produces a correctly-sized (visible) mouth ring at District's +## much larger fit zoom (~3.75, live capture confirmed this). The fix: every +## marker's draw-time radius/width must be pre-divided by `view_zoom` so the +## canvas transform's multiply cancels back out to the ruling's literal +## screen-space value. `view_zoom` is clamped to a small positive floor +## (MIN_ZOOM's own order of magnitude) to avoid a divide-by-zero/near-zero +## blowup on a degenerate zero-zoom caller — this floor is far below any +## legal `_view_zoom` (AtlasWindowViewer.MIN_ZOOM = 0.0005), so it is inert +## for every real caller and only guards a malformed test input. +static func zoom_compensated_size(screen_space_size: float, view_zoom: float) -> float: + return screen_space_size / maxf(view_zoom, 0.0001) diff --git a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd new file mode 100644 index 000000000..27e8d8080 --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd @@ -0,0 +1,381 @@ +extends Node2D + +## Draws the whole-body Layer-1 nature overlays (rivers/basins/attractors, +## T-1156 wave 1) on the AtlasWindowViewer zoom ladder. Child of +## AtlasWindowViewer._canvas, ABOVE the terrain composite (AtlasWindowOverlay) +## and below UI chrome — same parent, same pan/zoom transform, drawn after so +## river dots/basin fills sit on top of the terrain colorizer. +## +## This is a PORT, not a reactivation, of the retired planetary-screen draw +## code (atlas_marker_overlay.gd:523-572, _draw_gen_rivers/_draw_gen_basins/ +## _draw_gen_attractors) — atlas_marker_overlay.gd stays retired/unreachable. +## The drawing IDEAS survive (dot-scatter rivers, polygon basins, glyph-free +## double-ring mouths, draw order basins-under-rivers-under-attractors); the +## COORDINATE MAPPING does not — the retired code projected onto a resident +## displayed heightmap TEXTURE (_gen_pos(), texture-fraction space) that this +## ladder screen has no equivalent of. Positions here go all the way through +## world metres -> district -> canvas-local +## (AtlasWindowGeometry.layer1_pixel_to_canvas_local()), the same frame every +## other drawn feature on this screen already shares, wrap-resolved exactly +## like the tile mosaic resolves terrain tiles. +## +## Data source: the WHOLE-BODY Layer-1 response (`layer1` key on the shared +## AtlasLayerResponse envelope, SimBridge.atlas_layers_received) — a SEPARATE +## fetch from the windowed DistrictWindowLayer composite AtlasWindowOverlay +## draws (both responses ride the SAME signal, discriminated by which +## envelope key is populated — SimBridge/atlas_map_protocol.gd's decode +## always includes both `layer1` and `district_window` keys, only one +## non-null per response, per that decoder's own doc). This node connects to +## SimBridge.atlas_layers_received DIRECTLY (mirroring +## atlas_window_tile_set.gd's own "one shared inbound signal, N independent +## consumers filtering by their own criteria" shape) rather than being routed +## through AtlasWindowViewer's own _on_atlas_layers_received() — the viewer +## stays at the gdlint max-file-lines cap with this node needing zero new +## lines in that function. Requested once per body entry via request_layer1() +## (call sites: the viewer's _enter_at_rung() and _enter_tile_mode() — every +## fresh descent funnels through one of those two; enter() itself is a thin +## wrapper over _enter_at_rung and has no call of its own), which owns +## clearing stale data on a body change itself +## (see that function's own doc, no separate reset() call needed) — cached +## thereafter, rivers are static per body, no re-request on pan/zoom/rung +## crossing. +## +## No `class_name` on purpose, matching every other viewer-owned helper in +## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd/ +## atlas_window_tile_set.gd, review #8 precedent): the owner +## (AtlasWindowViewer) passes itself to `_init()`. +## +## Redraw wiring: no _process() poll — AtlasWindowViewer/AtlasWindowOverlay +## already call `_overlay_node.queue_redraw()` on every pan/zoom/rung- +## crossing/window-arrival event; adding this node as a SIBLING of +## AtlasWindowOverlay under `_canvas` means the viewer's existing redraw call +## sites need exactly one more line each (`_nature_overlay.queue_redraw()` +## alongside the existing `_overlay_node.queue_redraw()`) — trivial wiring on +## the viewer, no new redraw PATH. This node's OWN _on_atlas_layers_received() +## also queue_redraw()s directly (the direct-signal-connection path bypasses +## the viewer's own arrival redraw calls, so it must trigger its own). Once +## layer1 arrives for a body it never goes stale until the next +## enter()/enter_orbital(), so unlike the tile mosaic's pending-tile poll, +## this node never needs a _process() self-heal. + +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") +const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd") + +## Reused verbatim from the retired atlas_marker_overlay.gd (Araminta's +## ruling: "reuse the retired palette exactly") — same values, same source of +## truth, just no longer read from the retired file. +const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0) +const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0) +const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06) +const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45) + +var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref) + +## Whole-body Layer1Output dict (river_network/drainage_basins/attractors/ +## grid_w/grid_h), or null before the first response for the current body. +## Set by _on_atlas_layers_received() (this node's own direct signal +## connection); request_layer1()/reset() manage the request lifecycle. +var _layer1: Variant = null +var _requested_body_id: String = "" + + +func _init(viewer_ref = null) -> void: + viewer = viewer_ref + + +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) + + +## Adopt a Layer-1 response — ignores non-Layer1 responses (the windowed +## DistrictWindowLayer envelope leaves `layer1` null, per +## atlas_response_from_raw()'s own doc) and responses for a body this node +## didn't ask for (the player navigated away while the request was in +## flight, or this node never issued a request at all — an empty +## `_requested_body_id` must never match an empty response `body_id`, +## matching request_layer1()'s own "empty body_id is never a valid request" +## guard), matching AtlasGenerationProxy.on_response()'s own staleness guard. +func _on_atlas_layers_received(response: Dictionary) -> void: + if _requested_body_id.is_empty(): + return + var layer1: Variant = response.get("layer1") + if layer1 == null: + return + if str(response.get("body_id", "")) != _requested_body_id: + return + _layer1 = layer1 + queue_redraw() + + +## Request a body's Layer-1 data — the ONE entry point every fresh-descent +## viewer path (enter()/_enter_at_rung()/_enter_tile_mode()) calls, so it owns +## its own reset-on-body-change instead of requiring callers to remember to +## call reset() first (a single call site per entry path stays a one-line +## addition to atlas_window_viewer.gd, keeping that file at its gdlint cap). +## Idempotent per body — a second call for the SAME body_id (e.g. a +## re-entrant enter_orbital()/rung-crossing re-descent on the body already +## showing) is a no-op, keeping the held data drawn rather than flashing it +## away and re-fetching. A DIFFERENT body_id clears the stale data FIRST (so +## the previous body's rivers never draw over the new body's terrain during +## the gap) then re-requests. No-op on an empty body_id (matches +## AtlasGenerationProxy.request()'s own guard). +func request_layer1(body_id: String) -> void: + if body_id.is_empty(): + return + if body_id == _requested_body_id and _layer1 != null: + return + if body_id != _requested_body_id: + _layer1 = null + _requested_body_id = body_id + SimBridge.request_atlas_layers(body_id) + + +func get_layer1() -> Variant: + return _layer1 + + +func _draw() -> void: + if viewer == null or _layer1 == null: + return + var rn: Variant = _layer1.get("river_network") + if not rn is Dictionary: + return + var grid_w: float = float(_layer1.get("grid_w", 0)) + var grid_h: float = float(_layer1.get("grid_h", 0)) + if grid_w <= 0.0 or grid_h <= 0.0: + return + + var granularity_v2: String = viewer.get_held_granularity_v2() + var radius_km: float = viewer.get_body_radius_km() + var held_center: Vector2i = viewer.get_held_center() + var held_n: int = viewer.get_held_n() + var cell_px: float = viewer.get_cell_pixel_size() + var cols: int = _cols_for_wrap(radius_km) + + var ctx := { + "grid_w": grid_w, + "grid_h": grid_h, + "radius_km": radius_km, + "held_center": held_center, + "held_n": held_n, + "cell_px": cell_px, + "cols": cols, + "granularity_v2": granularity_v2, + # Coordinator live-eyeball finding (2026-07-23): every marker size + # below is drawn as a SCREEN-SPACE constant (Araminta's ruling), but + # draw calls execute inside _canvas, whose .scale IS view_zoom — a + # raw constant gets multiplied by that transform at render time, + # invisible at the Region orbital tile mosaic's tiny fit zoom + # (~0.006). zs() below pre-divides by view_zoom so the transform's + # multiply cancels back to the literal screen-space value. See + # AtlasWindowGeometry.zoom_compensated_size()'s own doc. + "view_zoom": viewer.get_view_zoom(), + } + + if AtlasWindowGeometry.basins_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( + "gen_basins" + ): + _draw_basins(ctx) + if viewer.is_overlay_visible("gen_rivers"): + _draw_rivers(rn, ctx) + if AtlasWindowGeometry.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( + "gen_attractors" + ): + _draw_attractors(ctx) + + +## Circumference in districts, for nearest_wrap_image()'s wrap resolution — +## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation +## exactly (same source, same reason: only meaningful in tile/orbital mode on +## a real body; a no-radius body has no periodicity, `cols=0` is +## nearest_wrap_image()'s own documented no-op passthrough). +func _cols_for_wrap(radius_km: float) -> int: + if radius_km <= 0.0: + return 0 + return int(AtlasDescendGeometryRef.district_extent(radius_km).get("cols", 0)) + + +## Zoom-compensated screen-space size — thin per-ctx wrapper over +## AtlasWindowGeometry.zoom_compensated_size() (see that function's own doc +## for the "why divide" rationale). Every draw_circle()/draw_arc()/ +## draw_polyline() radius or line-width in this file routes through this so +## Araminta's "constant on-screen size" ruling holds at every rung/zoom. +func _zs(screen_space_size: float, ctx: Dictionary) -> float: + return AtlasWindowGeometry.zoom_compensated_size(screen_space_size, ctx["view_zoom"]) + + +## Pixel (row, col) -> canvas-local, wrap-resolved to whichever longitude +## image is nearest the currently-held view — the SAME two-step +## (map-then-nearest-wrap) the tile mosaic draw path uses, just for a single +## point instead of a tile's four corners. +func _pos(row: float, col: float, ctx: Dictionary) -> Vector2: + var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m( + row, col, ctx["grid_w"], ctx["grid_h"], ctx["radius_km"] + ) + var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m) + var cols: int = ctx["cols"] + if cols > 0: + var held_center: Vector2i = ctx["held_center"] + var wrapped_col: float = float( + AtlasWindowGeometry.nearest_wrap_image(roundi(district.x), held_center.x, cols) + ) + # Preserve the SUB-district fractional offset nearest_wrap_image()'s + # integer rounding would otherwise discard — river dots are not + # district-lattice-snapped (see world_m_to_district()'s own doc). + district.x = wrapped_col + (district.x - roundi(district.x)) + return AtlasWindowGeometry.district_to_canvas_local( + district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + + +func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: + var granularity_v2: String = ctx["granularity_v2"] + var river_cells: Array = rn.get("river_cells", []) + var river_class: Array = rn.get("river_class", []) + var is_region: bool = granularity_v2 == "Region" + + for idx in range(river_cells.size()): + var c: Variant = river_cells[idx] + if not (c is Array and c.size() >= 2): + continue + var cls: int = ( + int(river_class[idx]) + if idx < river_class.size() + else AtlasWindowGeometry.RIVER_CLASS_FALLBACK + ) + if not AtlasWindowGeometry.river_class_visible_at_rung(cls, granularity_v2): + continue + var p: Vector2 = _pos(float(c[0]), float(c[1]), ctx) + if is_region: + var radius: float = AtlasWindowGeometry.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2) + draw_circle(p, _zs(radius, ctx), COLOR_GEN_RIVER) + else: + # District: trunk-only (already filtered above), reduced size + + # opacity — the ruling's "fade down" treatment. + var faded := Color( + COLOR_GEN_RIVER.r, + COLOR_GEN_RIVER.g, + COLOR_GEN_RIVER.b, + COLOR_GEN_RIVER.a * AtlasWindowGeometry.RIVER_DOT_OPACITY_DISTRICT_TRUNK + ) + draw_circle(p, _zs(AtlasWindowGeometry.RIVER_DOT_RADIUS_DISTRICT_TRUNK, ctx), faded) + + if AtlasWindowGeometry.confluences_visible_at_rung(granularity_v2): + for cf: Variant in rn.get("confluences", []): + if cf is Array and cf.size() >= 2: + var p: Vector2 = _pos(float(cf[0]), float(cf[1]), ctx) + var radius: float = _zs(AtlasWindowGeometry.RIVER_CONFLUENCE_RADIUS_REGION, ctx) + draw_circle(p, radius, COLOR_GEN_RIVER) + + if AtlasWindowGeometry.mouths_visible_at_rung(granularity_v2): + for m: Variant in rn.get("mouths", []): + if m is Array and m.size() >= 2: + _draw_mouth(_pos(float(m[0]), float(m[1]), ctx), ctx) + + +## Double-ring sea-terminus marker — verbatim geometry from the retired +## atlas_marker_overlay.gd _draw_gen_rivers() (:536-539). Mouths never fade +## (Araminta's ruling: "a mouth is always a landmark") — same styling at +## every rung it's visible at (Region, District; never Quarter). +func _draw_mouth(p: Vector2, ctx: Dictionary) -> void: + draw_arc( + p, _zs(AtlasWindowGeometry.MOUTH_RING_RADIUS, ctx), 0.0, TAU, 18, COLOR_GEN_MOUTH, _zs(1.5, ctx) + ) + var halo := Color( + COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, AtlasWindowGeometry.MOUTH_HALO_ALPHA + ) + draw_arc(p, _zs(AtlasWindowGeometry.MOUTH_HALO_RADIUS, ctx), 0.0, TAU, 22, halo, _zs(1.0, ctx)) + + +## Basins — Region only, binary (no fade), per the ruling. Polygon fill + +## boundary polyline, verbatim geometry from the retired +## atlas_marker_overlay.gd _draw_gen_basins() (:542-557), coordinate mapping +## replaced with _pos() (this file's wrap-aware canvas-local mapping) in place +## of the retired _gen_pos() texture-fraction mapping. The FILL polygon's +## points are positions (never zoom-compensated — the fill must track the +## real district-space shape); only the boundary LINE's width is a +## screen-space marker size and goes through _zs(). +func _draw_basins(ctx: Dictionary) -> void: + for b: Variant in _layer1.get("drainage_basins", []): + if not b is Dictionary: + continue + var boundary: Array = b.get("boundary", []) + var pts: PackedVector2Array = PackedVector2Array() + for pt: Variant in boundary: + if pt is Array and pt.size() >= 2: + pts.append(_pos(float(pt[0]), float(pt[1]), ctx)) + if pts.size() < 2: + continue + if pts.size() >= 3: + draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL) + var loop: PackedVector2Array = pts.duplicate() + loop.append(pts[0]) + draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs(0.8, ctx), true) + + +## Attractors — Region only, wave 1 (per the ruling; District/Quarter never +## reach this function since _draw() gates the whole call on +## attractors_visible_at_rung()). Ported from the retired +## atlas_marker_overlay.gd _draw_gen_attractors()/_draw_attractor_shape() +## (:560-...) — the shape vocabulary (7 attractor-type glyphs) is Araminta's +## existing design, unchanged; only the coordinate mapping moves to _pos() +## and the size is zoom-compensated before reaching the shape drawer (that +## function stays a pure "draw this size at this position", zoom-agnostic). +func _draw_attractors(ctx: Dictionary) -> void: + for a: Variant in _layer1.get("attractors", []): + if not a is Dictionary: + continue + var strength: float = float(a.get("strength", 0.0)) + if strength < AtlasWindowGeometry.ATTRACTOR_MIN_STRENGTH: + continue + var pos_rc: Variant = a.get("position") + if not pos_rc is Array or pos_rc.size() < 2: + continue + var p: Vector2 = _pos(float(pos_rc[0]), float(pos_rc[1]), ctx) + var size: float = _zs(5.0 + strength * 4.0, ctx) + var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", ""))) + _draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs(1.0, ctx)) + + +## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd +## _draw_attractor_shape(). `size` AND `px_w` (the 1-screen-px stroke unit) +## both arrive ALREADY zoom-compensated from _draw_attractors() — this +## function stays a pure "draw at these literal dimensions" primitive with no +## ctx/zoom knowledge of its own. px_w exists because Godot multiplies stroke +## WIDTH args by the canvas scale exactly like radii (PR #195 review, Tyre +## I1: the retired code's raw 1.0/2.0 widths rasterized at ~0.01px at the +## Region orbital fit zoom — the same sub-pixel failure the dot/ring +## compensation fixed, missed on glyph outlines). +func _draw_attractor_shape( + atype: String, pos: Vector2, size: float, color: Color, px_w: float +) -> void: + match atype: + "RiverMouth": + draw_circle(pos, size, color) + "Confluence": + draw_circle(pos, size * 0.8, color) + draw_arc(pos, size * 1.3, 0.0, TAU, 12, color, px_w) + "Alpine", "PassEntrance": + var pts := PackedVector2Array( + [ + pos + Vector2(0, -size), + pos + Vector2(-size * 0.8, size * 0.6), + pos + Vector2(size * 0.8, size * 0.6), + ] + ) + draw_colored_polygon(pts, color) + "Coastal", "NaturalHarbor": + draw_arc(pos, size, PI * 0.15, PI * 0.85, 10, color, 2.0 * px_w) + "Oasis": + draw_circle(pos, size * 0.5, color) + for i in range(6): + var ang: float = TAU * float(i) / 6.0 + draw_line(pos, pos + Vector2(cos(ang), sin(ang)) * size, color, px_w) + _: + draw_circle(pos, size * 0.6, color) diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index 3ef445379..f89801044 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -95,6 +95,8 @@ const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_g 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") +# T-1156 wave 1: whole-body nature overlay (rivers/basins/attractors). +const AtlasWindowNatureOverlay := preload("res://ui/implant/apps/atlas/atlas_window_nature_overlay.gd") # ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/ # atlas_legend_panel.gd's existing duck-typed viewer interface: both call @@ -121,6 +123,10 @@ const OVERLAY_DEFS: Array = [ "group": "toggle", "tooltip": "Vegetation — green-family ramp. Marine reads transparent (open water)." }, + # T-1156 wave 1: per-rung visibility lives in AtlasWindowGeometry's tables. + {"id": "gen_rivers", "label": "RVR", "group": "toggle", "tooltip": "Rivers — per-rung class filter."}, + {"id": "gen_basins", "label": "BAS", "group": "toggle", "tooltip": "Drainage basins — Region only."}, + {"id": "gen_attractors", "label": "ATR", "group": "toggle", "tooltip": "Attractors — Region only."}, ] # ── Context (set by enter()) ────────────────────────────────────────────── @@ -174,6 +180,7 @@ var _overlay_bar = null var _legend_panel = null var _window_request = null # AtlasWindowRequest var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3) +var _nature_overlay = null # AtlasWindowNatureOverlay (T-1156 wave 1) ## T-1153 (design doc §4): true while showing the orbital rest state as a ## MULTI-WINDOW MOSAIC instead of the single held composite (`_window`). Set @@ -193,6 +200,7 @@ func _ready() -> void: for def: Dictionary in OVERLAY_DEFS: _overlay_visibility[def["id"]] = false + _overlay_visibility["gen_rivers"] = true # T-1156 wave 1: RVR default ON _canvas = Node2D.new() _canvas.name = "WindowCanvas" @@ -203,6 +211,10 @@ func _ready() -> void: _overlay_node.viewer = self _canvas.add_child(_overlay_node) + _nature_overlay = AtlasWindowNatureOverlay.new(self) + _nature_overlay.name = "WindowNatureOverlay" + _canvas.add_child(_nature_overlay) + _window_request = AtlasWindowRequest.new(self) _window_request.name = "WindowRequest" add_child(_window_request) @@ -291,6 +303,7 @@ func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> _fit_and_center() _window_request.reset() _tile_set.enter(_dict_str(_body, "body_id", ""), radius_km) + _nature_overlay.request_layer1(_dict_str(_body, "body_id", "")) _refresh_screen_header() _legend_panel.refresh() grab_focus() @@ -326,6 +339,7 @@ func _enter_at_rung( _window_request.request_now( _dict_str(_body, "body_id", ""), _held_center, clamped_n, granularity_v2 ) + _nature_overlay.request_layer1(_dict_str(_body, "body_id", "")) _refresh_screen_header() _legend_panel.refresh() grab_focus() @@ -432,6 +446,7 @@ func set_overlay_visible(overlay_id: String, visible_state: bool) -> void: return _overlay_visibility[overlay_id] = visible_state _overlay_node.queue_redraw() + _nature_overlay.queue_redraw() _legend_panel.refresh() @@ -515,6 +530,7 @@ func _apply_transform() -> void: _canvas.scale = Vector2(_view_zoom, _view_zoom) queue_redraw() _overlay_node.queue_redraw() + _nature_overlay.queue_redraw() ## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index f53f16d55..33cbc7cab 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser **Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it. - **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed). **Pending-shape protocol note (T-1163, PR #193 review — Tyre):** `AtlasLayerResponse` has **two legal wire shapes for one logical "still deriving, client must re-poll" state**, and both are contract: whole-response `status: Pending` (whole-body cache cold — nothing about this body computed yet) and `status: Ready` with `district_window: null` (body warm, this window still in the derive queue). `Ready` is set **only** by the whole-body cache-hit branch, independent of the window's own derivation. Any `AtlasLayerResponse` consumer must treat BOTH shapes as retry-with-backoff and only `NotFound`/`Error` as terminal — the T-1163 cold-launch starvation (every first launch black) was precisely a client reading `status != Ready` as ignorable. A server refactor that "cleans up" the Pending/Ready-null asymmetry must migrate every consumer in the same change. **Filter-axis note (T-1161, PR #194 review — Tyre):** COMPOSITE_SMOOTH is retained as the compile-time *pipeline* axis (texture vs. per-cell rects, crisp path kept for debug/compare); the ladder's crispness-at-sparse-rungs requirement is met by the per-rung *sampling-filter* policy (`_filter_for_granularity_v2`: Region/orbital-mosaic NEAREST, District/Quarter LINEAR, unknown falls back LINEAR), **not** by deleting the const. The design pass §8 step 6 "retire COMPOSITE_SMOOTH" is errata'd accordingly; T-1155's retirement framing is cancelled, superseded by T-1161. + **Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row. **Refinement-semantics note (T-1153, PR #192 review — Tyre):** the ladder's progressive cross-rung refinement (hold the coarse composite, fetch the finer rung, swap in place on arrival; per-tile arrival in the orbital mosaic) **extends** the T-1124 §4 float-on-center/debounce async contract — it does not supersede it. §4 still governs the per-request mechanics unchanged (`district_window: None`-until-derived polling, the 150 ms debounce, float-on-center refetch); rung crossings add a second request class on top, per the design pass §3's progressive-refinement model. This record is the one that governs the swap-on-arrival behavior. The legacy `window_granularity: u32` wire field is now fully shadowed by `window_granularity_v2` (the server always echoes both); it is **scheduled for retirement** once pre-T-1152 wire-compat is confirmed unneeded (single-repo client/server pair — no external clients exist today; ticketed). **Pending-shape protocol note (T-1163, PR #193 review — Tyre):** `AtlasLayerResponse` has **two legal wire shapes for one logical "still deriving, client must re-poll" state**, and both are contract: whole-response `status: Pending` (whole-body cache cold — nothing about this body computed yet) and `status: Ready` with `district_window: null` (body warm, this window still in the derive queue). `Ready` is set **only** by the whole-body cache-hit branch, independent of the window's own derivation. Any `AtlasLayerResponse` consumer must treat BOTH shapes as retry-with-backoff and only `NotFound`/`Error` as terminal — the T-1163 cold-launch starvation (every first launch black) was precisely a client reading `status != Ready` as ignorable. A server refactor that "cleans up" the Pending/Ready-null asymmetry must migrate every consumer in the same change. **Filter-axis note (T-1161, PR #194 review — Tyre):** COMPOSITE_SMOOTH is retained as the compile-time *pipeline* axis (texture vs. per-cell rects, crisp path kept for debug/compare); the ladder's crispness-at-sparse-rungs requirement is met by the per-rung *sampling-filter* policy (`_filter_for_granularity_v2`: Region/orbital-mosaic NEAREST, District/Quarter LINEAR, unknown falls back LINEAR), **not** by deleting the const. The design pass §8 step 6 "retire COMPOSITE_SMOOTH" is errata'd accordingly; T-1155's retirement framing is cancelled, superseded by T-1161. **Wave-1 nature-overlay carrier note (T-1156, 2026-07-23 — Tyre):** river skeletons (rivers/basins/attractors) ride the Atlas zoom ladder on the **existing whole-body `layer1` field** (`RiverNetwork`/`drainage_basins`/`attractors`, already serialized on every `AtlasLayerResponse`), **not** on the windowed `district_window` carrier — so the windowed-family ceiling (§2, [HARD], exactly one windowed field) is untouched and no tagged-envelope migration is triggered. Rationale: the skeleton is discrete vector geometry ((u16,u16) cell chains, boundary polylines, point attractors), computed once per body in the Layer-1 pass and cached "valid forever" (D-227) — categorically the whole-body family, not a per-pan viewport query. Rasterizing rivers into `district_window`'s per-cell arrays is rejected: a 1-cell-wide thalweg is sub-cell at every ladder rung (512 m/cell and coarser), so a presence-byte either over-fattens (the Lendel failure the ladder mandate kills) or drops the river on the sampling grid. **Per-rung refinement is client-side** (trunk at Region → +tributaries at District → +streams at Quarter), a filter on a **new quantized `river_class` per cell added to `RiverNetwork`** (derived from the flow-accumulation `drainage.rs` already computes; additive, `#[serde(default)]`-safe, no ceiling impact) — NOT server-side per-rung re-transmission. This is distinct from T-1162's server-side `min_wavelength_m` cutoff: that truncates a continuous per-metre field at the rung's Nyquist limit; the skeleton is a fixed finite graph with nothing to truncate. **General rule established:** discrete map features (linear + point geometry) ride the whole-body overlay family, filtered per-rung client-side; only continuous per-metre fields ride the windowed per-cell arrays — Wave 2's roads/rail/settlements (already whole-body fields) inherit this carrier unchanged. **Consistency scope:** the river skeleton is upstream of and independent from T-1162's perturbed `moisture_q` (drainage runs on elevation, never samples moisture), so the two cannot disagree; the vegetation layer's riparian response to rivers (`near_perennial_water`) is a **named forward contract, deferred to T-1168** — not fixed in Wave 1. Until it lands, the river overlay draws over terrain whose vegetation layer does not yet respond to it (an accepted nature-layer-first gap). **Visibility-direction note (same PR, Araminta):** the per-rung visibility DIRECTION is Araminta's presentation ruling on the 76 km skeleton-resolution evidence — **fade-down** (Region shows the full skeleton, District trunk-only de-emphasized, Quarter off), consciously inverting the provisional add-as-you-descend mapping the ticket brief carried. This posture is explicitly **pre-T-1170**: it is revisited (in `RIVER_CLASS_VISIBLE_BY_RUNG`, the single client-side revisit point) when course invention gives finer rungs real geometry to reveal. - **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem. - **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`. - **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer. diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index e3284c137..b3d17dcff 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -42,6 +42,18 @@ pub struct RiverNetwork { pub confluences: Vec<(u16, u16)>, /// Positions where rivers reach sea level or the heightmap edge. pub mouths: Vec<(u16, u16)>, + /// Quantized river class per entry of `river_cells` (same index, same + /// length) — 0=stream, 1=tributary, 2=trunk (T-1156 wave 1). Deterministic + /// per body+seed (D-010/D-208): a monotonic function of each cell's flow + /// accumulation, binned by `drainage::classify_river_cell`. This is the + /// carrier for client-side per-rung filtering (Tyre's binding ruling — no + /// new wire field beyond this array; ladder rungs decide which classes to + /// draw by filtering this list, not by a server-side windowed query). + /// `#[serde(default)]` so pre-T-1156 payloads/consumers (and any golden + /// fixture predating this field) still decode — an absent array becomes + /// empty, never a decode error (the additive T-1124 §1 pattern). + #[serde(default)] + pub river_class: Vec, } /// One drainage basin / province derived from watershed analysis (D-205). diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index e8a09b5b4..16fc278e5 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -91,7 +91,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr // 4. Flow accumulation. let accum = flow_accumulation(&fdir, w, h); - // 5. River network. + // 5. River network. River-class banding (T-1156) anchors on its own + // river-restricted max internally — see `extract_river_network` — not on + // the grid-wide max computed below, so no dependency ordering between + // the two is needed. let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation); // 6. Basin labeling. @@ -104,7 +107,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr let drainage_basins = build_basins(&labels, w, h); // Max accumulation for D-209 strength normalization (clamped ≥ 1 so the - // division is always well-defined, even on a flat/empty world). + // division is always well-defined, even on a flat/empty world). This is + // the grid-wide max (includes below-sea-level cells) — distinct from the + // river-restricted max `extract_river_network` uses for its own T-1156 + // river-class banding. let max_accumulation = accum.iter().copied().max().unwrap_or(1).max(1); DrainageResult { @@ -258,6 +264,30 @@ fn extract_river_network( .map(|i| ((i / w) as u16, (i % w) as u16)) .collect(); + // River-restricted max accumulation — the ceiling for the T-1156 log-band + // classifier below. Deliberately NOT the grid-wide `max_accumulation` + // (DrainageResult's D-209 normalization denominator, which includes + // below-sea-level ocean cells where accumulation typically peaks, just + // past a river's mouth): anchoring on that grid-wide value would classify + // a wet, large-ocean body's actual wettest *river* cell short of trunk, + // producing an entirely riverless District rung (Araminta's per-rung + // table shows trunk only at District) on exactly the bodies with the + // most river to show. Anchoring on the max among cells that passed the + // `is_river` filter guarantees every body with any river cells has its + // wettest one classified trunk, by construction — see + // `classify_river_cell`'s doc comment. + let river_max_accumulation = (0..n) + .filter(|&i| is_river[i]) + .map(|i| accum[i]) + .max() + .unwrap_or(RIVER_THRESHOLD + 1); // unused when river_cells is empty + + // River class per entry of `river_cells`, same order (T-1156 wave 1). + let river_class: Vec = (0..n) + .filter(|&i| is_river[i]) + .map(|i| classify_river_cell(accum[i], river_max_accumulation)) + .collect(); + // Confluences: river cells with 2+ river neighbors flowing into them. let mut inflow_count = vec![0u8; n]; for r in 0..h { @@ -314,6 +344,77 @@ fn extract_river_network( river_cells, confluences, mouths, + river_class, + } +} + +/// Bin a river cell's flow accumulation into a quantized class (T-1156 wave 1): +/// 0=stream, 1=tributary, 2=trunk. The client filters the ladder rung's river +/// draw by this class (Araminta's per-rung table: Region shows trunk only, +/// District adds tributary, Quarter shows everything) — no new wire field, +/// this is the sole carrier (Tyre's ruling). +/// +/// **Binning: log-scaled fraction of the log-range between `RIVER_THRESHOLD` +/// (the accumulation floor below which a cell isn't a river cell at all) and +/// `river_max_accumulation` (the highest flow accumulation among this body's +/// own river cells), split into equal thirds.** Rationale for log rather than +/// linear: flow accumulation grows combinatorially downstream (each +/// confluence roughly sums its tributaries), so a linear split over-populates +/// the trunk band with anything past the halfway point and starves it on +/// modest bodies. Log-scaling spreads the bands evenly across orders of +/// magnitude instead, so a river's headwaters (streams), mid-course +/// tributaries, and lower trunk read as three roughly even bands on both a +/// wet, many-confluence body and a dry, single-channel one. +/// +/// **The ceiling must be `river_max_accumulation` (max over cells that pass +/// the `is_river` filter — `accum > RIVER_THRESHOLD && elevation >= +/// sea_level`), never `DrainageResult::max_accumulation` (the grid-wide max +/// used elsewhere for D-209 strength normalization).** Flow accumulation +/// peaks right at a river's mouth, typically on the ocean-side cell just past +/// the coastline — a cell that is *never* a river cell by definition +/// (`is_river` requires `elevation >= sea_level`). Anchoring on the grid-wide +/// max therefore admits a ceiling no river cell can ever reach: on a wet body +/// with a large ocean, where accumulation piles up hardest past the +/// coastline, every actual river cell would land short of trunk and the +/// District rung (trunk-only per Araminta's table) would render riverless — +/// exactly backwards, since that is the body with the most river to show. +/// Anchoring on `river_max_accumulation` instead guarantees, by construction, +/// that a body's own wettest *river* cell — not its wettest cell overall — +/// always lands in the trunk band. Every body with any river cells gets a +/// trunk, scaled to its own wet/dry character, which is what "this body's +/// main river" should mean, and it holds unconditionally (not merely "if the +/// wettest water happens to be fluvial"). +/// +/// Using a per-body-relative ceiling at all (rather than an absolute multiple +/// of `RIVER_THRESHOLD`, e.g. trunk = accum ≥ 800) is itself deliberate: a +/// body whose single river barely clears the threshold would classify every +/// cell as `stream` under an absolute scheme, reading as "no real river" +/// even though it has exactly one. +/// +/// Determinism (D-010/D-208): pure integer/float arithmetic on +/// `(accum, river_max_accumulation)`, no RNG, same body+seed → same class +/// every run. Monotonic by construction: `log` and the linear division into +/// thirds are both non-decreasing in `accum`, so a strictly higher +/// accumulation never produces a strictly lower class. +fn classify_river_cell(accum: i32, river_max_accumulation: i32) -> u8 { + // Callers only invoke this for cells that passed `is_river` (accum > + // RIVER_THRESHOLD == 200), and `river_max_accumulation` is the max over + // that same cell set, so both logs below are well-defined (positive + // arguments) and `river_max_accumulation > RIVER_THRESHOLD` always holds + // when there is at least one river cell. + let floor = (RIVER_THRESHOLD as f64).ln(); + let ceil = (river_max_accumulation as f64) + .max(RIVER_THRESHOLD as f64 + 1.0) + .ln(); + let span = (ceil - floor).max(f64::EPSILON); + let frac = ((accum as f64).ln() - floor) / span; + let frac = frac.clamp(0.0, 1.0); + if frac >= 2.0 / 3.0 { + 2 // trunk + } else if frac >= 1.0 / 3.0 { + 1 // tributary + } else { + 0 // stream } } @@ -806,4 +907,137 @@ mod tests { let n = res.drainage_basins.len(); assert!((1..=12).contains(&n), "basin count {n} out of range"); } + + // ----------------------------------------------------------------------- + // River class (T-1156 wave 1) + // ----------------------------------------------------------------------- + + #[test] + fn every_river_cell_has_a_class() { + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert_eq!( + result.river_network.river_cells.len(), + result.river_network.river_class.len(), + "river_class must be parallel/aligned with river_cells" + ); + assert!( + !result.river_network.river_cells.is_empty(), + "test grid should produce river cells" + ); + } + + #[test] + fn river_class_monotonic_with_accumulation() { + // A cell with higher accumulation must never have a lower class than + // a cell with lower accumulation — the core binning contract. + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + let rn = &result.river_network; + assert!(!rn.river_cells.is_empty()); + + // Recover each river cell's accumulation and pair it with its class. + let w = 512usize; + let mut pairs: Vec<(i32, u8)> = rn + .river_cells + .iter() + .zip(rn.river_class.iter()) + .map(|(&(r, c), &class)| { + let idx = r as usize * w + c as usize; + (result.flow_accumulation[idx], class) + }) + .collect(); + pairs.sort_by_key(|&(accum, _)| accum); + + let mut max_class_seen = 0u8; + for (_, class) in pairs { + assert!( + class >= max_class_seen, + "monotonicity violated: saw class {class} after class {max_class_seen} \ + in ascending-accumulation order" + ); + max_class_seen = max_class_seen.max(class); + } + } + + #[test] + fn at_least_one_trunk_cell_when_rivers_exist() { + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert!(!result.river_network.river_cells.is_empty()); + assert!( + result.river_network.river_class.contains(&2), + "a body with any rivers must have at least one trunk (class 2) cell — \ + this is the classify_river_cell river_max_accumulation-anchoring guarantee" + ); + } + + #[test] + fn at_least_one_trunk_cell_on_a_real_body_with_a_large_ocean() { + // Regression for the grid-wide-max anchoring bug: GJ1c is exactly the + // "wet body with a large ocean" shape where flow accumulation peaks + // past the coastline (a non-river cell), which starved the trunk band + // when the ceiling was anchored on the grid-wide max instead of the + // river-restricted max. Same body + downsample as the cascade golden + // (tests/golden/cascade_layer1.json) — 93 river cells there, so this + // is a real, non-synthetic exercise of the guarantee. + use crate::atlas::heightmap::load_heightmap_png; + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"); + let heightmap = + load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap"); + let small = heightmap.downsample(256, 128); + let result = analyze(&small.data, small.width, small.height, small.sea_level); + assert!( + !result.river_network.river_cells.is_empty(), + "GJ1c should have river cells at this downsample" + ); + assert!( + result.river_network.river_class.contains(&2), + "GJ1c's own wettest river cell must classify as trunk — river-restricted \ + anchoring must not be starved by ocean-cell accumulation past the coastline" + ); + } + + #[test] + fn river_class_deterministic() { + let elev = slope_grid(64, 32); + let r1 = analyze(&elev, 64, 32, 0.3); + let r2 = analyze(&elev, 64, 32, 0.3); + assert_eq!( + r1.river_network.river_class, r2.river_network.river_class, + "river_class must be deterministic (D-010/D-208)" + ); + } + + #[test] + fn classify_river_cell_barely_above_threshold_still_gets_a_trunk() { + // A body whose single river barely clears RIVER_THRESHOLD must still + // classify its own maximum as trunk — the whole point of anchoring + // the log-range ceiling at river_max_accumulation instead of an + // absolute multiple of RIVER_THRESHOLD. + let river_max_accumulation = RIVER_THRESHOLD + 5; + assert_eq!( + classify_river_cell(river_max_accumulation, river_max_accumulation), + 2, + "the body's own max river-cell accumulation must always classify as trunk" + ); + } + + #[test] + fn classify_river_cell_spans_all_three_classes_on_wide_range() { + // Sanity check on the log-binning: a body with a wide dynamic range + // (headwater trickles up to a major trunk) should exercise all three + // classes, not collapse to two. + let river_max_accumulation = 131_000; + let low = classify_river_cell(RIVER_THRESHOLD + 1, river_max_accumulation); + let mid = classify_river_cell(5_000, river_max_accumulation); + let high = classify_river_cell(river_max_accumulation, river_max_accumulation); + assert_eq!(low, 0, "just above threshold should be a stream"); + assert_eq!(mid, 1, "mid-range accumulation should be a tributary"); + assert_eq!( + high, 2, + "the body's max river-cell accumulation should be trunk" + ); + } } diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 5b0f48cda..ea0f56e78 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -557,6 +557,7 @@ fn generate_atlas_layer_response_fixtures() { river_cells: vec![(12, 58), (12, 59)], confluences: vec![], mouths: vec![(12, 58)], + river_class: vec![1, 2], }, drainage_basins: vec![DrainageBasin { basin_id: 1, diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index a7ef1bc41..434b7e1a1 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -8216,6 +8216,101 @@ 124, 239 ] + ], + "river_class": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 2, + 0, + 0, + 1, + 0, + 2, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0 ] } },