diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index 9d407785a..b1e6b812a 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -929,3 +929,51 @@ func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> v + " _view_offset — reusing the same offset across the crossing is" + " exactly the live round 4 bug (composite renders off-canvas)" ).is_not_equal(offset_quarter) + + +# ============================================================================= +# T-1172 round 2: cell_index_for_local_offset() — the shared painter/clip +# index formula (see its own doc for the "why shared, not duplicated" case). +# T-1170: these tests moved here from test_atlas_window_geometry_nature.gd — +# the function itself stayed on THIS file (AtlasWindowGeometry) rather than +# moving to atlas_window_geometry_nature.gd, since it is shared with +# AtlasWindowOverlay's terrain painter, a non-nature consumer — see that +## file's own header doc for the full split rationale. +# ============================================================================= + + +func test_cell_index_for_local_offset_top_left_is_zero_zero() -> void: + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(0.0, 0.0, 6400, 64) + assert_that(cell).is_equal(Vector2i(0, 0)) + + +## The exact live-repro numbers from T-1172 round 2's trace: a query whose +## district-space local offset is (2594.09, 5593.15) inside a 6400-wide, +## 64-cell-side window must resolve to (col=25, row=55) — pinned directly +## against the LIVE captured values that closed the investigation (both the +## painter's _build_tile_texture() and the clip independently produced this +## exact pair for the same query in the live trace). +func test_cell_index_for_local_offset_matches_the_live_trace_repro() -> void: + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset( + 2594.0849609375, 5593.15258789062, 6400, 64 + ) + assert_that(cell).override_failure_message( + "must match the live-captured painter/clip agreement point from the" + + " T-1172 round 2 investigation — (col=25, row=55)" + ).is_equal(Vector2i(25, 55)) + + +func test_cell_index_for_local_offset_bottom_right_boundary_clamps_inside() -> void: + # local offset == n (the exclusive upper boundary) must clamp to the LAST + # cell, not overflow to a nonexistent grid_side'th cell. + var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(6400.0, 6400.0, 6400, 64) + assert_that(cell).is_equal(Vector2i(63, 63)) + + +func test_cell_index_for_local_offset_zero_n_or_grid_side_returns_sentinel() -> void: + assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 0, 64)).is_equal( + Vector2i(-1, -1) + ) + assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 6400, 0)).is_equal( + Vector2i(-1, -1) + ) diff --git a/client/tests/test_atlas_window_geometry_nature.gd b/client/tests/test_atlas_window_geometry_nature.gd index 73486d8d9..4752bff5b 100644 --- a/client/tests/test_atlas_window_geometry_nature.gd +++ b/client/tests/test_atlas_window_geometry_nature.gd @@ -1,15 +1,26 @@ -## 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. +## T-1156 wave 1 / T-1170: pure-function tests for AtlasWindowGeometryNature's +## Layer-1 nature-overlay pixel mapping (layer1_pixel_to_world_m/ +## world_m_to_district/layer1_pixel_to_canvas_local), per-rung visibility/ +## filter policy (skeleton_class_visible_at_rung/course_class_visible_at_rung/ +## confluences_visible_at_rung/mouths_visible_at_rung/basins_visible_at_rung/ +## attractors_visible_at_rung), the D8 river_downstream decode +## (d8_downstream_target), and course width/opacity readers +## (course_class_width_px/course_class_opacity). 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. +## +## T-1170: this file's SUBJECT preload moved from AtlasWindowGeometry to +## AtlasWindowGeometryNature (the T-1170 split, see that file's own doc) — +## every symbol tested below now lives there. cell_index_for_local_offset() +## STAYED on AtlasWindowGeometry (shared with the non-nature terrain painter) +## — its tests stay in test_atlas_window_geometry.gd, not duplicated here. class_name TestAtlasWindowGeometryNature extends GdUnitTestSuite -const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +const AtlasWindowGeometryNature := preload( + "res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd" +) const CELL_PIXEL_SIZE: float = 16.0 const DISTRICT_M: float = 2048.0 @@ -25,7 +36,7 @@ const DISTRICT_M: float = 2048.0 ## 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) + var w: Vector2 = AtlasWindowGeometryNature.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) @@ -36,7 +47,9 @@ func test_layer1_pixel_to_world_m_col_zero_is_world_x_zero() -> void: 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 w: Vector2 = AtlasWindowGeometryNature.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) @@ -46,7 +59,7 @@ func test_layer1_pixel_to_world_m_row_zero_is_north_pole_negative_wy() -> void: 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( + var w: Vector2 = AtlasWindowGeometryNature.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 @@ -57,7 +70,7 @@ func test_layer1_pixel_to_world_m_last_row_is_south_pole_positive_wy() -> void: ## 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) + var w: Vector2 = AtlasWindowGeometryNature.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) @@ -66,7 +79,9 @@ func test_layer1_pixel_to_world_m_mid_row_is_near_equator() -> void: 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 w: Vector2 = AtlasWindowGeometryNature.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) @@ -75,13 +90,13 @@ func test_layer1_pixel_to_world_m_full_width_col_is_full_circumference() -> void ## — 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) + var w: Vector2 = AtlasWindowGeometryNature.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) + var w: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(1.0, 1.0, 0.0, 0.0, 6371.0) assert_that(w).is_equal(Vector2.ZERO) @@ -92,13 +107,16 @@ func test_layer1_pixel_to_world_m_zero_grid_dims_returns_zero() -> void: 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)) + var d: Vector2 = AtlasWindowGeometryNature.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. +# AtlasWindowGeometry.district_to_canvas_local() called manually with the +# same intermediate value. # ============================================================================= @@ -107,6 +125,7 @@ func test_world_m_to_district_divides_by_district_m() -> void: ## 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 AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") var radius_km := 6371.0 var grid_w := 256.0 var grid_h := 128.0 @@ -115,14 +134,14 @@ func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: var row := 40.0 var col := 80.0 - var result: Vector2 = AtlasWindowGeometry.layer1_pixel_to_canvas_local( + var result: Vector2 = AtlasWindowGeometryNature.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( + var world_m: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m( row, col, grid_w, grid_h, radius_km ) - var district: Vector2 = AtlasWindowGeometry.world_m_to_district(world_m) + var district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) var expected: Vector2 = AtlasWindowGeometry.district_to_canvas_local( district, held_center, held_n, CELL_PIXEL_SIZE ) @@ -130,94 +149,675 @@ func test_layer1_pixel_to_canvas_local_matches_manual_composition() -> void: # ============================================================================= -# Per-rung river-class visibility (Araminta's ruling, 2026-07-23) — -# river_class_visible_at_rung() +# T-1170 Ruling 2a-2d/5a: d8_downstream_target() — the river_downstream D8 +# pointer decode. Direction table CONFIRMED against Dudley's A1 +# (server/src/atlas/drainage.rs:35-44): 0=N(-1,0) 1=S(1,0) 2=E(0,1) 3=W(0,-1) +# 4=NE(-1,1) 5=NW(-1,-1) 6=SE(1,1) 7=SW(1,-1). Sentinels: MOUTH=8, +# EDGE_DRAIN=9, TERMINAL=10 (reserved). # ============================================================================= -func test_river_class_visible_at_rung_region_shows_every_class() -> void: +func test_d8_downstream_target_north_decrements_row() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 0) + assert_that(target).is_equal(Vector2(9.0, 10.0)) + + +func test_d8_downstream_target_south_increments_row() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 1) + assert_that(target).is_equal(Vector2(11.0, 10.0)) + + +func test_d8_downstream_target_east_increments_col() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 2) + assert_that(target).is_equal(Vector2(10.0, 11.0)) + + +func test_d8_downstream_target_west_decrements_col() -> void: + var target: Variant = AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 3) + assert_that(target).is_equal(Vector2(10.0, 9.0)) + + +func test_d8_downstream_target_diagonals_move_both_axes() -> void: + # 4=NE, 5=NW, 6=SE, 7=SW — each a diagonal (row, col) delta of magnitude 1 + # on both axes, matching the direction letters' compass meaning. + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 4)).is_equal( + Vector2(9.0, 11.0) + ) # NE + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 5)).is_equal( + Vector2(9.0, 9.0) + ) # NW + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 6)).is_equal( + Vector2(11.0, 11.0) + ) # SE + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 7)).is_equal( + Vector2(11.0, 9.0) + ) # SW + + +## MOUTH (8), EDGE_DRAIN (9), and TERMINAL (10, reserved) are all sentinels +## >= RIVER_DOWNSTREAM_SENTINEL_BASE — every one must decode to `null` (chain +## end, no segment to draw), not a direction lookup. +func test_d8_downstream_target_sentinels_return_null() -> void: + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.d8_downstream_target( + 10.0, 10.0, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL + ) + ).is_null() + + +## A malformed/out-of-range direction (negative, or >= sentinel base but not +## one of the three named sentinels — e.g. a future reserved value) must also +## decode to null, not crash on an out-of-bounds D8_DIRECTION_DELTAS index. +func test_d8_downstream_target_out_of_range_returns_null_not_crash() -> void: + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, -1)).is_null() + assert_that(AtlasWindowGeometryNature.d8_downstream_target(10.0, 10.0, 255)).is_null() + + +## The sentinel base itself (8) is the exact boundary between the last real +## direction (7=SW) and the first sentinel (8=MOUTH) — pin the boundary +## exactly rather than relying only on the interior-value tests above. +func test_d8_downstream_target_boundary_seven_is_direction_eight_is_sentinel() -> void: + assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 7)).is_not_null() + assert_that(AtlasWindowGeometryNature.d8_downstream_target(0.0, 0.0, 8)).is_null() + + +# ============================================================================= +# T-1170 Ruling 5a: build_skeleton_chords() — the pure chain-CONSTRUCTION +# function (no draw calls) AtlasWindowNatureOverlay._draw_skeleton_chords() +# delegates to. This is the load-bearing chain-walking logic (visibility +# filtering + D8 decode + sentinel chain-ends), tested here directly rather +# than only through the draw-smoke suite's pixel proof. +# ============================================================================= + + +## Two river cells, cell 0 flows SOUTH (direction 1) into cell 1's own grid +## position — one segment constructed, from cell 0's position to cell 0's +## position + (1, 0) [south]. cls read from river_class at the SAME index. +func test_build_skeleton_chords_constructs_one_segment_for_a_simple_pair() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] # 1 = S + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).override_failure_message( + "cell 0 (flows S, a real direction) must construct one segment;" + + " cell 1 (MOUTH sentinel) must construct none — expected exactly 1 total" + ).is_equal(1) + var chord: Dictionary = chords[0] + assert_that(chord["from"]).is_equal(Vector2(10.0, 10.0)) + assert_that(chord["to"]).is_equal(Vector2(11.0, 10.0)) + assert_int(chord["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) + + +## Sentinel chain ends: MOUTH, EDGE_DRAIN, and TERMINAL (reserved) must each +## construct ZERO segments for their own cell — a chain-end has no downstream +## neighbor to connect to, regardless of which sentinel flavor. +func test_build_skeleton_chords_sentinel_chain_ends_construct_no_segment() -> void: + var river_cells: Array = [[0, 0], [10, 10], [20, 20]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [ + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_EDGE_DRAIN, + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_TERMINAL, + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_array(chords).override_failure_message( + "every cell is a sentinel chain-end (MOUTH/EDGE_DRAIN/TERMINAL) —" + + " zero segments must be constructed" + ).is_empty() + + +## A downstream direction pointing at a class not visible at this rung's +## SKELETON path is filtered by the UPSTREAM cell's own class, not the +## target's — District shows NO skeleton classes at all (T-1170: skeleton +## draws only at Region now), so a District query must construct zero +## segments regardless of the fixture's directions. +func test_build_skeleton_chords_district_rung_constructs_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var river_downstream: Array = [1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "District" + ) + assert_array(chords).is_empty() + + +## river_downstream shorter than river_cells (pre-T-1170 payload / graceful +## empty-Vec decode) — cells with no corresponding index must construct no +## segment, not crash on an out-of-bounds read. +func test_build_skeleton_chords_missing_downstream_entries_construct_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10], [12, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [1] # only index 0 has a pointer + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(1) + + +## An empty river_downstream array entirely (the actual wire shape Dudley's +## `#[serde(default)]` produces for a pre-T-1170 payload) must construct zero +## segments, not error. +func test_build_skeleton_chords_empty_downstream_array_constructs_nothing() -> void: + var river_cells: Array = [[10, 10], [11, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, [], "Region" + ) + assert_array(chords).is_empty() + + +## A malformed river_cells entry (not an Array, or too short) is skipped +## entirely — no segment constructed for it, no crash, and it does not +## disturb construction for the OTHER (well-formed) entries in the same +## fixture. +func test_build_skeleton_chords_malformed_cell_entry_is_skipped_not_fatal() -> void: + var river_cells: Array = [[10, 10], "not an array", [12, 10]] + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [1, 1, AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).override_failure_message( + "the malformed middle entry must be skipped without disturbing the" + + " well-formed entries around it — expected exactly 1 (cell 0 -> S)" + ).is_equal(1) + + +## river_class shorter than river_cells falls back to RIVER_CLASS_FALLBACK +## (TRUNK) for the missing entry — the same graceful-decode posture +## skeleton_class_visible_at_rung()'s own caller already relies on. +func test_build_skeleton_chords_missing_class_entry_falls_back_to_trunk() -> void: + var river_cells: Array = [[10, 10]] + var river_downstream: Array = [1] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, [], river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(1) + assert_int(chords[0]["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) + + +## REVERT-VERIFICATION pin (per the ticket brief's explicit ask to +## revert-verify the most load-bearing construction path): a chain of THREE +## cells (0 -> 1 -> MOUTH) must construct exactly TWO segments in the correct +## from/to order — proving the chain doesn't just count sentinels correctly +## in isolation (the tests above) but actually threads a multi-hop chain. +## Breaking build_skeleton_chords() to, e.g., always connect cell i to cell +## i+1 by INDEX (the old dot-scatter's adjacency, not a real D8 decode) would +## still pass the single-pair test above by coincidence but fail this one, +## since cell 1's OWN downstream direction (2 = E) does not point at +## cell 2's grid position. +func test_build_skeleton_chords_three_hop_chain_threads_correctly() -> void: + var river_cells: Array = [[0, 0], [1, 0], [1, 5]] # cell 2 is NOT south of cell 1 + var river_class: Array = [ + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ] + var river_downstream: Array = [ + 1, # cell 0 -> S -> (1, 0), matches cell 1's own grid position + 2, # cell 1 -> E -> (1, 1) — NOT cell 2's position (1, 5) + AtlasWindowGeometryNature.RIVER_DOWNSTREAM_MOUTH, + ] + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, "Region" + ) + assert_int(chords.size()).is_equal(2) + assert_that(chords[0]["from"]).is_equal(Vector2(0.0, 0.0)) + assert_that(chords[0]["to"]).is_equal(Vector2(1.0, 0.0)) + assert_that(chords[1]["from"]).is_equal(Vector2(1.0, 0.0)) + # cell 1's OWN downstream (E) decodes to (1, 1), NOT cell 2's own listed + # position (1, 5) — pinning that this function trusts the D8 DECODE, not + # a by-index lookup into river_cells, exactly per the doc's "the decoded + # target cell is not required to appear in river_cells" contract. + assert_that(chords[1]["to"]).override_failure_message( + "cell 1's downstream target must be its DECODED D8 neighbor (1,1)," + + " never a by-index lookup into river_cells (which would wrongly" + + " give (1,5), cell 2's own listed position)" + ).is_equal(Vector2(1.0, 1.0)) + + +# ============================================================================= +# T-1170 Ruling 5c: the RIVER_CLASS_VISIBLE_BY_RUNG split — +# skeleton_class_visible_at_rung() (Region+ chord-chain path, Ruling 5a) and +# course_class_visible_at_rung() (District/Quarter windowed path, Ruling 5b). +# ============================================================================= + + +func test_skeleton_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" + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Region" ) ).is_true() assert_bool( - AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_TRUNK, "Region") + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Region" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.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() +## T-1170: the skeleton path no longer draws AT ALL at District/Quarter (the +## chord chain is Region-only — District/Quarter draw courses instead, the +## OTHER table below) — this is a CHANGE from wave 1's original District +## "trunk only" disposition on the single RIVER_CLASS_VISIBLE_BY_RUNG table. +func test_skeleton_class_visible_at_rung_district_and_quarter_show_nothing() -> void: + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "District") + ).override_failure_message( + "the skeleton (chord-chain) path must show NOTHING at District —" + + " District draws courses instead (Ruling 5b)" + ).is_false() + assert_bool( + AtlasWindowGeometryNature.skeleton_class_visible_at_rung(cls, "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: +func test_skeleton_class_visible_at_rung_unknown_tag_falls_back_to_region() -> void: assert_bool( - AtlasWindowGeometry.river_class_visible_at_rung(AtlasWindowGeometry.RIVER_CLASS_STREAM, "Bogus") + AtlasWindowGeometryNature.skeleton_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Bogus" + ) ).is_true() +func test_course_class_visible_at_rung_district_shows_trunk_and_tributary_only() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "District" + ) + ).override_failure_message("District courses must NOT show streams").is_false() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "District" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "District" + ) + ).is_true() + + +## The pre-announced wave-1 revisit executing: Quarter shows ALL THREE +## classes on the course path — "Quarter rivers return". +func test_course_class_visible_at_rung_quarter_shows_every_class() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, "Quarter" + ) + ).override_failure_message("Quarter rivers return — streams must be visible").is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, "Quarter" + ) + ).is_true() + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Quarter" + ) + ).is_true() + + +## Region never carries courses (Ruling 1) — the course table has no Region +## key at all, and this reader must fail to EMPTY (not fall back to "show +## everything", the opposite fallback direction from the skeleton reader) so +## a caller can never accidentally draw course polylines at Region. +func test_course_class_visible_at_rung_region_shows_nothing() -> void: + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung(cls, "Region") + ).override_failure_message( + "courses must never be visible at Region — Region draws the skeleton" + + " chord chain, never windowed course content" + ).is_false() + + +func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void: + assert_bool( + AtlasWindowGeometryNature.course_class_visible_at_rung( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "Bogus" + ) + ).is_false() + + +# ============================================================================= +# T-1170 Ruling 5b/3h: build_course_render_plan() — pure course-polyline +# CONSTRUCTION (no draw calls), the course-path counterpart to B2's +# build_skeleton_chords(). Synthetic fixtures shaped per Ruling 3h's wire +# shape: {class: u8, points: Vec<(i32,i32)> world-metres, terminus: string} +# — built BEFORE Dudley's A2 (course inventor) lands, per the ticket brief's +# explicit instruction. +# ============================================================================= + + +static func _course_fixture( + cls: int, points: Array, terminus: String = "None" +) -> Dictionary: + return {"edge_id": 1, "class": cls, "points": points, "terminus": terminus} + + +func test_build_course_render_plan_district_trunk_is_visible_and_constructs_points() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0], [4096, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_int(canvas_pts.size()).is_equal(3) + assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK) + assert_str(plan["terminus"]).is_equal("None") + + +## District does NOT show streams (COURSE_CLASS_VISIBLE_BY_RUNG: District == +## [TRIBUTARY, TRUNK]) — a stream-class course must construct nothing at +## District, even with perfectly well-formed points. +func test_build_course_render_plan_district_stream_is_not_visible() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [2048, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).override_failure_message( + "streams must not draw at District — only tributary+trunk are visible there" + ).is_null() + + +## Quarter rivers return — ALL THREE classes construct at Quarter, including +## streams. This is the wave-1 pre-announced revisit actually landing. +func test_build_course_render_plan_quarter_stream_is_visible() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, [[0, 0], [512, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "Quarter", Vector2i.ZERO, 16, CELL_PIXEL_SIZE + ) + assert_that(plan).override_failure_message( + "Quarter rivers return — streams must be visible at Quarter" + ).is_not_null() + + +## Region never carries courses — a course-shaped fixture queried at "Region" +## must construct nothing, regardless of class. +func test_build_course_render_plan_region_constructs_nothing() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "Region", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## Ruling 3h: terminus MOUTH is preserved through to the plan — the caller +## (the overlay's draw function) reads this to decide whether to draw a +## mouth ring at the LAST canvas point. +func test_build_course_render_plan_preserves_mouth_terminus() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], "Mouth" + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH) + + +## EdgeDrain, ContinuesBeyondWindow, and the default None terminus are all +## preserved verbatim too — the PLAN doesn't collapse them, the DRAW caller +## decides presentation (no ring for any of these three). +func test_build_course_render_plan_preserves_edge_drain_and_continues_and_none_termini() -> void: + for terminus in ["EdgeDrain", "ContinuesBeyondWindow", "None"]: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], [2048, 0]], terminus + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_str(plan["terminus"]).is_equal(terminus) + + +## A course with no `terminus` key at all (an old/malformed payload) defaults +## to COURSE_TERMINUS_NONE (the string "None"), never GDScript `null` or an +## empty string — matching the class-fallback graceful-decode posture used +## throughout this cluster. +func test_build_course_render_plan_missing_terminus_defaults_to_none_string() -> void: + var course: Dictionary = { + "edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, "points": [[0, 0], [100, 0]] + } + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_str(plan["terminus"]).is_equal(AtlasWindowGeometryNature.COURSE_TERMINUS_NONE) + + +## A course missing `class` entirely falls back to RIVER_CLASS_FALLBACK +## (TRUNK) — same posture as the skeleton path's river_class fallback. +func test_build_course_render_plan_missing_class_falls_back_to_trunk() -> void: + var course: Dictionary = {"edge_id": 1, "points": [[0, 0], [100, 0]]} + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + assert_int(plan["cls"]).is_equal(AtlasWindowGeometryNature.RIVER_CLASS_FALLBACK) + + +## Fewer than 2 points (a degenerate single-point or empty course) has no +## line to draw — must construct null, not a 1-point/0-point polyline. +func test_build_course_render_plan_fewer_than_two_points_constructs_nothing() -> void: + var one_point: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0]]) + var no_points: Dictionary = _course_fixture(AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, []) + assert_that( + AtlasWindowGeometryNature.build_course_render_plan( + one_point, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + ).is_null() + assert_that( + AtlasWindowGeometryNature.build_course_render_plan( + no_points, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + ).is_null() + + +## Missing `points` key entirely (not just an empty array) must also +## construct nothing, not crash on a null/missing field read. +func test_build_course_render_plan_missing_points_key_constructs_nothing() -> void: + var course: Dictionary = {"edge_id": 1, "class": AtlasWindowGeometryNature.RIVER_CLASS_TRUNK} + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## A malformed individual point (not an array, or too short) is skipped — +## not fatal to the whole polyline, matching build_skeleton_chords()'s own +## "skip the bad entry, keep going" posture — as long as >= 2 valid points +## remain. +func test_build_course_render_plan_malformed_point_is_skipped_not_fatal() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "not a point", [2048, 0], [4096, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_not_null() + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_int(canvas_pts.size()).override_failure_message( + "the malformed point must be skipped, leaving exactly the 3 well-formed points" + ).is_equal(3) + + +## Malformed points that leave FEWER than 2 valid entries must still +## construct null (the "too many bad points" case, distinct from "some bad +## points but enough good ones remain" above). +func test_build_course_render_plan_malformed_points_leaving_too_few_constructs_nothing() -> void: + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, [[0, 0], "bad", "also bad"] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", Vector2i.ZERO, 64, CELL_PIXEL_SIZE + ) + assert_that(plan).is_null() + + +## Points are WORLD METRES (Ruling 3h), not heightmap pixels — cross-checked +## against world_m_to_canvas_local() called manually, proving the plan's +## conversion path matches the documented one-fewer-step-than-skeleton +## pipeline (no layer1_pixel_to_world_m() involved at all). +func test_build_course_render_plan_points_are_world_metres_not_pixels() -> void: + var held_center := Vector2i(5, 5) + var held_n := 64 + var world_pt := Vector2(10240.0, -4096.0) # 5 districts east, 2 north of origin + var course: Dictionary = _course_fixture( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + [[int(world_pt.x), int(world_pt.y)], [0, 0]] + ) + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, "District", held_center, held_n, CELL_PIXEL_SIZE + ) + var expected: Vector2 = AtlasWindowGeometryNature.world_m_to_canvas_local( + world_pt, held_center, held_n, CELL_PIXEL_SIZE + ) + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + assert_that(canvas_pts[0]).is_equal_approx(expected, Vector2.ONE * 0.01) + + +# ============================================================================= +# T-1170 Ruling 5c: course_class_width_px() / course_class_opacity() — +# functional-default companion tables to COURSE_CLASS_VISIBLE_BY_RUNG. +# ============================================================================= + + +func test_course_class_width_px_trunk_widest_stream_thinnest() -> void: + var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var tributary_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY + ) + var trunk_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ) + assert_float(trunk_w).override_failure_message( + "trunk course width must be the WIDEST of the three classes" + ).is_greater(tributary_w) + assert_float(tributary_w).override_failure_message( + "tributary course width must be strictly between stream and trunk" + ).is_greater(stream_w) + + +func test_course_class_opacity_trunk_most_opaque_stream_least() -> void: + var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var trunk_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK + ) + assert_float(trunk_o).is_greater(stream_o) + assert_float(trunk_o).override_failure_message("trunk opacity must be fully opaque (1.0)").is_equal_approx( + 1.0, 0.0001 + ) + + +## An unrecognized class id falls back to the stream (thinnest/most transparent) +## defaults on both tables — the documented, deliberate "unknown -> least +## visually assertive" fallback. +func test_course_class_width_and_opacity_unknown_class_falls_back_to_stream() -> void: + var stream_w: float = AtlasWindowGeometryNature.course_class_width_px( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + var stream_o: float = AtlasWindowGeometryNature.course_class_opacity( + AtlasWindowGeometryNature.RIVER_CLASS_STREAM + ) + assert_float(AtlasWindowGeometryNature.course_class_width_px(99)).is_equal_approx( + stream_w, 0.0001 + ) + assert_float(AtlasWindowGeometryNature.course_class_opacity(99)).is_equal_approx( + stream_o, 0.0001 + ) + + # ============================================================================= # 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() + assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.confluences_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.mouths_visible_at_rung("District")).is_true() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.basins_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.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() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Region")).is_true() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("District")).is_false() + assert_bool(AtlasWindowGeometryNature.attractors_visible_at_rung("Quarter")).is_false() # ============================================================================= @@ -234,7 +834,7 @@ func test_attractors_visible_at_rung_region_only() -> void: ## 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) + assert_float(AtlasWindowGeometryNature.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, @@ -246,7 +846,9 @@ func test_zoom_compensated_size_at_zoom_one_is_unchanged() -> void: 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) + var compensated: float = AtlasWindowGeometryNature.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 @@ -277,50 +879,7 @@ func test_uncompensated_radius_at_orbital_zoom_would_be_sub_pixel() -> void: ## 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) + var result: float = AtlasWindowGeometryNature.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() - - -# ============================================================================= -# T-1172 round 2: cell_index_for_local_offset() — the shared painter/clip -# index formula (see its own doc for the "why shared, not duplicated" case). -# ============================================================================= - - -func test_cell_index_for_local_offset_top_left_is_zero_zero() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(0.0, 0.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(0, 0)) - - -## The exact live-repro numbers from T-1172 round 2's trace: a query whose -## district-space local offset is (2594.09, 5593.15) inside a 6400-wide, -## 64-cell-side window must resolve to (col=25, row=55) — pinned directly -## against the LIVE captured values that closed the investigation (both the -## painter's _build_tile_texture() and the clip independently produced this -## exact pair for the same query in the live trace). -func test_cell_index_for_local_offset_matches_the_live_trace_repro() -> void: - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset( - 2594.0849609375, 5593.15258789062, 6400, 64 - ) - assert_that(cell).override_failure_message( - "must match the live-captured painter/clip agreement point from the" - + " T-1172 round 2 investigation — (col=25, row=55)" - ).is_equal(Vector2i(25, 55)) - - -func test_cell_index_for_local_offset_bottom_right_boundary_clamps_inside() -> void: - # local offset == n (the exclusive upper boundary) must clamp to the LAST - # cell, not overflow to a nonexistent grid_side'th cell. - var cell: Vector2i = AtlasWindowGeometry.cell_index_for_local_offset(6400.0, 6400.0, 6400, 64) - assert_that(cell).is_equal(Vector2i(63, 63)) - - -func test_cell_index_for_local_offset_zero_n_or_grid_side_returns_sentinel() -> void: - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 0, 64)).is_equal( - Vector2i(-1, -1) - ) - assert_that(AtlasWindowGeometry.cell_index_for_local_offset(10.0, 10.0, 6400, 0)).is_equal( - Vector2i(-1, -1) - ) diff --git a/client/tests/test_atlas_window_geometry_stroke_width.gd b/client/tests/test_atlas_window_geometry_stroke_width.gd new file mode 100644 index 000000000..9766199dc --- /dev/null +++ b/client/tests/test_atlas_window_geometry_stroke_width.gd @@ -0,0 +1,252 @@ +## T-1170 live round (2026-07-23): tests for +## AtlasWindowGeometryNature.zoom_compensated_stroke_width() — the +## STROKE-WIDTH-specific sibling of zoom_compensated_size(), added after the +## course-polyline hairline finding (Araminta's pixel scan of +## D-district-courses.png/Q-quarter-courses.png: a UNIFORM 1px hairline for +## the ENTIRE visible course, no width/opacity variation at all, in BOTH +## District and Quarter captures). Split into its own file rather than +## folded into test_atlas_window_geometry_nature.gd, which was already at the +## gdlint max-file-lines cap — same file-per-concern precedent as every other +## split in this cluster. +## +## Live A/B evidence (temporary instrumentation, since reverted — the +## dossier discipline): draw_line()/draw_polyline() called with a +## canvas-local width in [0.6, 1.0) renders as a flat 1px hairline +## regardless of the input value, confirmed identically on BOTH APIs (ruling +## out a draw_polyline()-specific quirk) — Godot's line rasterizer has a +## ~1.0-canvas-local-unit floor that draw_circle()'s radius parameter does +## NOT share (confirmed: mouth ring radii at the same District/Quarter zoom +## render correctly-sized via the unchanged zoom_compensated_size()/_zs() +## path — only the STROKE WIDTH argument was affected). The compensation +## MATH itself was never wrong (0.373 * 3.75 round-trips to 1.4 exactly) — +## the bug was that nothing floored the intermediate value against Godot's +## own rasterizer minimum before handing it to draw_line()/draw_polyline(). +class_name TestAtlasWindowGeometryStrokeWidth +extends GdUnitTestSuite + +const AtlasWindowGeometryNature := preload( + "res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd" +) + + +## At zoom=1.0, unchanged from zoom_compensated_size() — no floor engages +## when the input is already >= 1.0. +func test_zoom_compensated_stroke_width_at_zoom_one_is_unchanged() -> void: + assert_float( + AtlasWindowGeometryNature.zoom_compensated_stroke_width(2.2, 1.0) + ).is_equal_approx(2.2, 0.0001) + + +## The EXACT regression shape this round closes: at District's real fit zoom +## (3.75, live capture), the tributary class's raw table width (1.4px) +## divides to 0.3733 canvas-local — BELOW the 1.0 floor under the OLD +## zoom_compensated_size() path (pinned directly, not just asserted) — and +## zoom_compensated_stroke_width() must instead return exactly 1.0 (the +## floor), never the sub-floor raw division result. +func test_zoom_compensated_stroke_width_district_tributary_hits_the_floor() -> void: + var view_zoom := 3.75 # LENDEL's live District fit zoom, capture-confirmed + var raw_width_px := 1.4 # COURSE_CLASS_WIDTH_PX[RIVER_CLASS_TRIBUTARY] + var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom) + assert_float(unfloored).override_failure_message( + "regression pin: the OLD unfloored division must be BELOW 1.0 at this" + + " zoom — this is the exact numeric shape of the hairline bug" + ).is_less(1.0) + var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) + assert_float(floored).override_failure_message( + "zoom_compensated_stroke_width() must clamp to the 1.0 floor, not the" + + " sub-pixel unfloored value that collapses to Godot's hairline" + ).is_equal_approx(1.0, 0.0001) + + +## Same shape at Quarter's real fit zoom (7.5, live capture) — the floor +## engages even harder there (raw width divides to 0.1867). +func test_zoom_compensated_stroke_width_quarter_tributary_hits_the_floor() -> void: + var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed + var raw_width_px := 1.4 + var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) + assert_float(floored).is_equal_approx(1.0, 0.0001) + + +## Regression pin (PR #195 stroke-width-class shape, per the coordinator's +## explicit ask): for EACH course class, at District's real fit zoom, the +## EFFECTIVE on-screen width the render plan feeds (canvas-local width times +## view_zoom, exactly what the canvas transform multiplies at render time) +## must equal AT LEAST the table value — never less, since the floor can only +## push the effective width UP from what an unfloored divide would produce, +## never down. This is the "does the value actually reaching the screen +## match the table" pin the coordinator asked for, computed both ways +## (floored vs table) rather than eyeballed. +func test_effective_stroke_width_at_district_zoom_meets_table_value_per_class() -> void: + var view_zoom := 3.75 + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls) + var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( + table_width, view_zoom + ) + var effective_screen_px: float = canvas_local * view_zoom + assert_float(effective_screen_px).override_failure_message( + ( + "class %d's effective on-screen stroke width (%.3fpx) must be AT" + + " LEAST its table value (%.3fpx) — the floor must never make a" + + " course THINNER than the ruling specifies, only ever thicker" + + " when the literal value would otherwise be sub-pixel" + ) + % [cls, effective_screen_px, table_width] + ).is_greater_equal(table_width - 0.001) + + +## Same pin at Quarter's fit zoom (7.5) — the floor engages harder there +## (streams' 0.9px table value divides to 0.12 canvas-local, furthest below +## the floor of any class/rung combination this batch draws). +func test_effective_stroke_width_at_quarter_zoom_meets_table_value_per_class() -> void: + var view_zoom := 7.5 + for cls in [ + AtlasWindowGeometryNature.RIVER_CLASS_STREAM, + AtlasWindowGeometryNature.RIVER_CLASS_TRIBUTARY, + AtlasWindowGeometryNature.RIVER_CLASS_TRUNK, + ]: + var table_width: float = AtlasWindowGeometryNature.course_class_width_px(cls) + var canvas_local: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( + table_width, view_zoom + ) + var effective_screen_px: float = canvas_local * view_zoom + assert_float(effective_screen_px).is_greater_equal(table_width - 0.001) + + +## At a LOW zoom (well under 1.0, e.g. an extreme zoom-out within a rung — +## not just Region's orbital case), the floor must NOT engage: the ordinary +## divide-then-scale math must still produce the literal table value exactly, +## matching zoom_compensated_size()'s own unfloored behavior. Pins that the +## floor is a ONE-DIRECTION safety net, not a blanket override. +func test_zoom_compensated_stroke_width_does_not_engage_at_low_zoom() -> void: + var view_zoom := 0.1 + var raw_width_px := 2.2 + var floored: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(raw_width_px, view_zoom) + var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_width_px, view_zoom) + assert_float(floored).override_failure_message( + "at a zoom where the unfloored value is already well above 1.0, the" + + " floor must be a no-op — identical to zoom_compensated_size()" + ).is_equal_approx(unfloored, 0.0001) + + +## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce +## infinity/NaN — same total-function guarantee as zoom_compensated_size(). +func test_zoom_compensated_stroke_width_zero_zoom_does_not_blow_up() -> void: + var result: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(2.2, 0.0) + assert_bool(is_finite(result)).override_failure_message( + "a degenerate zero view_zoom must not produce inf/NaN" + ).is_true() + + +# ============================================================================= +# T-1170 live round (2026-07-23, coordinator's mouth-ring finding): +# zoom_compensated_ring_radius() — the RADIUS-SMALLER-THAN-STROKE regime. +# Same A/B-bracket discipline as the stroke-width suite above, applied to +# draw_arc() RING markers (mouth rings), whose radius AND stroke are both +# small zoom-compensated values that can cross each other. +## +## Live A/B evidence (temporary instrumentation, since reverted): at the +## PRODUCTION Quarter-rung radius/stroke pair (radius=0.667, stroke=1.0 +## canvas-local units, Quarter fit zoom 7.5), draw_arc() rendered a SOLID +## BLOB, not a hollow ring — confirmed via a re-centered live capture (the +## ORIGINAL "zero ring pixels" symptom was a separate viewport-framing crop, +## not this bug — see zoom_compensated_ring_radius()'s own doc). Bracket +## (stroke fixed at 1.0 canvas-local, radius varied): 0.51 (~stroke/2) -> +## blob; 1.0 (=stroke, the production case) -> blob; 1.5 (1.5x stroke) -> +## hollow ring recovers; 2.0 (2x stroke) -> hollow ring, cleaner. Floor set +## at 2x with margin over the observed 1.0x-blob/1.5x-hollow transition. +# ============================================================================= + + +## At zoom=1.0 with a radius comfortably above stroke*2 already, the floor +## must be a no-op — identical to zoom_compensated_size() directly. +func test_zoom_compensated_ring_radius_no_op_when_radius_already_clears_the_floor() -> void: + var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, 1.5, 1.0) + assert_float(radius).is_equal_approx(5.0, 0.0001) + + +## The EXACT regression shape this round closes: at Quarter's real fit zoom +## (7.5, live capture), MOUTH_RING_RADIUS (5.0) divides to 0.667 +## canvas-local — BELOW its own paired stroke (1.5/7.5 floored to 1.0 via +## zoom_compensated_stroke_width) — pinned directly, not just asserted. +## zoom_compensated_ring_radius() must instead return stroke * 2.0 (the +## floor), never the sub-floor raw division result that produced the blob. +func test_zoom_compensated_ring_radius_quarter_mouth_ring_hits_the_floor() -> void: + var view_zoom := 7.5 # LENDEL's live Quarter fit zoom, capture-confirmed + var raw_radius := 5.0 # MOUTH_RING_RADIUS + var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom) + var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom) + assert_float(unfloored).override_failure_message( + "regression pin: the OLD unfloored radius must be AT/BELOW the paired" + + " stroke at this zoom — this is the exact numeric shape of the blob bug" + ).is_less_equal(stroke) + var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( + raw_radius, stroke, view_zoom + ) + assert_float(floored).override_failure_message( + "zoom_compensated_ring_radius() must clamp to stroke * 2.0 (the floor)," + + " not the sub-floor unfloored value that renders as a solid blob" + ).is_equal_approx(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, 0.0001) + + +## Regression pin (the stroke-width suite's own "effective on-screen value" +## shape, applied to the radius/stroke RATIO instead of an absolute value): +## for the mouth ring AND halo (the two draw_arc() ring markers in this +## cluster), at Quarter's real fit zoom, the floored radius must be AT LEAST +## RING_RADIUS_STROKE_MULTIPLIER times its own paired stroke — the actual +## geometric property that keeps the ring hollow, verified directly rather +## than just re-checking the numeric floor value in isolation. +func test_ring_radius_stays_at_least_the_multiplier_above_its_stroke_at_quarter_zoom() -> void: + var view_zoom := 7.5 + # (raw_radius_px, raw_stroke_px) pairs — the mouth ring and halo's own + # literal call-site arguments in _draw_mouth(). + for pair in [[5.0, 1.5], [8.0, 1.0]]: + var raw_radius: float = pair[0] + var raw_stroke: float = pair[1] + var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width( + raw_stroke, view_zoom + ) + var radius: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( + raw_radius, stroke, view_zoom + ) + assert_float(radius).override_failure_message( + ( + "radius %.3f must be at least %.1fx its paired stroke %.3f — a ratio" + + " below this rendered as a SOLID BLOB in the live A/B bracket," + + " never a hollow ring" + ) + % [radius, AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER, stroke] + ).is_greater_equal(stroke * AtlasWindowGeometryNature.RING_RADIUS_STROKE_MULTIPLIER - 0.0001) + + +## At a LOW zoom (e.g. Region's tiny orbital fit, or any zoom where the +## naive radius is already well clear of the floor), the floor must NOT +## engage — matching zoom_compensated_stroke_width()'s own +## does-not-engage-at-low-zoom guarantee. Pins that this is a one-direction +## safety net, not a blanket override. +func test_zoom_compensated_ring_radius_does_not_engage_at_low_zoom() -> void: + var view_zoom := 0.0063 # Lendel's real orbital fit zoom + var raw_radius := 5.0 # MOUTH_RING_RADIUS + var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, view_zoom) + var floored: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius( + raw_radius, stroke, view_zoom + ) + var unfloored: float = AtlasWindowGeometryNature.zoom_compensated_size(raw_radius, view_zoom) + assert_float(floored).override_failure_message( + "at a zoom where the naive radius is already far above the floor, the" + + " floor must be a no-op — identical to zoom_compensated_size()" + ).is_equal_approx(unfloored, 0.0001) + + +## A degenerate zero (or negative) view_zoom must not divide-by-zero/produce +## infinity/NaN — same total-function guarantee as the stroke-width sibling. +func test_zoom_compensated_ring_radius_zero_zoom_does_not_blow_up() -> void: + var stroke: float = AtlasWindowGeometryNature.zoom_compensated_stroke_width(1.5, 0.0) + var result: float = AtlasWindowGeometryNature.zoom_compensated_ring_radius(5.0, stroke, 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 index 90b9e0a03..10d453427 100644 --- a/client/tests/test_atlas_window_nature_overlay.gd +++ b/client/tests/test_atlas_window_nature_overlay.gd @@ -442,3 +442,138 @@ func test_mouth_position_on_land_is_not_suppressed() -> void: assert_bool(o._is_drawn_water(Vector2(1.9, 1.9), ctx)).override_failure_message( "a mouth position over drawn land must render exactly as today (not clipped)" ).is_false() + + +# ============================================================================= +# T-1170 Ruling 3g/5a: _segment_touches_drawn_water() — the CHORD SEGMENT +# clip rule (both endpoints + midpoint), replacing the old per-point-only +# clip for the skeleton-chord draw path. The water cell is (0,0) in district +# space, per _mock_4x4_water_corner_window()'s own doc — spans roughly +# [-0.5, 0.5) x [-0.5, 0.5) at this fixture's district granularity. +# ============================================================================= + + +## Both endpoints on land, entirely away from the water cell — no clip. +func test_segment_touches_drawn_water_false_when_fully_on_land() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + assert_bool( + o._segment_touches_drawn_water(Vector2(1.0, 1.0), Vector2(1.9, 1.9), ctx) + ).override_failure_message( + "a segment entirely on land (both endpoints, and therefore its" + + " midpoint) must not be clipped" + ).is_false() + + +## Either endpoint alone on water clips the whole segment. +func test_segment_touches_drawn_water_true_when_an_endpoint_is_on_water() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + assert_bool( + o._segment_touches_drawn_water(Vector2(-1.9, -1.9), Vector2(1.9, 1.9), ctx) + ).override_failure_message( + "a segment with EITHER endpoint over drawn water must be clipped" + ).is_true() + + +## The decision this rule specifically exists to catch (Ruling 3g's ask, "pick +## the visually cleaner rule, document it, test it"): BOTH endpoints on land, +## on opposite sides of the water cell, with the MIDPOINT landing inside it — +## an endpoints-only rule would miss this entirely (a chord visibly crossing +## open water with neither end clipped). The midpoint sample must catch it. +func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> void: + var stub := _ViewerStub.new() + stub.district_window = _mock_4x4_water_corner_window() + var o = _make_overlay(stub) + var ctx: Dictionary = _ctx_for(stub) + # The water cell is (col=0, row=0), spanning district [-2,-1) x [-2,-1) in + # this n=4/grid_side=4 fixture (1:1 district-to-cell mapping). Pick + # endpoints that EACH resolve to a DIFFERENT LAND cell adjacent to the + # water corner — (-1.99, -0.9) resolves to (col=0, row=1), land; (-0.9, + # -1.99) resolves to (col=1, row=0), land — but their MIDPOINT + # (-1.445, -1.445) falls squarely inside the water cell (col=0, row=0). + # Verified numerically, not eyeballed (see the two sanity asserts below). + var from_district := Vector2(-1.99, -0.9) + var to_district := Vector2(-0.9, -1.99) + # Sanity: neither endpoint alone is clipped (both resolve to LAND cells) + # — isolates the midpoint as the ONLY reason the segment clips below. + assert_bool(o._is_drawn_water(from_district, ctx)).override_failure_message( + "test setup invariant: the FROM endpoint alone must resolve to land" + ).is_false() + assert_bool(o._is_drawn_water(to_district, ctx)).override_failure_message( + "test setup invariant: the TO endpoint alone must resolve to land" + ).is_false() + assert_bool(o._segment_touches_drawn_water(from_district, to_district, ctx)).override_failure_message( + "a segment whose ENDPOINTS are both on land but whose MIDPOINT lands" + + " on drawn water must still be clipped — this is the exact failure" + + " mode an endpoints-only rule would miss (Ruling 3g's ask)" + ).is_true() + + +# ============================================================================= +# T-1170 Ruling 5b (B3): _draw_course_path() early-return gating — the SAME +# "call the function directly when its early-return happens BEFORE any +# draw_*() call" precedent test_draw_with_null_viewer_is_a_noop() and +# test_draw_with_zero_grid_dims_returns_before_any_draw_call() already +# establish. Every case below returns before _draw_one_course() is ever +# reached, so calling _draw_course_path() directly (no SubViewport/render +# context) is safe. This is a SEPARATE data source/gate from the Layer-1 +# skeleton path above — none of these tests touch _layer1 at all. +# ============================================================================= + + +## The overlay-bar "gen_rivers" toggle gates the course path too — the SAME +## toggle the skeleton path uses (one player-facing "rivers" control covers +## both presentation surfaces, per the ruling). +func test_draw_course_path_returns_before_any_draw_when_gen_rivers_is_off() -> void: + var stub := _ViewerStub.new() + stub.overlay_visibility["gen_rivers"] = false + stub.district_window = { + "n": 64, "granularity_v2": "District", + "courses": [{"class": 2, "points": [[0, 0], [100, 0]], "terminus": "None"}], + } + var o = _make_overlay(stub) + o._draw_course_path() # must return before draw_polyline() — no crash outside a render context + + +## No district window at all (single-window mode hasn't arrived yet) — the +## course path must return cleanly, not crash on a null window read. +func test_draw_course_path_returns_before_any_draw_when_no_window() -> void: + var stub := _ViewerStub.new() + stub.district_window = null + var o = _make_overlay(stub) + o._draw_course_path() + + +## Ruling 3h decode tolerance: a window WITHOUT a `courses` key at all (the +## old/pre-A2 payload shape) must draw NOTHING at District/Quarter except +## mouths-on-land from the skeleton (that's the OTHER path's job) — this +## path itself must simply return, not error or fall back to a dot-scatter. +func test_draw_course_path_missing_courses_field_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District"} # no "courses" key + var o = _make_overlay(stub) + o._draw_course_path() + + +## An explicitly present but EMPTY courses array must also be tolerated +## cleanly (the loop simply iterates zero times). +func test_draw_course_path_empty_courses_array_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District", "courses": []} + var o = _make_overlay(stub) + o._draw_course_path() + + +## A `courses` field that is present but the WRONG TYPE (not an Array — e.g. +## a malformed/corrupted payload) must be tolerated the same way as a +## missing field, not crash attempting to iterate a non-Array. +func test_draw_course_path_non_array_courses_field_is_tolerated() -> void: + var stub := _ViewerStub.new() + stub.district_window = {"n": 64, "granularity_v2": "District", "courses": "not an array"} + var o = _make_overlay(stub) + o._draw_course_path() diff --git a/client/tests/test_atlas_window_viewer.gd b/client/tests/test_atlas_window_viewer.gd index 94fc603f5..916ac1a49 100644 --- a/client/tests/test_atlas_window_viewer.gd +++ b/client/tests/test_atlas_window_viewer.gd @@ -233,6 +233,146 @@ func test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() ).is_greater(baseline) +## T-1170 B3 (PR #197 review, Hoshe #3): _on_window_ready() now also calls +## _nature_overlay.queue_redraw() (atlas_window_viewer.gd:507) — courses ride +## `DistrictWindowLayer.courses`, the SAME `_window` this handler adopts, so +## a window arrival that never redraws the nature overlay would leave freshly +## arrived courses invisible until an UNRELATED pan/zoom gesture happened to +## redraw it. Same spy-and-baseline shape as +## test_set_overlay_visible_gen_basins_flips_gate_and_redraws_nature_overlay() +## above — this is the exact pattern PR #196 established for "prove a +## specific queue_redraw() call site actually fires", applied to the +## window-arrival call site instead of the toggle call site. +## +## **Isolation note (live finding while writing this test):** `_on_window_ready()` +## ALSO calls `_fit_and_center()` on the first-ever arrival +## (`_awaiting_first_window and not _user_adjusted`), and `_fit_and_center()` +## itself already ends in `_apply_transform()`, which redraws the nature +## overlay through a SEPARATE, pre-existing call site. That path would mask +## a broken/removed line 507 (both call sites fire on a fresh entry's first +## arrival, so removing just one wouldn't drop draw_count below baseline). +## Setting `_user_adjusted = true` before the response arrives — the SAME +## guard a real pan/zoom gesture sets (`_maybe_refloat_window()`/`_zoom_at()`) +## — skips the fit-and-center branch, so ONLY line 507 can be the source of +## any redraw the assertion below observes. This is a real, reachable state +## (any window arrival after the player's first manual pan/zoom), not a +## test-only fiction. +func test_window_arrival_redraws_nature_overlay() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + v._user_adjusted = true # isolate line 507 from the first-arrival fit-and-center redraw + + # Swap in the counting spy AFTER enter() (matching the gen_basins test's + # own "swap after construction, then let it settle" shape) so enter()'s + # own queue_redraw() calls don't pollute the baseline. + 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 window" + + " arrives, or this test can't distinguish 'redrawn BY the arrival'" + + " from 'never drawn at all'" + ).is_greater(0) + + var window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", window)) + await get_tree().process_frame + + assert_that(v.get_district_window()).override_failure_message( + "sanity: the response must actually have been adopted (matching echo)" + + " or this test proves nothing about the arrival path specifically" + ).is_equal(window) + assert_int(spy.draw_count).override_failure_message( + "_on_window_ready() must queue_redraw() the NATURE overlay — courses" + + " ride the SAME _window this handler adopts, so a window arrival" + + " that doesn't redraw the nature overlay leaves freshly arrived" + + " courses invisible until an unrelated pan/zoom happens to redraw" + + " it — draw_count must have advanced past the baseline (%d)" % baseline + ).is_greater(baseline) + + +## The RUNG-SWAP arrival case (a later _on_window_ready() call for a +## DIFFERENT granularity_v2 than the one the viewer entered at — e.g. a +## wheel-zoom crossing from District into Quarter) — cheap to cover in the +## SAME test file per the review's own "if cheap" allowance. Confirms the +## redraw fires on EVERY window adoption, not just the first-ever one +## (T-1153's progressive-refinement doc is explicit that _window only ever +## gets REPLACED, never renulled, on a rung swap). +## +## **Uses `_window_request.request_now()` directly, NOT `_enter_at_rung()`** +## — a live finding while writing this test: `_enter_at_rung()` sets +## `_awaiting_first_window = true` again (it's the SAME reset path a fresh +## descent uses), which would route the swap response back through +## `_fit_and_center()`'s OWN redraw call site, masking line 507 exactly like +## the note on the test above. The REAL production rung-swap path, +## `_maybe_reselect_rung()`, never touches `_awaiting_first_window` at all — +## it only calls `_window_request.request_debounced(...)`. `request_now()` +## (the non-debounced sibling, same effect minus the timer) is called +## directly here to update `_window_request`'s own `_granularity_v2` — the +## exact field `_on_window_ready()`'s echo-matching guard reads — mirroring +## the real path's state change without needing a live debounce timer in a +## unit test. +func test_rung_swap_window_arrival_redraws_nature_overlay() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.enter({"body_id": "GJ380c"}, {}, Vector2i(10, 20), 2) + + # First arrival (District, matches enter()'s own default rung) — settles + # the viewer into a held window, exactly as a real progressive-refinement + # sequence would before a rung swap. Uses the REAL (non-spy) nature + # overlay for this leg — only the swap leg itself needs the spy. + var district_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", district_window)) + await get_tree().process_frame + assert_that(v.get_district_window()).override_failure_message( + "sanity: the first (District) arrival must have been adopted before" + + " simulating the swap" + ).is_equal(district_window) + + # Now swap in the spy and simulate the RUNG SWAP itself — update the + # request's echoed granularity_v2 to "Quarter" (what + # _maybe_reselect_rung() -> request_debounced() would do on a real + # wheel-zoom crossing) WITHOUT touching _awaiting_first_window, so the + # response below takes the "not first window" branch — the genuinely + # different code path from the test above. + var spy := _CountingNatureOverlay.new(v) + v._nature_overlay.queue_free() + v._nature_overlay = spy + v._canvas.add_child(spy) + v._window_request.request_now( + "GJ380c", Vector2i(10, 20), 2, AtlasWindowRequest.GRANULARITY_V2_QUARTER + ) + + 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 rung-swap" + + " response arrives" + ).is_greater(0) + + var quarter_window: Dictionary = _mock_window(Vector2i(10, 20), 2) + quarter_window["granularity_v2"] = "Quarter" + SimBridge.atlas_layers_received.emit(_mock_response("GJ380c", quarter_window)) + await get_tree().process_frame + + assert_that(v.get_district_window()).override_failure_message( + "sanity: the rung-swap response must actually have been adopted" + ).is_equal(quarter_window) + assert_int(spy.draw_count).override_failure_message( + "a RUNG-SWAP window arrival (a later _on_window_ready() call at a" + + " DIFFERENT granularity_v2 than entry) must ALSO redraw the nature" + + " overlay — draw_count must have advanced past the post-first-" + + " arrival baseline (%d)" % baseline + ).is_greater(baseline) + + # ============================================================================= # T-1120 capture-API parity (the ticket's explicit note: must survive here too) # ============================================================================= diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index e6f1657fe..6f00d33b1 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -22,6 +22,15 @@ extends RefCounted ## the SAME wrap/clamp discipline every other piece of this cluster already ## depends on, hence the preload below (no circular dependency: ## atlas_descend_geometry.gd never references this file). +## +## T-1170: the T-1156 wave-1 nature-overlay (river/basin/attractor) pixel +## mapping and per-rung visibility policy (RIVER_CLASS_*, layer1_pixel_to_*, +## *_visible_at_rung, zoom_compensated_size) moved OUT of this file to +## atlas_window_geometry_nature.gd (this file was at 954/1000 gdlint +## max-file-lines when the move happened) — see that file's own header doc. +## cell_index_for_local_offset() (T-1172, near the bottom of this file) stayed +## here since it is shared with AtlasWindowOverlay's terrain painter, a +## non-nature consumer. const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") ## D-243 rung spacings, metres/cell — the SAME constants @@ -108,93 +117,6 @@ 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 @@ -775,155 +697,6 @@ static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) 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) - - ## T-1172 round 2 (coordinator's "reconsider the split" ask): the SHARED ## cell-index formula both AtlasWindowOverlay's terrain painter (which builds ## the drawn `grid_side x grid_side` per-cell texture — `i = row * grid_side diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd new file mode 100644 index 000000000..9e8f5f8ff --- /dev/null +++ b/client/ui/implant/apps/atlas/atlas_window_geometry_nature.gd @@ -0,0 +1,685 @@ +extends RefCounted + +## Nature-overlay (river/basin/attractor) pure geometry + per-rung policy — +## split out of atlas_window_geometry.gd (T-1170, that file was at 954/1000 +## gdlint max-file-lines when this batch started) exactly the same way +## test_atlas_window_geometry_nature.gd was already split from +## test_atlas_window_geometry.gd — one file, one concern, room to grow. Every +## symbol below moved VERBATIM from atlas_window_geometry.gd; no behavior +## change in this split itself. atlas_window_nature_overlay.gd is the only +## runtime consumer (verified: grep across client/ before the move) and now +## preloads THIS file instead. +## +## Contains: +## - T-1156 wave 1 whole-body Layer-1 pixel-space -> canvas-local mapping +## (layer1_pixel_to_world_m/world_m_to_district/layer1_pixel_to_canvas_local) +## - T-1156 wave 1 per-rung skeleton visibility/styling policy (RIVER_CLASS_*, +## CONFLUENCES/MOUTHS/BASINS/ATTRACTORS_VISIBLE_BY_RUNG, dot/ring/attractor +## size consts) — RENAMED this batch (T-1170 Ruling 5c, see below) from +## RIVER_CLASS_VISIBLE_BY_RUNG to SKELETON_CLASS_VISIBLE_BY_RUNG. +## - zoom_compensated_size() — the screen-space marker-size zoom-compensation +## fix (coordinator live-eyeball finding, 2026-07-23). +## +## atlas_window_geometry.gd retains cell_index_for_local_offset() (T-1172 +## round 2) rather than moving it here — that function is shared with +## AtlasWindowOverlay's terrain painter (a non-nature consumer), so it stays +## on the base file both files already depend on, avoiding a nature-file -> +## base-file dependency for a symbol the base file's own painter needs too. +const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd") + +## atlas_window_geometry.gd never depends on this file (verified: no preload +## of atlas_window_geometry_nature.gd anywhere in that file) — so preloading +## it back here is safe, no circular dependency, matching the pattern +## AtlasWindowOverlay/AtlasWindowWaterClip already use for +## AtlasWindowGeometryRef. +const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +## D-243 district spacing, metres/district — this file's own copy of +## AtlasWindowGeometry.DISTRICT_SPACING_M (duplicated, not preloaded-and-read, +## matching this cluster's existing "each file owns its own reading of a +## small pure constant rather than force a dependency" precedent — +## atlas_overlay_colors.gd's header doc states this explicitly; the same +## rationale that kept atlas_window_water_clip.gd's cell_grid_side_for_window() +## a deliberate duplicate rather than a shared call applies here). MUST stay +## numerically identical to the base file's constant — both ultimately trace +## to D-243's 2,048 m district spacing, which is locked project vocabulary, +## not a value expected to drift. +const DISTRICT_SPACING_M: float = 2048.0 + + +# ============================================================================= +# T-1156 wave 1 / T-1170: 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). +# +# T-1170 Ruling 5c (Tyre, 2026-07-23) — THE REVISIT, split on the carrier +# axis: the single RIVER_CLASS_VISIBLE_BY_RUNG table is replaced by TWO +# tables, one per presentation surface — +# - SKELETON_CLASS_VISIBLE_BY_RUNG: the Region+ whole-body skeleton-chord +# path (Ruling 5a) — unchanged posture from wave 1, Region shows every +# class. +# - COURSE_CLASS_VISIBLE_BY_RUNG: the District/Quarter windowed course- +# polyline path (Ruling 5b) — THIS is where "Quarter rivers return" +# (the pre-announced wave-1 fade-down revisit executes): District shows +# trunk+tributary, Quarter shows all three classes. +# Companion per-class width/opacity tables (COURSE_CLASS_WIDTH_PX/ +# COURSE_CLASS_OPACITY) carry FUNCTIONAL DEFAULTS per the ruling brief +# (trunk widest ~2.2px, tributary ~1.4px, stream ~0.9px, screen-space via the +# existing zoom-compensation discipline) — Araminta's forthcoming presentation +# ruling edits THESE TABLES AND ONLY THESE TABLES, same single-revisit-point +# discipline wave 1 established for RIVER_CLASS_VISIBLE_BY_RUNG itself. +# ============================================================================= + +## 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 + + +# ============================================================================= +# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode — the wire +# convention `RiverNetwork.river_downstream` (Vec, index-aligned with +# river_cells) encodes per river cell: a DIRECTION 0-7 into an adjacent D8 +# neighbor, or a SENTINEL >= RIVER_DOWNSTREAM_SENTINEL_BASE marking a chain +# end (MOUTH/EDGE_DRAIN/reserved-TERMINAL). CONFIRMED against Dudley's A1 +# (server/src/atlas/drainage.rs:35-44, landed 0fea69feb, relayed by the +# coordinator) — these are the REAL shipped values, final until/unless the +# server's own encoding changes, in which case this is the one place to +# repoint. +# ============================================================================= + +## Sentinel base — any river_downstream value >= this is a chain-end +## sentinel, not a direction. Direction values are 0-7 (8 real D8 neighbors); +## sentinels start immediately above at 8. +const RIVER_DOWNSTREAM_SENTINEL_BASE: int = 8 +const RIVER_DOWNSTREAM_MOUTH: int = 8 +const RIVER_DOWNSTREAM_EDGE_DRAIN: int = 9 +## TERMINAL is reserved/unused in round 1 (Ruling 2c/7b — future endorheic +## basin support) — this client never expects to see it on real data yet, but +## decodes it identically to EDGE_DRAIN (chain end, no ring) rather than +## treating an unrecognized-but-in-sentinel-range value as an error, so a +## future server enabling TERMINAL needs no client change to degrade +## gracefully (it would just draw as an unmarked chain end until a future +## ticket gives it its own ring treatment, exactly EDGE_DRAIN's own current +## disposition). +const RIVER_DOWNSTREAM_TERMINAL: int = 10 + +## D8 direction index (0-7) -> (row_delta, col_delta), CONFIRMED against +## drainage.rs:35-44's own fdir table order (not assumed/guessed — the +## coordinator relayed this explicitly from Dudley's A1 source): row +## increases SOUTH (matching layer1_pixel_to_world_m()'s own "row 0 = north +## pole" convention, confirmed the same convention on both sides of this +## mapping), col increases EAST and WRAPS at the antimeridian (handled by the +## caller's existing nearest-wrap-image discipline, same as every other +## column value flowing through this file — this table itself has no wrap +## concept, it is pure grid-adjacency). +## 0 = N (-1, 0) 4 = NE (-1, 1) +## 1 = S ( 1, 0) 5 = NW (-1, -1) +## 2 = E ( 0, 1) 6 = SE ( 1, 1) +## 3 = W ( 0, -1) 7 = SW ( 1, -1) +const D8_DIRECTION_DELTAS: Array = [ + Vector2i(-1, 0), # 0 N + Vector2i(1, 0), # 1 S + Vector2i(0, 1), # 2 E + Vector2i(0, -1), # 3 W + Vector2i(-1, 1), # 4 NE + Vector2i(-1, -1), # 5 NW + Vector2i(1, 1), # 6 SE + Vector2i(1, -1), # 7 SW +] + +# ============================================================================= +# T-1170 Ruling 3h/5b: RiverCourse.terminus wire vocabulary — the +# CourseTerminus enum's variant NAMES as they arrive over msgpack (bare +# strings, the SAME "unit variant -> string tag" convention granularity_v2 +# already uses on this same wire — see AtlasMapProtocol's own doc). `None` on +# the Rust side (a mid-window course that neither reaches a real mouth nor +# the window edge — the ordinary "ends because the chord's amplitude taper +# reached zero at a confluence/headwater anchor cell inside this window" +## case) decodes to the bare string "None" per rmp_serde's unit-variant +## convention — NOT GDScript `null`. Callers must compare against the STRING +## constant below, never `== null`. +# ============================================================================= + +const COURSE_TERMINUS_NONE: String = "None" +const COURSE_TERMINUS_MOUTH: String = "Mouth" +const COURSE_TERMINUS_EDGE_DRAIN: String = "EdgeDrain" +const COURSE_TERMINUS_CONTINUES_BEYOND_WINDOW: String = "ContinuesBeyondWindow" + +## Region+ SKELETON path (Ruling 5a) — the whole-body chord-chain draw, built +## from river_downstream. Region shows every class (the full skeleton) — this +## table's posture is UNCHANGED from wave 1's original +## RIVER_CLASS_VISIBLE_BY_RUNG (renamed, not re-tuned). District/Quarter keys +## are retained (both empty) purely so a caller that queries this table by an +## unexpected rung tag gets the same documented "nothing visible" answer wave +## 1 shipped, rather than a KeyError — the SKELETON path itself is only ever +## drawn at Region in practice (District/Quarter draw courses, the OTHER +## table, per Ruling 5b). +const SKELETON_CLASS_VISIBLE_BY_RUNG: Dictionary = { + "Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], + "District": [], + "Quarter": [], +} + +## District/Quarter COURSE path (Ruling 5b/5c) — the windowed polyline draw, +## built from DistrictWindowLayer.courses. District: trunk+tributary (streams +## stay off at District — the ruling's own example enumeration). Quarter: +## ALL THREE classes — "Quarter rivers return", the pre-announced wave-1 +## fade-down revisit executing here. Region is not a key here at all (Region +## never draws courses — it draws the skeleton chord chain, the OTHER table) +## — a caller must not query this table at Region; river_class_visible_at_rung() +## style readers for this table live on this file too and fall back safely +## for an unrecognized tag (see course_class_visible_at_rung()'s own doc). +const COURSE_CLASS_VISIBLE_BY_RUNG: Dictionary = { + "District": [RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], + "Quarter": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK], +} + +## 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). +## +## T-1170: these RIVER_DOT_* consts now describe the Region SKELETON path +## ONLY (Ruling 5a's chord-chain draw reuses the same per-class radii the old +## dot-scatter used — chords are drawn at these widths, not a new table). +## RIVER_DOT_RADIUS_DISTRICT_TRUNK/RIVER_DOT_OPACITY_DISTRICT_TRUNK are DEAD +## at District now that District draws courses (Ruling 5b/3g retires the +## District dot-scatter entirely) — left in place, unread by any T-1170 draw +## path, rather than deleted mid-batch: B3 (course polyline drawing) is the +## change that stops calling them; deleting here would be a premature edit to +## a still-referenced-by-wave-1-code constant ahead of that landing. +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 + +## T-1170 Ruling 5c: course polyline per-class width/opacity, District/Quarter +## COURSE path companion tables to COURSE_CLASS_VISIBLE_BY_RUNG above. +## FUNCTIONAL DEFAULTS ONLY (the ruling's own numbers) — Araminta's +## forthcoming presentation ruling edits these two tables and only these two +## tables, same discipline as every other single-revisit-point table in this +## file. Widths are screen-space px at zoom=1.0, routed through +## zoom_compensated_size()/the caller's `_zs()` wrapper before reaching +## draw_polyline() exactly like every other marker size in this cluster (PR +## #195's stroke-width miss is the standing regression class this discipline +## exists to prevent — see zoom_compensated_size()'s own doc). Opacities are +## plain [0,1] alpha multipliers on COLOR_GEN_RIVER, no zoom involvement. +## Trunk widest / stream thinnest, matching the Region skeleton's own +## per-class radius ordering (RIVER_DOT_RADIUS_BY_CLASS_REGION) so the visual +## "trunk is the biggest river" read is consistent whether the player is +## looking at the Region chord chain or a District/Quarter course polyline. +const COURSE_CLASS_WIDTH_PX: Dictionary = { + RIVER_CLASS_STREAM: 0.9, + RIVER_CLASS_TRIBUTARY: 1.4, + RIVER_CLASS_TRUNK: 2.2, +} +const COURSE_CLASS_OPACITY: Dictionary = { + RIVER_CLASS_STREAM: 0.8, + RIVER_CLASS_TRIBUTARY: 0.9, + RIVER_CLASS_TRUNK: 1.0, +} + +## 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. T-1170: also +## the mouth-ring geometry for REAL course termini (Ruling 5b/3e) — one +## geometry, both presentation surfaces (skeleton chord ends at Region, +## course polyline ends at District/Quarter). +const MOUTH_RING_RADIUS: float = 5.0 +const MOUTH_HALO_RADIUS: float = 8.0 +const MOUTH_HALO_ALPHA: float = 0.30 + +## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): +## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow +## rather than degenerate into a solid blob — see +## zoom_compensated_ring_radius()'s own doc for the full A/B bracket +## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with +## margin over the observed transition). +const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0 + +## 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 + + +# ============================================================================= +# 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 in atlas_window_geometry.gd. 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 AtlasWindowGeometry.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 — see that const's +## own doc for why it's a deliberate duplicate, not a preload-and-read). +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 AtlasWindowGeometry.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 (AtlasWindowGeometry.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 AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) + + +## T-1170 Ruling 5b/3h: RiverCourse.points are already WORLD METRES on the +## wire (unlike the skeleton path's heightmap-pixel `river_cells` — see +## Ruling 3h's wire shape doc) — one fewer conversion step than +## layer1_pixel_to_canvas_local() above: world metres -> fractional district +## (world_m_to_district(), reused verbatim) -> canvas-local +## (AtlasWindowGeometry.district_to_canvas_local(), same shared transform +## every other drawn feature on this screen uses). No pixel-grid/body-radius +## step at all — courses have no heightmap-pixel domain to convert out of. +static func world_m_to_canvas_local( + world_m: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float +) -> Vector2: + var district: Vector2 = world_m_to_district(world_m) + return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size) + + +# ============================================================================= +# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode. +# ============================================================================= + + +## Decode one river cell's `river_downstream` wire value into its downstream +## neighbor's (row, col) heightmap-pixel position, or `null` if the value is +## a chain-end sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or an out-of-range/ +## malformed direction. `row`/`col` are the UPSTREAM cell's own pixel +## position (float, matching this file's own row/col domain everywhere +## else); the return value (when non-null) is a Vector2 in that SAME +## (row, col) pixel domain — NOT yet converted to world metres/district/ +## canvas-local, that conversion is the caller's job via the usual +## layer1_pixel_to_world_m()/world_m_to_district() pipeline, exactly as if +## the target were itself an entry read out of `river_cells`. +## +## Deliberately returns the RAW grid-adjacent position rather than looking it +## up in a `river_cells` array — a D8 downstream pointer always names a real +## adjacent grid cell by construction (that is what D8 flow direction means), +## whether or not that specific cell independently appears in whatever +## (possibly filtered) `river_cells` list the caller is iterating. +static func d8_downstream_target(row: float, col: float, downstream_raw: int) -> Variant: + if downstream_raw < 0 or downstream_raw >= RIVER_DOWNSTREAM_SENTINEL_BASE: + return null # sentinel or malformed — no real direction to decode + var delta: Vector2i = D8_DIRECTION_DELTAS[downstream_raw] + return Vector2(row + float(delta.x), col + float(delta.y)) + + +## T-1170 Ruling 5a — pure chord-chain CONSTRUCTION (no draw calls, no water +## clip, no canvas-local conversion): given `river_cells`/`river_class`/ +## `river_downstream` (the raw decoded river_network sub-dict arrays) and a +## `granularity_v2` rung tag, returns an Array of +## `{"from": Vector2, "to": Vector2, "cls": int}` dicts — one per river cell +## whose class is visible at this rung AND whose river_downstream pointer +## resolves to a real direction (not a sentinel, not out of range, not +## missing). `from`/`to` are in the SAME (row, col) heightmap-pixel domain +## `river_cells` entries themselves use — the caller converts to world +## metres/district/canvas-local and applies the water clip, exactly as if it +## had built this list inline (this function exists so that CONSTRUCTION is +## unit-testable without a live render pass — draw_line() itself requires +## one, per this cluster's own "pure function tests are the gate" draw-smoke +## caveat, so the chain-walking logic that actually decides WHICH segments +## exist must not be entangled with the draw call that paints them). +## +## Split out of AtlasWindowNatureOverlay._draw_skeleton_chords() specifically +## so a test can assert "this exact set of segments was constructed from +## this exact fixture" (including the sentinel-chain-end and malformed-input +## cases) without a SubViewport/render context — matching this file's +## existing "geometry/construction here, draw calls in the overlay node" +## split for every other piece of this cluster. +static func build_skeleton_chords( + river_cells: Array, river_class: Array, river_downstream: Array, granularity_v2: String +) -> Array: + var chords: Array = [] + 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 RIVER_CLASS_FALLBACK + if not skeleton_class_visible_at_rung(cls, granularity_v2): + continue + if idx >= river_downstream.size(): + continue # no downstream pointer for this cell yet — no segment + var downstream_raw: int = int(river_downstream[idx]) + var row: float = float(c[0]) + var col: float = float(c[1]) + var target: Variant = d8_downstream_target(row, col, downstream_raw) + if target == null: + continue # sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or malformed direction — chain end + chords.append({"from": Vector2(row, col), "to": target, "cls": cls}) + return chords + + +## T-1170 Ruling 5b/3h — pure course-polyline CONSTRUCTION (no draw calls): +## given one raw `RiverCourse` dict (as decoded off the wire — `class`, +## `points` (world-metres `[x,y]` pairs), `terminus` (a bare string tag)) and +## the window's own `granularity_v2`/`held_center`/`held_n`/`cell_pixel_size`, +## returns `null` if the course should not draw at all at this rung (class +## not visible, missing/degenerate points), or +## `{"canvas_pts": PackedVector2Array, "cls": int, "terminus": String}` +## ready for the caller to draw_polyline() + terminus-marker dispatch. +## +## Class defaults to RIVER_CLASS_FALLBACK (TRUNK) when missing, the same +## graceful-decode posture as the skeleton path's river_class fallback. +## `terminus` defaults to COURSE_TERMINUS_NONE when missing — an ordinary +## interior/no-marker ending, never crashing on an old/malformed payload. +## Malformed individual points are skipped (not fatal to the whole polyline, +## matching build_skeleton_chords()'s own "skip the bad entry, keep going" +## posture) — if fewer than 2 valid points remain after skipping, returns +## `null` (nothing to draw a line between). +static func build_course_render_plan( + course: Dictionary, + granularity_v2: String, + held_center: Vector2i, + held_n: int, + cell_pixel_size: float +) -> Variant: + var cls: int = int(course.get("class", RIVER_CLASS_FALLBACK)) + if not course_class_visible_at_rung(cls, granularity_v2): + return null + var points_raw: Variant = course.get("points") + if not points_raw is Array or (points_raw as Array).size() < 2: + return null + + var canvas_pts: PackedVector2Array = PackedVector2Array() + for pt: Variant in points_raw: + if not (pt is Array and pt.size() >= 2): + continue # malformed point — skip it, don't fail the whole polyline + var world_m := Vector2(float(pt[0]), float(pt[1])) + canvas_pts.append(world_m_to_canvas_local(world_m, held_center, held_n, cell_pixel_size)) + if canvas_pts.size() < 2: + return null # too many malformed points left too few to draw a line + + var terminus: String = str(course.get("terminus", COURSE_TERMINUS_NONE)) + return {"canvas_pts": canvas_pts, "cls": cls, "terminus": terminus} + + +# ============================================================================= +# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility policy READERS. +# The policy TABLES themselves live up in the top-of-file const block per +# class-definitions-order. +# ============================================================================= + + +## Whether a river cell of `river_class` should draw on the Region+ SKELETON +## path (Ruling 5a) 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 skeleton_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: + var visible: Array = SKELETON_CLASS_VISIBLE_BY_RUNG.get( + granularity_v2, SKELETON_CLASS_VISIBLE_BY_RUNG["Region"] + ) + return visible.has(river_class) + + +## Whether a river class should draw on the District/Quarter COURSE path +## (Ruling 5b) at `granularity_v2`. No Region key exists in +## COURSE_CLASS_VISIBLE_BY_RUNG (Region never draws courses) — an unrecognized +## OR Region tag both fall back to an EMPTY array (nothing visible), the +## inverse fallback posture from skeleton_class_visible_at_rung() above, +## deliberately: falling back to "show everything" for a course-path query at +## an unexpected rung would risk drawing course polylines at Region, which no +## window response ever carries (courses are windowed-only content, Ruling 1) +## — failing to EMPTY is the safe direction on this specific table. +static func course_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool: + var visible: Array = COURSE_CLASS_VISIBLE_BY_RUNG.get(granularity_v2, []) + 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)) + + +## Per-class course polyline width (screen-space px, zoom=1.0 domain — see the +## const's own doc). Falls back to the stream (thinnest) width for an +## unrecognized class id, matching RIVER_DOT_RADIUS_BY_CLASS_REGION's own +## `.get(cls, 2.2)` call-site fallback shape on the skeleton side (there the +## fallback is trunk/widest — the caller passes a literal default; here the +## table itself owns a documented fallback since this is a NAMED reader, not +## an inline `.get()`). +static func course_class_width_px(river_class: int) -> float: + return float(COURSE_CLASS_WIDTH_PX.get(river_class, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM])) + + +## Per-class course polyline opacity multiplier on COLOR_GEN_RIVER. Same +## unrecognized-class fallback posture as course_class_width_px() above. +static func course_class_opacity(river_class: int) -> float: + return float(COURSE_CLASS_OPACITY.get(river_class, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM])) + + +## 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, T-1170 course polylines/chords) 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) + + +## T-1170 live-round finding (2026-07-23, coordinator/Araminta's pixel-scan +## of the course captures — D-district-courses.png/Q-quarter-courses.png +## showed a UNIFORM 1px hairline for the entire course, no width/opacity +## variation at all): zoom_compensated_size() is correct arithmetic (verified: +## 2.2 / 3.75 = 0.5867, and 0.5867 * 3.75 round-trips to 2.2 exactly — the +## compensation MATH has never been the bug), but it has NO FLOOR against +## Godot's own STROKE-WIDTH rasterizer minimum — confirmed empirically via a +## live A/B bracket (temporary instrumentation, since reverted): draw_line()/ +## draw_polyline() called with a width in [0.6, 1.0) canvas-local units +## renders as a flat 1px hairline REGARDLESS of the input value, identically +## on both APIs (ruling out a draw_polyline()-specific quirk) — Godot's line +## rasterizer treats any width below ~1.0 the same as its historical +## width=-1.0 "hairline" sentinel, rather than continuing to shrink the +## antialiased stroke sub-pixel the way draw_circle()'s radius parameter +## does (mouth rings at the SAME District/Quarter zoom levels render +## correctly-sized — confirmed, radii have no equivalent floor). +## +## District/Quarter fit zooms (3.75/7.5+, and the player can zoom further +## within a rung) divide COURSE_CLASS_WIDTH_PX's 0.9-2.2px table values down +## to 0.12-0.59 canvas-local units — BELOW the 1.0 floor — so every course +## class collapses to the identical hairline the moment view_zoom exceeds +## roughly `screen_space_size` itself. This is the STROKE-WIDTH-SPECIFIC +## sibling of zoom_compensated_size() (which remains correct and unchanged +## for radii/point sizes, its own existing floor is a divide-by-zero guard +## only, not a rasterizer-minimum guard) — a SEPARATE function because the +## two draw families have genuinely different Godot-side minimums, not a +## single shared bug. +## +## The fix clamps the OUTPUT to a 1.0 canvas-local-unit floor — the closest +## representable value to "as thin as Godot's rasterizer can actually draw a +## non-hairline stroke" — rather than letting the divide produce a +## sub-floor value that Godot silently reinterprets as hairline anyway. This +## is an honest floor, not a workaround: below it, EVERY value (0.373, 0.6, +## 0.9999...) already rendered identically as hairline before this fix, so +## clamping to exactly 1.0 changes nothing about what could already be drawn +## at that zoom — it only stops different classes/rungs from silently +## collapsing to the SAME wrong result and starts drawing the class/opacity +## variation the ruling specifies. At extreme zoom-in (small view_zoom +## relative to the literal px value) the floor never engages — the same +## divide-then-scale math takes over exactly as design intends, matching +## zoom_compensated_size()'s own behavior at Region's tiny fit zoom. +static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float: + return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0) + + +## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the +## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to +## zoom_compensated_size()/zoom_compensated_stroke_width(), needed +## specifically for draw_arc() RING markers (mouth rings — the only +## draw_arc() caller in this file whose radius and stroke width are BOTH +## small, zoom-compensated values that can cross each other). +## +## Root-cause evidence (live A/B bracket, temporary instrumentation, since +## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver): +## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE, +## already-correct-code issue — the terminus point legitimately sits near +## the requesting window's own edge, and the fit-and-center COVER strategy +## crops that edge off the visible viewport (screen_p verified computed as +## (1755, -191) against a 1920x1080 frame — above the top edge, not a +## drawing bug). Panning the view to re-center the SAME point (verified via +## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a +## SOLID BLOB, not a hollow ring, at the production radius/stroke pair +## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5): +## draw_arc()'s stroke, centered ON the radius circle, extends inward past +## the circle's own center once stroke exceeds ~1x the radius, filling the +## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied): +## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) -> +## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring +## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob +## persists past the naive geometric threshold (radius > stroke/2, where an +## infinitely-thin/perfectly-antialiased ring would already have a hole) +## because draw_arc()'s low tessellation (18 points, this file's own call) +## plus antialiasing blur eat into the theoretical hole at these tiny +## absolute magnitudes — an empirical floor, not a derived one, chosen with +## margin over the observed 1.0x-blob/1.5x-hollow transition rather than +## shaving the boundary exactly. +## +## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER` +## (2.0, the top-of-file const — verified clean in the bracket above) +## whenever the naive zoom-compensated radius would fall below it — the +## same "floor the OUTPUT, never let a sub-threshold value reach Godot's +## renderer" pattern zoom_compensated_stroke_width() already established, +## applied to the paired radius/stroke relationship a lone-value floor +## can't express (unlike the stroke-width floor, this one's threshold is +## RELATIVE to another draw-time value, not an absolute constant). At every +## zoom where the naive radius already clears the floor on its own +## (Region's dot radii, or any District/Quarter case wide enough), this is +## an exact no-op — identical to calling zoom_compensated_size() directly. +static func zoom_compensated_ring_radius( + screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float +) -> float: + var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom) + return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER) diff --git a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd index aba4ee391..82998c9f4 100644 --- a/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_nature_overlay.gd @@ -6,16 +6,37 @@ extends Node2D ## and below UI chrome — same parent, same pan/zoom transform, drawn after so ## river dots/basin fills sit on top of the terrain colorizer. ## +## T-1170 (Ruling 5a): the Region+ river dot-scatter upgraded to CONNECTED +## STRAIGHT CHORDS via river_downstream (_draw_skeleton_chords()) — per +## Ruling 3b this chord chain IS the rung-truncated course at Region +## truncation, not an approximation of it. District/Quarter no longer draw +## the (now-retired) dot-scatter at all; they draw windowed course polylines +## instead (Ruling 5b, _draw_courses()). +## +## T-1170 Ruling 5b (B3): course polylines ride `DistrictWindowLayer.courses` +## — a SEPARATE data source from `_layer1` above (courses arrive on the +## WINDOWED response, `viewer.get_district_window()`, not the whole-body +## Layer-1 response this node requests via request_layer1()). _draw() is +## therefore two INDEPENDENT gates, not one: the skeleton/basin/attractor +## path gates on `_layer1 != null` (unchanged); the course path gates on +## `viewer.get_district_window()` being a Dictionary with a `courses` key, +## entirely independent of whether Layer-1 has arrived yet — a player who +## descends straight to District without the whole-body fetch completing +## still sees courses the moment the window arrives. NO T-1172 water clip on +## this path (Ruling 3g) — courses carry real rung-consistent termini +## server-side (the whole POINT of windowing course invention, Ruling 1d). +## ## 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 +## The drawing IDEAS survive (polygon basins, glyph-free double-ring mouths, +## draw order basins-under-rivers-under-attractors; the dot-scatter idea +## itself is superseded at Region by T-1170's chord chain, see above); 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 +## (AtlasWindowGeometryNature.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. ## @@ -59,6 +80,9 @@ extends Node2D ## this node never needs a _process() self-heal. const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") +# T-1170: the nature-overlay pixel-mapping + per-rung visibility policy split +# out of atlas_window_geometry.gd — see that file's own doc. +const AtlasWindowGeometryNature := preload("res://ui/implant/apps/atlas/atlas_window_geometry_nature.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") # T-1172: two-waterline clip — see that file's own header doc. @@ -142,8 +166,24 @@ func get_layer1() -> Variant: return _layer1 +## T-1170 (B3): TWO INDEPENDENT draw gates, not one — see the class doc's own +## "two independent gates" paragraph. The skeleton/basin/attractor path +## (Layer-1, whole-body) is unchanged from wave 1; the course path (windowed, +## Ruling 5b) is a SEPARATE early-return chain reaching _draw_courses(), +## checked regardless of whether `_layer1` has arrived — a player descending +## straight into District/Quarter must see courses without waiting on the +## whole-body Layer-1 fetch this node happens to also own. func _draw() -> void: - if viewer == null or _layer1 == null: + if viewer == null: + return + _draw_skeleton_path() + _draw_course_path() + + +## The pre-T-1170 draw gate, unchanged in shape: whole-body Layer-1 +## (rivers/basins/attractors), gated on `_layer1` having arrived. +func _draw_skeleton_path() -> void: + if _layer1 == null: return var rn: Variant = _layer1.get("river_network") if not rn is Dictionary: @@ -176,22 +216,114 @@ func _draw() -> void: # 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. + # AtlasWindowGeometryNature.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( + if AtlasWindowGeometryNature.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( + if AtlasWindowGeometryNature.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible( "gen_attractors" ): _draw_attractors(ctx) +## T-1170 Ruling 5b (B3): the District/Quarter windowed COURSE path — an +## entirely separate data source (`viewer.get_district_window()`) and draw +## gate from _draw_skeleton_path() above. Gated on `gen_rivers` (the SAME +## overlay-bar toggle the skeleton path uses — one player-facing "rivers" +## toggle covers both presentation surfaces, matching Araminta's ruling that +## the two paths are one continuous feature from the player's perspective, +## not two separate layers to independently show/hide). +## +## Region NEVER reaches this function's draw calls (course_class_visible_at_ +## rung() has no Region key, always false there — Region draws the skeleton +## chord chain, never windowed course content, per Ruling 1). Tile mode +## (the Region orbital mosaic) also never carries `district_window` data at +## all (get_district_window() is single-window-mode-only, per that +## accessor's own doc — courses simply never reach this path in tile mode by +## construction, no separate is_tile_mode() guard needed here). +func _draw_course_path() -> void: + if not viewer.is_overlay_visible("gen_rivers"): + return + var window: Variant = viewer.get_district_window() + if not window is Dictionary: + return + var w: Dictionary = window + var courses: Variant = w.get("courses") + if not courses is Array: + return # missing `courses` field (old/pre-A2 payload) — draw nothing, see class doc + var granularity_v2: String = str(w.get("granularity_v2", "District")) + var ctx := { + "held_center": viewer.get_held_center(), + "held_n": viewer.get_held_n(), + "cell_px": viewer.get_cell_pixel_size(), + "granularity_v2": granularity_v2, + "view_zoom": viewer.get_view_zoom(), + } + + for course: Variant in courses: + if not course is Dictionary: + continue + _draw_one_course(course, ctx) + + +## T-1170 Ruling 5b: one RiverCourse's polyline draw — class-filtered per +## COURSE_CLASS_VISIBLE_BY_RUNG, width/opacity from the COURSE_CLASS_WIDTH_PX/ +## COURSE_CLASS_OPACITY companion tables (zoom-compensated via _zs(), the SAME +## discipline every other marker in this cluster follows — PR #195's +## stroke-width miss is the standing regression class this exists to +## prevent). Points arrive as `Vec<(i32,i32)>` WORLD METRES (Ruling 3h, NOT +## heightmap pixels — see world_m_to_canvas_local()'s own doc for why this is +## one conversion step shorter than the skeleton path). NO T-1172 water +## clip on this path (Ruling 3g) — courses carry real rung-consistent +## termini server-side; that is the entire point of windowing course +## invention (Ruling 1d). +## +## Terminus handling (Ruling 3h's CourseTerminus vocabulary): +## - Mouth: double-ring at the LAST point (the real coast anchor — mouths +## return as real geometry here, per Ruling 3g/3e). +## - EdgeDrain: no ring (Ruling 3f — pole-edge drains are grid artifacts, +## not river-meets-sea events; same disposition as the skeleton path's +## EDGE_DRAIN sentinel). +## - ContinuesBeyondWindow: draw to the last point, no marker (the course +## keeps going outside this window's crop — nothing to mark AT this +## window's edge, the polyline simply stops because the data stops). +## - None: an ordinary interior terminus (a headwater/confluence anchor +## inside this window) — no marker, same as ContinuesBeyondWindow's "just +## stop drawing" treatment; the two differ in MEANING (why the points ran +## out) but not in PRESENTATION (neither gets a ring). +## The actual gating/construction (class visibility, point decode, terminus +## lookup) is delegated to AtlasWindowGeometryNature.build_course_render_plan() +## — a pure function with no draw calls, unit-tested directly in +## test_atlas_window_geometry_nature.gd, the SAME split B2's +## build_skeleton_chords() already established. This function's own job is +## just the draw calls the plan feeds. +func _draw_one_course(course: Dictionary, ctx: Dictionary) -> void: + var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan( + course, ctx["granularity_v2"], ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + if plan == null: + return + var canvas_pts: PackedVector2Array = plan["canvas_pts"] + var cls: int = plan["cls"] + var terminus: String = plan["terminus"] + + var width: float = _zs_stroke(AtlasWindowGeometryNature.course_class_width_px(cls), ctx) + var opacity: float = AtlasWindowGeometryNature.course_class_opacity(cls) + var color := Color(COLOR_GEN_RIVER.r, COLOR_GEN_RIVER.g, COLOR_GEN_RIVER.b, COLOR_GEN_RIVER.a * opacity) + draw_polyline(canvas_pts, color, width) + + if terminus == AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH: + _draw_mouth(canvas_pts[canvas_pts.size() - 1], ctx) + # EdgeDrain/ContinuesBeyondWindow/None: no marker — draw to the last + # point and stop, per the doc above. + + ## 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 @@ -204,12 +336,48 @@ func _cols_for_wrap(radius_km: float) -> int: ## 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. +## AtlasWindowGeometryNature.zoom_compensated_size() (see that function's own +## doc for the "why divide" rationale). Every draw_circle()/draw_arc() RADIUS +## in this file routes through this so Araminta's "constant on-screen size" +## ruling holds at every rung/zoom. NOT for stroke widths — see _zs_stroke() +## below, added T-1170 live round (2026-07-23) after the course-polyline +## hairline finding: draw_line()/draw_polyline() STROKE WIDTH arguments have +## a Godot-side rasterizer floor radii don't share (confirmed empirically — +## zoom_compensated_stroke_width()'s own doc has the full A/B evidence). func _zs(screen_space_size: float, ctx: Dictionary) -> float: - return AtlasWindowGeometry.zoom_compensated_size(screen_space_size, ctx["view_zoom"]) + return AtlasWindowGeometryNature.zoom_compensated_size(screen_space_size, ctx["view_zoom"]) + + +## T-1170 live round (2026-07-23): the STROKE-WIDTH-specific sibling of +## _zs() — every draw_line()/draw_polyline()/draw_arc() STROKE WIDTH +## argument (never a radius/point-size) in this file routes through this +## instead of _zs(), so the width never crosses Godot's ~1.0-canvas-local- +## unit line-rasterizer floor and silently collapses to an +## indistinguishable hairline. See +## AtlasWindowGeometryNature.zoom_compensated_stroke_width()'s own doc for +## the full live-repro evidence (the course-path pixel scan that found this: +## a uniform 1px hairline with zero class/width variation in both District +## and Quarter captures). +func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float: + return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"]) + + +## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the +## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring +## marker whose radius and stroke are BOTH small, zoom-compensated values +## (currently: the two _draw_mouth() rings) routes its RADIUS through this +## instead of plain _zs(), so the radius never falls at-or-below its own +## paired stroke width and degenerates from a hollow ring into a solid blob. +## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for +## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow, +## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY +## zoom-compensated stroke value (this function's own caller passes +## _zs_stroke()'s result, not a raw screen-space width) — the floor compares +## against the SAME canvas-local units the naive radius divide produces. +func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float: + return AtlasWindowGeometryNature.zoom_compensated_ring_radius( + screen_space_radius, stroke_canvas_local, ctx["view_zoom"] + ) ## Pixel (row, col) -> fractional district position, wrap-resolved against @@ -225,10 +393,10 @@ func _zs(screen_space_size: float, ctx: Dictionary) -> float: ## periodic — any wrap-image of the same district resolves to the same ## real-world position). func _district(row: float, col: float, ctx: Dictionary) -> Vector2: - var world_m: Vector2 = AtlasWindowGeometry.layer1_pixel_to_world_m( + var world_m: Vector2 = AtlasWindowGeometryNature.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 district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m) var cols: int = ctx["cols"] if cols > 0: var held_center: Vector2i = ctx["held_center"] @@ -285,50 +453,30 @@ func _is_drawn_water(district: Vector2, ctx: Dictionary) -> bool: return AtlasOverlayColors.is_morphology_water(zone) -## T-1172 clip — retire when T-1170 course invention terminates courses at -## the invented coast. River cells, confluences, and mouths are each dropped -## (strict, no snap) when their resolved composite cell reads as drawn water -## — see AtlasWindowWaterClip's own header doc for the two-waterline -## rationale. Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched. +## T-1172 clip — RETAINED for this Region-skeleton path only (Ruling 3g: the +## clip retires for the District/Quarter COURSE-drawing rungs — see +## _draw_courses() below — because courses carry real rung-consistent +## termini and the clip's job is done there; the Region skeleton path keeps +## drawing against a rung-dependent drawn coast and needs the presentation- +## frame reconciliation until Region itself goes windowed, T-1143 ruling 2). +## River cells, confluences, and mouths are each dropped (strict, no snap) +## when their resolved composite cell reads as drawn water — see +## AtlasWindowWaterClip's own header doc for the two-waterline rationale. +## Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched. +## +## T-1170 Ruling 5a: at Region+, river cells draw as CONNECTED STRAIGHT +## CHORDS (each river cell to its river_downstream neighbor) instead of a +## dot-scatter — see _draw_skeleton_chords() below, called from here. +## District/Quarter no longer reach this function's river-cell/confluence +## loop at all (SKELETON_CLASS_VISIBLE_BY_RUNG has empty District/Quarter +## entries) — they draw via _draw_courses() instead (Ruling 5b), wired from +## _draw(). 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 district: Vector2 = _district(float(c[0]), float(c[1]), ctx) - # T-1172 clip — retire when T-1170 course invention terminates at the invented coast. - if _is_drawn_water(district, ctx): - continue - var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( - district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] - ) - 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) + _draw_skeleton_chords(rn, ctx) - if AtlasWindowGeometry.confluences_visible_at_rung(granularity_v2): + if AtlasWindowGeometryNature.confluences_visible_at_rung(granularity_v2): for cf: Variant in rn.get("confluences", []): if cf is Array and cf.size() >= 2: var district: Vector2 = _district(float(cf[0]), float(cf[1]), ctx) @@ -337,10 +485,10 @@ func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] ) - var radius: float = _zs(AtlasWindowGeometry.RIVER_CONFLUENCE_RADIUS_REGION, ctx) + var radius: float = _zs(AtlasWindowGeometryNature.RIVER_CONFLUENCE_RADIUS_REGION, ctx) draw_circle(p, radius, COLOR_GEN_RIVER) - if AtlasWindowGeometry.mouths_visible_at_rung(granularity_v2): + if AtlasWindowGeometryNature.mouths_visible_at_rung(granularity_v2): for m: Variant in rn.get("mouths", []): if m is Array and m.size() >= 2: var district: Vector2 = _district(float(m[0]), float(m[1]), ctx) @@ -357,18 +505,161 @@ func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void: _draw_mouth(p, ctx) +## T-1170 Ruling 5a — the Region+ skeleton-chord draw: each river cell whose +## class is visible at this rung draws a STRAIGHT LINE SEGMENT to its +## `river_downstream` neighbor. `river_network.river_downstream` is a u8 PER +## RIVER CELL (index-aligned with river_cells, the SAME alignment convention +## river_class already uses) encoding a **D8 DIRECTION** (0-7, see +## AtlasWindowGeometryNature.D8_DIRECTION_DELTAS — NOT a river_cells index; +## the target cell's grid position is `c + delta`, decoded via +## AtlasWindowGeometryNature.d8_downstream_target()), with SENTINEL values +## `>= RIVER_DOWNSTREAM_SENTINEL_BASE` for MOUTH/EDGE_DRAIN/reserved-TERMINAL +## (Ruling 2c). Direction-index convention and sentinel values CONFIRMED +## against Dudley's A1 (server/src/atlas/drainage.rs:35-44, landed +## 0fea69feb; relayed by the coordinator, not guessed) — MOUTH=8, +## EDGE_DRAIN=9, TERMINAL=10 (reserved/unused), directions 0-7 = N/S/E/W/NE/ +## NW/SE/SW. Every place this convention is encoded is a SINGLE named +## constant group on AtlasWindowGeometryNature (D8_DIRECTION_DELTAS / +## RIVER_DOWNSTREAM_SENTINEL_BASE / RIVER_DOWNSTREAM_MOUTH / +## RIVER_DOWNSTREAM_EDGE_DRAIN / RIVER_DOWNSTREAM_TERMINAL) — see that file's +## own doc. +## +## Per Ruling 3b, these chords ARE the rung-truncated course at Region (no +## octave warp survives at Region spacing — the invented course degenerates +## exactly to this chord), NOT an approximation of it — one function (the +## server's course inventor, eventually), every rung, this is simply what it +## looks like with zero surviving octaves. +## +## Sentinel dispositions: MOUTH and EDGE_DRAIN both END the chain — no +## downstream segment is drawn for a sentinel-terminated cell (there is no +## real neighbor cell to connect to). EDGE_DRAIN gets NO mouth ring (Ruling +## 3f — pole-edge drains are grid artifacts, not river-meets-sea events; the +## existing mouths array/_draw_mouth() call in _draw_rivers() is already +## scoped to real MOUTH sentinels via rn["mouths"], server-side, per Ruling +## 3f's "extract_river_network stops classifying grid-edge exits into +## mouths" — this function draws NO ring at all, sentinel or otherwise, that +## is _draw_rivers()'s mouths-array job). +## +## `river_downstream` missing or shorter than `river_cells` (pre-T-1170 +## payload — Dudley's `#[serde(default)]` empty-Vec contract, the exact same +## graceful-decode shape river_class already established) means NO chord +## segment can be drawn for that index at all (there is no real downstream +## direction to connect to, unlike the class-fallback case where TRUNK is a +## safe visual default) — those cells draw NOTHING at Region until the field +## arrives, a graceful (not crashing) degradation, documented here rather +## than silently falling back to the old dot-scatter (which would require +## carrying that whole second code path forward past this ticket). The +## decoded target cell is ALSO not required to appear in `river_cells` itself +## (the chord draws to the raw grid position `c + delta`, not to a looked-up +## river-cell entry) — a downstream D8 pointer always names a real adjacent +## grid cell by construction, whether or not that cell independently made it +## into the (possibly rung/threshold-filtered) `river_cells` list. +## +## The actual chain-CONSTRUCTION (which segments exist at all, given the +## fixture and rung) is delegated to +## AtlasWindowGeometryNature.build_skeleton_chords() — a pure function with +## no draw calls, unit-tested directly in +## test_atlas_window_geometry_nature.gd (the sentinel/malformed/visibility +## cases). This function's own job is the remaining per-segment work that DOES +## need the overlay's own state: the water clip (_segment_touches_drawn_water(), +## needs the composite/tile data only the overlay holds) and the actual +## draw_line() call (needs a live render pass). +func _draw_skeleton_chords(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 river_downstream: Array = rn.get("river_downstream", []) + + var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords( + river_cells, river_class, river_downstream, granularity_v2 + ) + for chord: Dictionary in chords: + var from_rc: Vector2 = chord["from"] + var to_rc: Vector2 = chord["to"] + var cls: int = chord["cls"] + + var from_district: Vector2 = _district(from_rc.x, from_rc.y, ctx) + var to_district: Vector2 = _district(to_rc.x, to_rc.y, ctx) + # T-1172 clip (Region-only, retained per Ruling 3g): a segment is + # clipped when EITHER endpoint OR its midpoint resolves to drawn + # water — see _segment_touches_drawn_water()'s own doc for why this + # three-point rule was chosen over an endpoints-only test. + if _segment_touches_drawn_water(from_district, to_district, ctx): + continue + var from_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + from_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + var to_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + to_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"] + ) + var width: float = AtlasWindowGeometryNature.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2) + draw_line(from_p, to_p, COLOR_GEN_RIVER, _zs_stroke(width, ctx)) + + +## T-1172 clip rule for a CHORD SEGMENT (as opposed to a single point, which +## is what the pre-T-1170 dot-scatter clipped): tested at the segment's TWO +## ENDPOINTS AND its MIDPOINT, clipping the whole segment if ANY of those +## three samples resolves to drawn water. **Decision, documented per the +## ruling's ask ("pick the visually cleaner rule, document it, test it"):** +## endpoints-only was rejected because a chord that DIPS through a coastal +## composite cell without either endpoint landing in it (a river cell just +## inland connecting to a river cell just inland on the OTHER side of a +## narrow drawn-water inlet/bay) would draw a visible line segment crossing +## open water with neither end clipped — worse than the old dot-scatter's +## per-point clip, which never had this failure mode since a dot has no +## extent to cross anything. Midpoint-only was rejected symmetrically: a +## long chord whose midpoint happens to land on drawn land while both real +## endpoints sit in drawn water would draw an uncllipped segment starting and +## ending in the ocean. Three-point (both ends + midpoint) catches the +## common cases of both failure modes at negligible extra cost (one more +## _is_drawn_water() lookup per segment) without requiring a full +## segment-rasterization walk — chords at Region spacing (~76 km apart) are +## short enough relative to Region's own 204.8 km composite cell that a +## single midpoint sample is a reasonable proxy for "does this segment pass +## through this cell", matching the coarseness the Region rung already draws +## at everywhere else in this file (204.8 km cells, not sub-cell precision). +func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2, ctx: Dictionary) -> bool: + if _is_drawn_water(from_district, ctx): + return true + if _is_drawn_water(to_district, ctx): + return true + var mid_district: Vector2 = (from_district + to_district) * 0.5 + return _is_drawn_water(mid_district, 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). +## every rung it's visible at (Region, District; never Quarter). T-1170: +## also the marker for REAL course termini (Ruling 5b/3e) — one geometry +## function, both presentation surfaces. func _draw_mouth(p: Vector2, ctx: Dictionary) -> void: + var ring_stroke: float = _zs_stroke(1.5, ctx) draw_arc( - p, _zs(AtlasWindowGeometry.MOUTH_RING_RADIUS, ctx), 0.0, TAU, 18, COLOR_GEN_MOUTH, _zs(1.5, ctx) + p, + _zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx), + 0.0, + TAU, + 18, + COLOR_GEN_MOUTH, + ring_stroke ) var halo := Color( - COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, AtlasWindowGeometry.MOUTH_HALO_ALPHA + COLOR_GEN_MOUTH.r, + COLOR_GEN_MOUTH.g, + COLOR_GEN_MOUTH.b, + AtlasWindowGeometryNature.MOUTH_HALO_ALPHA + ) + var halo_stroke: float = _zs_stroke(1.0, ctx) + draw_arc( + p, + _zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx), + 0.0, + TAU, + 22, + halo, + halo_stroke ) - 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 + @@ -394,7 +685,7 @@ func _draw_basins(ctx: Dictionary) -> void: 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) + draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs_stroke(0.8, ctx), true) ## Attractors — Region only, wave 1 (per the ruling; District/Quarter never @@ -410,7 +701,7 @@ func _draw_attractors(ctx: Dictionary) -> void: if not a is Dictionary: continue var strength: float = float(a.get("strength", 0.0)) - if strength < AtlasWindowGeometry.ATTRACTOR_MIN_STRENGTH: + if strength < AtlasWindowGeometryNature.ATTRACTOR_MIN_STRENGTH: continue var pos_rc: Variant = a.get("position") if not pos_rc is Array or pos_rc.size() < 2: @@ -418,7 +709,7 @@ func _draw_attractors(ctx: Dictionary) -> void: 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)) + _draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs_stroke(1.0, ctx)) ## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index f89801044..54838b898 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -504,6 +504,7 @@ func _on_window_ready(window: Dictionary) -> void: _legend_panel.refresh() queue_redraw() _overlay_node.queue_redraw() + _nature_overlay.queue_redraw() # T-1170 B3: courses read THIS window too ## T-1153 (design doc §4 "progressive... with visible refinement as tiles diff --git a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd b/client/ui/implant/apps/atlas/atlas_window_water_clip.gd index acb5e699f..2fd654a99 100644 --- a/client/ui/implant/apps/atlas/atlas_window_water_clip.gd +++ b/client/ui/implant/apps/atlas/atlas_window_water_clip.gd @@ -14,9 +14,21 @@ extends RefCounted ## clip against whichever composite cell is currently ON SCREEN at a given ## river dot's position — strict drop, no snap (a dot that lands on drawn ## water is simply not drawn; Region's 205 km cells may amputate a river's -## final coastal dots, an accepted cost per the ruling). T-1172 clip — -## retire when T-1170 course invention terminates courses at the invented -## coast. +## final coastal dots, an accepted cost per the ruling). +## +## T-1170 Ruling 3g update (RESTRUCTURED, not blanket-retired): the clip is +## RETIRED for the District/Quarter COURSE-drawing rungs +## (AtlasWindowNatureOverlay._draw_course_path()/_draw_one_course()) — +## courses carry real rung-consistent termini invented server-side against +## the SAME rung's drawn coast, so the clip's job is already done there. This +## file's clip machinery is STILL LIVE and used at the Region SKELETON path +## (_draw_skeleton_chords()/_segment_touches_drawn_water()) — Region still +## draws the whole-body skeleton against a rung-dependent drawn coast, which +## is precisely the presentation-frame reconciliation this file exists for. +## The Region clip is PERMANENT-UNTIL-REGION-GOES-WINDOWED (T-1143 ruling 2's +## progressive tiling) — when Region itself becomes a windowed rung, it +## inherits windowed courses too, and this file retires entirely at that +## point, not before. ## ## const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd") diff --git a/docs/architecture/atlas-zoom-ladder-t1143.md b/docs/architecture/atlas-zoom-ladder-t1143.md index 2f5ddfa4e..df2f3ce72 100644 --- a/docs/architecture/atlas-zoom-ladder-t1143.md +++ b/docs/architecture/atlas-zoom-ladder-t1143.md @@ -156,6 +156,20 @@ Release build, this session, body GJ338Bd, seed "yolo", 16 cores / Rayon pool 14 | Octave-cutoff derive (`min_wavelength_m`-bearing) | — | not measured | **UNBUILT, UNMEASURED** — every planetary-rung and canvas-sampling latency claim resting on "cheaper because fewer octaves" is an unmeasured extrapolation on top of code that doesn't exist yet | | Full-canvas planetary sample, 1920×1080 (~2.07M cells) | — | ~2.6–3.0 s single-thread; ~0.2–0.3 s parallel (claimed) | **ESTIMATED, UNVERIFIED** — depends on both the unbuilt cutoff and the unbenchmarked parallel throughput above; treat as directional only | +> **UNMEASURED gap closed (2026-07-23, T-1170 A2 discipline item 1 — Dudley).** `zoom_ladder_bench.rs`'s three `#[ignore]`d benches (dormant since T-1149/T-1152) were run `--release --ignored` on this session's hardware (16 cores, same class of machine as the §7 table above) before the T-1170 course inventor landed, per the batch's own discipline requirement. Numbers below are **MEASURED**, not extrapolated — this table row is retired as UNBUILT/UNMEASURED for the octave-cutoff case specifically (the cutoff plumbing has existed since T-1162; what was missing was ever actually running the bench): +> +> | Sweep (64×64 = 4,096 cells, release) | Total | ns/cell | µs/cell | +> |---|---|---|---| +> | District spacing (2,048 m), cutoff=0 (uncut) | 12.33 ms | 3,009.4 | 3.009 | +> | District spacing (2,048 m), cutoff=2,048 m | 7.31 ms | 1,784.7 | 1.785 | +> | Quarter spacing (512 m), cutoff=512 m | 7.47 ms | 1,822.9 | 1.823 | +> | Orbital (`derive_orbital_at_metres`), Region spacing (204.8 km) | 5.96 ms | 1,454.2 | 1.454 | +> | Full `derive_at_metres` (invention included) at Region spacing, for comparison | 11.18 ms | 2,729.4 | 2.729 | +> | Orbital speedup vs. full derive at the same (Region) spacing | — | — | **1.88×** | +> | Served Region window at `DISTRICT_WINDOW_MAX_N_REGION` (n=6,400 → 64×64=4,096 cells, `WIRE_CAP_CELLS` capped, real production `build_district_window_layer` path, row-chunked `par_iter`, 16 Rayon threads) | 12.35 ms / 20 calls | — | **0.617 ms/call** | +> +> Extrapolated full-canvas figures (1600×900 @ 1/2 px-per-cell) from the orbital per-cell rate: ~2,094 ms single-thread @1px/cell, ~524 ms @2px/cell — still **EXTRAPOLATED**, not independently measured at full canvas size (that remains a separate, not-yet-run measurement; the per-cell rate itself is now real). The octave cutoff itself is confirmed cheaper (cutoff=2,048 m runs at ~59% of the uncut cost at District spacing) rather than assumed cheaper. This closes the "not load-bearing for T-1170's design, but the gap closes now" item from the T-1170 ruling's Discipline §1 — the course inventor's Stage A/B split does not depend on these numbers (it deliberately avoids per-candidate `derive_at_metres` calls, Ruling 3b), but the gap this table flagged is no longer open. + **Interactive-latency verdict:** district and quarter rungs (capped) are comfortably interactive on a warm `TerrainAnalysis` — 1–5 ms per response at current cap sizes. The **only** rung with a real latency question is planetary/canvas-fill, and its numbers are two extrapolation-hops from anything actually measured this session. **No implementation should proceed on the planetary rung's cost story without first (a) prototyping the octave cutoff and re-measuring, and (b) chunked-`par_iter`-ing the window loop and re-measuring** — both are cheap to do (the probe binary already exists) and should happen before, not during, the follow-up ticket. --- diff --git a/docs/architecture/river-courses-t1170.md b/docs/architecture/river-courses-t1170.md new file mode 100644 index 000000000..cf3c03fda --- /dev/null +++ b/docs/architecture/river-courses-t1170.md @@ -0,0 +1,148 @@ +--- +title: "River Course Invention — T-1170/T-1168 Design Ruling" +description: Binding design ruling for course invention + riparian vegetation (Tyre, from the three-audit design pass) +type: design +status: binding +round: T-1170 +created: 2026-07-23 +--- + +*cracks knuckles* — I've read both tickets in full, the D-226 amendment block (wave-1 carrier note, two-waterline note, T-1137 queue ruling, windowed-family ceiling), D-227, D-243, and the T-1143 zoom-ladder serving model. The audits are code-true and the tension called out in point 1 is real — I created it, and it resolves cleanly once the carrier rule is split on the right axis. Ruling follows. + +--- + +# TYRE — Binding Design Ruling: T-1170 (river course invention) + T-1168 (riparian vegetation) + +**Date:** 2026-07-23. **Status:** binding for this batch; implementers code from this directly. Governance capture text in Ruling 6. + +**The keystone insight, stated once up front:** the framework tension between my wave-1 carrier note ("discrete geometry rides whole-body") and my two-waterline note ("invention detail is rung-indexed") dissolves when you split on the correct axis. The wave-1 rule was written for the **skeleton** — a fixed, finite, rung-independent graph, computed once, valid forever. Course geometry is not skeleton; it is **invention** — the linear sibling of the coast crinkle, refined per rung by construction. Skeleton rides whole-body; invention rides the window. The rule wasn't wrong, it was under-specified, and this ruling refines it rather than reversing it. + +--- + +## Ruling 1 — Invention locus and wire carrier: server-side, per-window, inside `DistrictWindowLayer` + +**1a. Locus: server, per-window, at serve time — Dudley's option (b). Binding.** Course geometry is invented inside the window derive (`GenWorkItem::DeriveWindow`, background queue per T-1137 — queue discipline [HARD], unchanged), at the window's granularity and `min_wavelength_m`. Dudley's numbers close this: +0.09–0.21 ms against a measured ~5 ms window baseline, under 5% — noise. Whole-body precompute at Quarter detail is **rejected** on payload grounds (~4.4 MB dense-body for geometry 99% never viewed — categorically the wrong shape under D-227 derive-don't-store). Client-side GDScript invention is **rejected** on the determinism surface, not CPU: a byte-exact two-language mirror of `splitmix64`/`value_noise`/octave tables is a standing D-010/D-227 liability the project has deliberately avoided everywhere else, purchased for a compute saving option (b) proves unnecessary. + +**1b. Carrier: a new vector field inside the existing windowed payload. The windowed-family ceiling is untouched — and here is why, precisely.** The ceiling ([HARD], D-226 T-1124 §2) counts **windowed-query fields on `AtlasLayerResponse`**. It exists to prevent a second concurrently-in-flight windowed *query* needing per-field request correlation. Course geometry is not a second query: it is **content of the one windowed payload**, keyed by the same `(body, center, n, granularity)` echo, arriving in the same response, stale-discardable by the same rule. `DistrictWindowLayer` gains `courses: Vec` (`#[serde(default)]`), exactly as it holds six dense arrays today. No ceiling impact, no tagged-envelope migration, no new request class. + +**1c. The carrier rule, refined (this is the governance-grade restatement — capture text in Ruling 6):** + +> Three-way, replacing the wave-1 two-way rule: **(i)** rung-independent discrete features — skeletons, graphs, point markers, computed once and valid forever — ride the whole-body overlay family, filtered per-rung client-side. **(ii)** Continuous per-metre fields ride the windowed per-cell arrays. **(iii)** **Rung-indexed invented detail rides the windowed payload regardless of geometric kind** — raster or vector — because rung is a *request parameter*, and only the windowed query carries one. The coast crinkle has always followed (iii) implicitly (it arrives baked into windowed cell verdicts); course polylines are its vector sibling, arriving alongside them. + +**1d. The two-waterline problem dissolves under this carrier.** A whole-body course would need a rung-parametrized terminus (the fork the audit flagged as sharpest). A windowed course does not: the server invents the course *at a known rung*, in the same pass that computes that rung's per-cell water verdicts — so the terminus is resolved against the drawn coast **at that rung**, server-side, consistently. Nothing whole-body ever carries a terminus. The skeleton stays rung-independent, as the two-waterline note requires. + +**1e. Window-independence invariant (binding, tested).** The geometry is a pure function of `(seed, body, edge, rung)` — **never** of the window rect. Stations are generated at deterministic global arc-length positions along the edge; the window *crops*, it never re-parametrizes. Two overlapping windows at the same granularity must produce byte-identical points for the shared stretch. This is the course analog of the coast warp's absolute-world-metre keying and it is a mandatory determinism test (see Discipline). + +--- + +## Ruling 2 — Adjacency: persisted downstream pointer. Reconstruction rejected. + +**2a.** `RiverNetwork` gains `river_downstream: Vec` — additive, `#[serde(default)]`, parallel to `river_cells` (the exact `river_class` precedent). Captured in `extract_river_network` (`drainage.rs:247-349`) where `fdir[i]` is already in scope and currently discarded after its boolean use. One `.map()`, zero new grid passes, ~1 byte/river cell. This is the **opening move** of the batch — everything else consumes it. + +**2b. Reconstruction is rejected** on the Si audit's confluence finding: adjacency alone cannot distinguish inflow from outflow at a 3-river-neighbor confluence, and re-deriving direction from elevation is re-running D8 badly. We had the true answer in hand and threw it away; stop throwing it away. The D-203 memory-frugality rationale for discarding covered the 131 KB *full grid*, not the river-cell projection. + +**2c. Sentinel vocabulary (values in the u8 space above 7):** `MOUTH` (flow reaches raw-sea) · `EDGE_DRAIN` (flow exits the grid's top/bottom edge — a grid artifact, *not* a mouth, per Ruling 3f) · `TERMINAL` — **reserved now, unused in round 1** — flow ends in an interior sink (future endorheic basin / inland delta, see Ruling 7b). Reserving the value costs one enum arm and keeps the future option additive instead of a wire migration. + +**2d.** Each edge is uniquely identified by its **upstream cell** (every river cell has exactly one downstream pointer), so `edge_id = pack(u16,u16) → u32`. This is the seeding key (Ruling 3a). + +--- + +## Ruling 3 — Course algorithm + +**3a. Seeding (D-227/D-010 discipline).** `SeedChain::for_body(...).derive(SeedDomain::RiverCourse, edge_id as u64)`, new `SeedDomain` variant, with a distinct `RIVER_COURSE_WARP_SALT` on the noise stream (the `COAST_WARP_SALT` pattern verbatim) — never correlated with coast warp, terrain scatter, or vegetation massif fields at the same position. Pure `(seed, body, edge, rung)`. + +**3b. Two-stage shape — and the stage split is load-bearing for cross-rung stability:** + +- **Stage A — coarse valley-seeking control path. Rung-INDEPENDENT.** Between the two anchor points (upstream/downstream cell centres — skeleton truth, never moved), place control stations at chord/8 (~9.6 km). At each, evaluate k=5 candidate perpendicular offsets within the amplitude envelope; score = bilinear `ta.elev_pct` at the candidate (plus a small continuity penalty against the previous chosen offset to prevent zigzag); pick minimum. **The elevation proxy is the bilinear `TerrainAnalysis` read — the same tradeoff the coast warp already made at `district_profile.rs:1152-1157` — NOT `derive_at_metres` per candidate.** Dudley's numbers make this affordable (+µs/segment); the Si audit's unmeasured-derive-cost gap is thereby not load-bearing for this design (we still close the measurement gap — see Discipline). Stage A is computed identically at every rung, so the coarse course never moves as you refine. +- **Stage B — fine warp octaves. Rung-INDEXED.** Perpendicular scalar displacement of intermediate stations via salted multi-octave value noise keyed on global arc-length, octave band from ~chord/2 (~38 km) down to the window's `min_wavelength_m` hard-truncate (the `warp_fbm` cutoff idiom, T-1162 rung discipline, reused exactly). Station spacing = the rung's sample spacing (2,048 m District / 512 m Quarter — Dudley's ~37/~150 points per segment), at global arc-length multiples so windows agree (Ruling 1e). + +**Cross-rung invariant that falls out:** the Quarter course is the District course plus octaves in the (1,024 m..4,096 m) band — displacement between rungs is bounded by the truncated-octave amplitude sum, sub-cell at District spacing. And at Region spacing no octaves survive at all, so **the rung-truncated course degenerates to the straight chord — which means the client's Region-rung skeleton-chord drawing (Ruling 5a) IS the course at that rung**, not an approximation. That's the kind of unification I get excited about: one function, every rung, no special cases. + +**3c. Amplitude envelope.** Sine-shaped taper to **zero at both endpoints** — this is what makes confluences work for free: every edge into and out of a cell meets exactly at the cell-centre anchor, C0-continuous, and the confluence marker draws at the anchor. Peak amplitude ≤ ~8% of chord (~6 km), scaled down by local `ta.slope_deg` (steep terrain → straighter) and up modestly by `river_class` (trunks meander wider). All tunables; taper-to-zero and the ≤ half-cell hard cap are binding. + +**3d. Valley preference is IN for round 1** (costed at noise level by Dudley), via Stage A only. This is the round-1 answer to the wave-1 "rivers may cross invented hills" gap: courses *prefer* invented-terrain valleys at coarse wavelengths; residual fine-octave conflicts (a course crossing a `voxel_relief` hillock) are **accepted and documented**. The converse mechanism — terrain invention becoming course-aware (valley carving along courses) — is explicitly deferred; it belongs at the block/tile rungs and Phase 5 fill, noted in governance as the future direction, not built now. + +**3e. Termination at the invented coast (the T-1172 retirement mechanism).** For an edge whose downstream sentinel is `MOUTH`: walk stations upstream→downstream, sampling the **same rung-consistent morphology water verdict the window's own cells use** (the `open_water`/morphology path, at the window's `min_wavelength_m` — not raw `ocean_frac`, so the terminus agrees with what is drawn). First water station → bisect against the previous land station (fixed 6 iterations) → terminus point, flagged `Mouth` on the wire. If the final anchor still samples land at this rung (drawn coast receded past the raw-sea cell — the inland-mouth oddity, fixed by construction here), extend along the D8 direction up to one cell length probing; if still no water, terminate at the anchor with **no** mouth flag (degenerate, rare, bounded). A few dozen probes per mouth edge, on the background queue — within the T-1137 discipline by construction. + +**3f. Pole-edge drains are not mouths. Binding.** `EDGE_DRAIN` termini get no mouth flag and no double-ring; the course ends at the last in-grid station. Server-side, `extract_river_network` stops classifying grid-edge exits into `mouths` (deliberate Layer-1 golden re-pin, see Discipline). They are grid artifacts, exactly as Jeroen's capture question suspected. + +**3g. T-1172 clip: restructured, not blanket-deleted.** The three marker sites in `atlas_window_nature_overlay.gd` (lines 288/311/347) retire **for the course-drawing rungs** (District/Quarter) — courses carry real rung-consistent termini, the clip's job is done there, and suppressed offshore mouths return as real geometry. The **Region-rung skeleton path keeps a clip**: Region still draws whole-body skeleton against a rung-dependent drawn coast, which is precisely the presentation-frame reconciliation the two-waterline note prescribes. Rewrite the marker comments: the surviving clip is documented as permanent-until-Region-goes-windowed (T-1143 ruling 2's progressive tiling), at which point Region inherits windowed courses and the last clip retires. + +**3h. Wire shape.** `RiverCourse { edge_id: u32, class: u8, points: Vec<(i32,i32)> /* world-metres */, terminus: CourseTerminus /* None | Mouth | EdgeDrain | ContinuesBeyondWindow */ }`. Only edges whose chord-inflated-by-max-amplitude bounding box intersects the window ship; points cropped to window + one station beyond each edge of it. ~1–2 KB typical per window. Well inside every budget. + +--- + +## Ruling 4 — T-1168: shape and sequencing + +**4a. The mechanism: a scale-free point-sample distance test. This dissolves the blotch problem rather than mitigating it.** `near_perennial_water(sample_pos) = distance(sample_pos, nearest invented course polyline) ≤ band_m`, where `band_m` defaults to D-239 §8's governed 1–3 tiles (class-scaled: thicket band for trunks, scrub band for streams — tunable constants, governed default). Because the test is a pure point predicate against real course geometry, it is **automatically correct at every sampling density**: at 512 m Quarter spacing it essentially never fires (the band is sub-cell — honest, no over-fattening, the exact Lendel-failure discipline), and at 1 m tile spacing it fires on exactly the 1–3 tile bank strip. No per-rung riparian policy exists or is needed. The audit's skeleton-disc model — any "within N km of a 76 km-spaced skeleton point" test — is **rejected outright**: it is off by 3–4 orders of magnitude from the governed band and cannot be tuned into anything but green blotches. + +**4b. Consequently the 512×256 distance-field-over-grid proposal is also rejected** — a grid quantized at 76 km cannot express a metres-scale band. The test runs against course polylines resident in the derive context: in the window path, T-1170's already-invented courses (coarse bounding-box cull, then point-to-segment distance — most cells cull to zero edges); in the batch path, courses for edges near the district, invented on demand via the same pure function (cachable per derive batch). The Si audit's observation that `gen_queue.rs:626` discards the `Layer1Output` **still gets fixed** — the window path needs `RiverNetwork` (cells + downstream pointers) resident to know which edges exist; retain it in `TerrainAnalysisCache` instead of re-deriving-and-discarding. + +**4c. Hard sequencing: T-1168's riparian test lands AFTER T-1170's course inventor, full stop.** The test is meaningless without course geometry (4a). The **threading scaffolding is exempt** and proceeds in parallel — it is mechanical and fully mapped by the audit: `build_district_profile` gains the riparian signal parameter consumed at `district_profile.rs:1469`; threaded via `derive_all_districts` → `derive_district_profile` (batch, sourced from `cascade.rs`'s already-unpacked `river_network`, the `road_graph` precedent) and `build_district_window_layer` → `derive_window_cell` → `derive_at_metres`/`derive_orbital_at_metres` (window, sourced from the retained `Layer1Output`). Until the course inventor lands, the provider returns "no course in range" — wired but inert, zero behavioral change, zero golden churn. + +**4d. T-1162 composition contract, re-affirmed as binding:** `near_perennial_water` is a separate boolean/geometry signal into `derive_vegetation`; it never touches `moisture_q`, and `moisture_perturb_q` never reads river data. The existing parameter separation already enforces this — the implementer's only job is not to break it. Climate gating stands: extreme-cold Barren beats riparian (ice is geology per D-227); `Marine`/airless precedence unchanged. + +**4e. Experience honesty (what Jeroen will see):** after this batch, District/Quarter windows show continuous meandering rivers with real coast mouths. They will **not** show visible green riparian corridors — under the governed 1–3 tile band that is sub-cell at every Atlas rung, and honestly so. T-1168's visible payoff arrives at the block/tile rungs (opened by T-1143 ruling 1) and Phase 5 ground truth. If Atlas-visible green corridors are *wanted*, that is a design widening of D-239 §8 — flagged to Jeroen in Ruling 7a, one constant away, not an architecture change. + +--- + +## Ruling 5 — Per-rung presentation surface + +**5a. Region and coarser (skeleton path, whole-body `layer1`):** the client upgrades dots to **connected straight chords** via `river_downstream` — which per Ruling 3b *is* the rung-truncated course, so this is not an approximation, it is the course at Region truncation. Clip retained (Ruling 3g). Confluence/mouth markers from the skeleton lists (mouths now excluding pole drains). + +**5b. District and Quarter (course path, windowed):** polylines from `DistrictWindowLayer.courses` via the existing `_pos()`/`draw_polyline` plumbing (`_draw_basins` precedent — no new transform code). Mouth markers at `Mouth` termini. No clip. **Quarter rivers return** — the streams class draws at Quarter, executing exactly the pre-announced fade-down revisit from the wave-1 visibility note. + +**5c. The revisit point is preserved and split:** `RIVER_CLASS_VISIBLE_BY_RUNG` is replaced by two tables in `atlas_window_geometry.gd` — `SKELETON_CLASS_VISIBLE_BY_RUNG` (Region+, path 5a) and `COURSE_CLASS_VISIBLE_BY_RUNG` (District/Quarter, path 5b), with per-class width/opacity companion tables. **This is where Araminta's forthcoming presentation ruling plugs in** — implementers land functional defaults (trunk widest, streams thinnest, class-filtered per rung: District trunk+tributary, Quarter all three), and Araminta's ruling edits those tables and only those tables. Same single-revisit-point discipline as wave 1. + +--- + +## Ruling 6 — Governance captures (exact text) + +Append to the D-226 amendment block (`governance/decisions/architecture.md`), dated 2026-07-23: + +> **Course-invention carrier note (T-1170, 2026-07-23 — Tyre):** river course geometry below D8 resolution is **invention, not skeleton**, and rides the **windowed payload** as a vector field inside `DistrictWindowLayer` (`courses`, additive) — invented server-side per window on the background queue (T-1137 discipline) at the window's rung, terminated server-side against the same rung's morphology water verdict the window's cells carry. This does **not** count against the windowed-family ceiling (§2 [HARD]): the ceiling counts windowed *query* fields on `AtlasLayerResponse`; courses are content of the single existing windowed payload, same echo key, same staleness semantics. The wave-1 carrier rule is hereby refined three-way: **(i)** rung-independent discrete features (skeletons, graphs, markers — computed once, valid forever) ride the whole-body family, filtered per-rung client-side; **(ii)** continuous per-metre fields ride the windowed per-cell arrays; **(iii)** rung-indexed invented detail rides the windowed payload regardless of geometric kind, because rung is a request parameter and only the windowed query carries one — the coast crinkle has always implicitly been (iii); courses are its vector sibling. The two-waterline note's reconciliation resolves per-rung server-side for courses (the terminus is windowed content at a known rung); the Region-rung skeleton path retains the presentation-frame clip until Region itself goes windowed (T-1143 ruling 2), which retires the last clip site. The skeleton itself gains `river_downstream` (additive per-river-cell D8 pointer with `MOUTH`/`EDGE_DRAIN`/reserved-`TERMINAL` sentinels) on `RiverNetwork` — whole-body, rung-independent, per rule (i). Pole-edge drains are grid artifacts, not mouths — excluded from `mouths` at extraction. + +Append to D-227's amendment trail: + +> **Amended 2026-07-23 (T-1170 — river courses join the invention family):** the "invented deterministically" clause now covers linear features: river courses between D8 cells are pure `(seed, body, edge, rung)` functions — a rung-independent valley-seeking coarse path (bilinear `TerrainAnalysis` proxy, never full re-derivation) plus rung-indexed perpendicular warp octaves under the `min_wavelength_m` truncation discipline, distinct salted stream, amplitude tapered to zero at cell-centre anchors (confluence continuity), stations at global arc-length positions (window-independent). Round-1 relief reconciliation is course-follows-terrain (valley preference); terrain-carves-for-course is the deferred converse, targeted at block/tile rungs. + +Also: fix the stale `scale.rs:107` grid-shape comment (one line, audit nit) in whichever PR touches that file first. + +--- + +## Ruling 7 — Jeroen items + +**7a. Riparian visibility at Atlas rungs (genuine design call).** Under D-239 §8's governed 1–3 tile band, riparian vegetation is honestly invisible at every Atlas rung (Ruling 4e) — the drawn river itself is the only visible river response at District/Quarter. If you want the Nile effect — a visible green corridor at Atlas scale — that is a *design widening* of the band (e.g. floodplain-riparian corridors of hundreds of metres on alluvial-plain morphology, class- and morphology-scaled). The mechanism ships scale-free, so this is one governed constant/function away, zero architecture change. Your call, not blocking this batch. + +**7b. Endorheic basins (one paragraph, as asked).** This design does not foreclose deliberate endorheic basins or inland deltas — it deliberately cheapens the future path. The `TERMINAL` downstream sentinel is reserved now (Ruling 2c), so an interior sink becomes an additive drainage-extraction change, not a wire migration; and the termination mechanism (walk stations to the first rung-consistent *water* verdict — which is the morphology verdict, covering lakes, not just ocean) generalizes unchanged to a terminal lake shoreline. When you want endorheic basins, the work is in D8 sink retention and lake morphology, and every piece this batch builds — pointers, courses, termination, riparian — consumes them without modification. Nothing to design today. + +--- + +## Discipline — benches, goldens, determinism tests + +1. **Run the dormant bench first.** `zoom_ladder_bench.rs` `--release --ignored`, before the course inventor merges; record the numbers in `docs/architecture/atlas-zoom-ladder-t1143.md` (which itself flags them UNMEASURED). Cheap, closes a documented gap this batch would otherwise inherit blind. Not load-bearing for the design (Ruling 3b avoided depending on it) but the gap closes now. +2. **Course-cost bench:** window derive with courses on/off; assert the delta stays under ~5% of the window baseline (Dudley's envelope). +3. **Determinism tests (mandatory, new):** (a) overlapping-windows agreement — two windows sharing an edge produce byte-identical course stations in the overlap (Ruling 1e); (b) cross-rung stability — Quarter course points within the truncated-octave amplitude bound of the District polyline (Ruling 3b); (c) salt isolation — course stream uncorrelated with coast warp at shared positions (the existing isolation-test pattern). +4. **Goldens, deliberate re-pin discipline** (`UPDATE_GOLDEN=1` + rationale comment, the T-1162 precedent): `cascade_golden` re-pins (mouths list loses pole drains; `river_downstream` added). `window_derivation_golden`: first **verify empirically** whether the 128×64 fixture produces river cells near the sweep positions (audit: unconfirmed); add a course-bearing sweep position either way so courses are actually pinned. `believability`: expected unchanged (the point test cannot fire at district spacing) — **verify, don't assume**; an unexpected re-pin there is a bug signal, not a regen chore. +5. **Full `cargo test` before push** (the golden harnesses are separate test binaries; `--lib` misses them), and the live-screenshot pixel-diff eyeball check on the merged result, cold-launched — this batch is rendered-path. + +--- + +## Work split and sequencing + +**Dudley — server (owns the cost model he probed):** +- **A1** (opening move, unblocks everything): `river_downstream` + sentinels + pole-drain mouth exclusion + Layer-1 golden re-pin. +- **A2**: course inventor (Ruling 3a–3d, 3h) in the window derive + `Layer1Output` retention in `TerrainAnalysisCache` + wire field. +- **A3**: termination + mouth flags (3e–3f) — with A2. +- **A5** (after A2): T-1168 riparian point test wired into the threaded signal, batch + window paths. + +**Stig — client + mechanical threading:** +- **B1** (parallel with A2): T-1168 threading scaffolding (Ruling 4c), inert provider, zero behavior change. +- **B2** (needs A1 only): Region skeleton-chord drawing. +- **B3** (needs A2/A3): course polyline drawing, mouth markers, clip restructure (3g), visibility-table split (5c) with functional defaults for Araminta. + +**Hard edges:** A1 → everything. A2 → {A3, A5, B3}. B1 ∥ A2, converging at A5. Nothing else serializes. + +**Difficulty tier, honestly: challenging but doable — and the architecture is on our side.** Every primitive exists (`value_noise`, `splitmix64`, the cutoff idiom, the queue, the polyline draw path); this batch composes them, it doesn't invent machinery. The one place I'd have called "extremely difficult" — reconciling a whole-body course with a rung-indexed coastline — stopped being a problem the moment courses went windowed. That's the ruling doing the work. + +— Tyre \ No newline at end of file diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 779d10943..530c05bd3 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. **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. **Two-waterline note (T-1172, 2026-07-23 — Tyre):** the river skeleton is extracted against the **raw heightmap sea level** (`drainage.rs`), a rung-independent graph; the drawn coast is the **derived morphology verdict** — which is **rung-dependent by construction** (the coast-warp crinkle, `coast_invention.rs`, adds octaves at finer rungs, so the drawn coastline is a *family* of curves indexed by rung, not a single curve). There is therefore **no single authoritative server-side waterline** to reconcile the skeleton against — a server-side classification would bake one rung's coast into the wire and be wrong at every other rung. Reconciliation is a **presentation-frame** operation: the draw site clips river dots/confluences/mouths against the arrived composite's per-cell water verdict at the rung being painted (drop-in-water, no snap). The skeleton stays rung-independent (its correct nature per the carrier note); the clip is retired into T-1170 when course invention terminates courses at the invented coast with continuous geometry. + **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. **Two-waterline note (T-1172, 2026-07-23 — Tyre):** the river skeleton is extracted against the **raw heightmap sea level** (`drainage.rs`), a rung-independent graph; the drawn coast is the **derived morphology verdict** — which is **rung-dependent by construction** (the coast-warp crinkle, `coast_invention.rs`, adds octaves at finer rungs, so the drawn coastline is a *family* of curves indexed by rung, not a single curve). There is therefore **no single authoritative server-side waterline** to reconcile the skeleton against — a server-side classification would bake one rung's coast into the wire and be wrong at every other rung. Reconciliation is a **presentation-frame** operation: the draw site clips river dots/confluences/mouths against the arrived composite's per-cell water verdict at the rung being painted (drop-in-water, no snap). The skeleton stays rung-independent (its correct nature per the carrier note); the clip is retired into T-1170 when course invention terminates courses at the invented coast with continuous geometry. **Course-invention carrier note (T-1170, 2026-07-23 — Tyre, full ruling in docs/architecture/river-courses-t1170.md):** river course geometry below D8 resolution is **invention, not skeleton**, and rides the **windowed payload** as a vector field inside `DistrictWindowLayer` (`courses`, additive) — invented server-side per window on the background queue (T-1137 discipline) at the window's rung, terminated server-side against the same rung's morphology water verdict the window's cells carry. This does **not** count against the windowed-family ceiling (§2 [HARD]): the ceiling counts windowed *query* fields on `AtlasLayerResponse`; courses are content of the single existing windowed payload, same echo key, same staleness semantics. The wave-1 carrier rule is hereby refined three-way: **(i)** rung-independent discrete features (skeletons, graphs, markers — computed once, valid forever) ride the whole-body family, filtered per-rung client-side; **(ii)** continuous per-metre fields ride the windowed per-cell arrays; **(iii)** rung-indexed invented detail rides the windowed payload regardless of geometric kind, because rung is a request parameter and only the windowed query carries one — the coast crinkle has always implicitly been (iii); courses are its vector sibling. The two-waterline note's reconciliation resolves per-rung server-side for courses (the terminus is windowed content at a known rung); the Region-rung skeleton path retains the presentation-frame clip until Region itself goes windowed (T-1143 ruling 2), which retires the last clip site. The skeleton itself gains `river_downstream` (additive per-river-cell D8 pointer with `MOUTH`/`EDGE_DRAIN`/reserved-`TERMINAL` sentinels) on `RiverNetwork` — whole-body, rung-independent, per rule (i). Pole-edge drains are grid artifacts, not mouths — excluded from `mouths` at extraction. **Implementation notes (PR #197 review):** (a) Godot's line rasterizer floors stroke widths below ~1.0 canvas units to a 1-px hairline; the client floors compensated stroke widths accordingly (`zoom_compensated_stroke_width`), with the consequence that Ruling 5c's **per-class width differentiation is inert at every shipping fit zoom** — course classes are distinguished by opacity alone until T-1175's per-vertex tapering (Polygon2D strips) supersedes stroke-width rendering; the 'trunk widest' promise is design intent, not current pixels. (b) The pole-row edge-drain branch is **structurally unreachable** (`flow_direction` bounds-checks before assignment); the real EDGE_DRAIN mechanism is the interior `k<0` no-valid-downstream case — discovered by revert-verification, test fixture exercises the reachable path. - **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. @@ -1692,6 +1692,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Open sub-questions:** the geology-model fidelity (simple depth-horizon stack vs tectonic-grade folding/faults) and how far `FloorMaterial` is derived now vs deferred to the city layers (both tracked in D-228 / Q-101); the mutator op schema (Q-103). - **Implementation:** Phase 4+ (epic T-750). The caching substrate exists at the atlas level (`BodyWorldStateCache`, D-203/D-225); the tile/voxel cache, mutator overlay, and geology derivation land as the cascade reaches the tile layers. No migration needed pre-save (D-202 `schema_version` lineage covers future drift once saves exist). - **Amended 2026-07-17 (T-1125 — the invention carries geographic content into district classification; two-tier driver model):** the "invented deterministically (interpolation + domain warp + detail-scatter)" clause is now implemented *with character* at the district tier, closing the T-1123 finding that classification consumed only the raw bilinear envelope (pixel-smooth coasts; scatter amplitude slaved to coarse heightmap slope ≈ 0 exactly on low-relief coasts). Mechanism (`atlas/coast_invention.rs` + `district_profile::invent_primitives`, shared by the on-demand `derive_district` AND batch `derive_district_profile` paths so they can never silently diverge): **(a) coastline domain-warp** — every envelope field (elevation, slope, ocean mask) is bilinearly sampled at the same warp-displaced position (C¹ multi-octave value noise, ≈16–262 km band, sub-pixel amplitude cap 0.75 px, distinct salted hash stream — never correlated with the terrain scatter or climate edge-fuzz), inventing bays/capes/fjord inlets while the heightmap stays the truth at its own scale; **(b) slope-independent scatter floor** — the detail-scatter envelope gains a character-driven floor (invention no longer collapses on flat coasts; the old "flat envelope → zero invention" reading is superseded — the envelope rule survives as a *ceiling*: gentle bounded relief, never mountains on an authored plain); **(c) shoreline carving** — ridged character contributes real slope in shoreline patches so the steep coastal families (Fjord/CliffCoast) can fire where glacially/tectonically justified. **Crinkle varies (Jeroen's ruling): two driver tiers, zero new authored data.** Tier 1 (body personality envelope): `planet_class` (via `TectonicClass`) + `hydrosphere` + `atmosphere` + `body_radius_km` (via the pixel⇄metre seam) only — **D-240 stands: no orbital/tilt inputs**; erosion-proneness is *derived* (more ocean → wetter/rainier → higher erosion → smoother mature coasts; dry/thin-atmosphere → sharp young coasts). Tier 2 (position): latitude, driver-tier `GlaciationGrade` (fjordy high-latitude glaciated coasts), local wetness, and a seeded ~100–400 km heterogeneity field so stretches of the same coast differ; longitude participates via absolute world-metre noise keying. **Circularity ruling:** the driver-tier climate (glaciation/moisture) reads the *unwarped* raw-bilinear primitives — one-step-stale by design, documented at the call site. All pure `(seed, body, position)` (D-010); character never steps on a district/region line (D-243 edge-fuzz discipline). +- **Amended 2026-07-23 (T-1170 — river courses join the invention family; full ruling in docs/architecture/river-courses-t1170.md):** the "invented deterministically" clause now covers linear features: river courses between D8 cells are pure `(seed, body, edge, rung)` functions — a rung-independent valley-seeking coarse path (bilinear `TerrainAnalysis` proxy, never full re-derivation) plus rung-indexed perpendicular warp octaves under the `min_wavelength_m` truncation discipline, distinct salted stream, amplitude tapered to zero at cell-centre anchors (confluence continuity), stations at global arc-length positions (window-independent). Round-1 relief reconciliation is course-follows-terrain (valley preference); terrain-carves-for-course is the deferred converse, targeted at block/tile rungs. - **Raised by:** Jeroen (derive-don't-store, volumetric, drop-the-floor-cap directives) + Claude, atlas-derivation workshop, 2026-05-25. - **Cross-reference:** [D-010](#d-010) (determinism — now save-critical), [D-222](#d-222) (subtile/tile/chunk hierarchy), [D-110](#d-110) (signed z-levels), [D-225](#d-225) (layer-stream proxy + cache pattern), [D-203](#d-203) (LRU cache tier), [D-224](#d-224) (SeedChain — feeds `derive`), [D-228](#d-228) (composite tile schema — the derived value type), [Q-101](../questions/architecture.md#q-101) (refinement contract), [Q-103](../questions/architecture.md#q-103) (mutator op schema), [Q-104](../questions/architecture.md#q-104) (floor↔voxel-z mapping) - **Dissent:** None diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index b3d17dcff..b11e83253 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -54,8 +54,87 @@ pub struct RiverNetwork { /// empty, never a decode error (the additive T-1124 §1 pattern). #[serde(default)] pub river_class: Vec, + /// Per-`river_cells`-entry D8 downstream pointer (T-1170 Ruling 2a) — same + /// index, same length as `river_cells`/`river_class`. Each river cell has + /// exactly one downstream direction, so this is the exact same shape as + /// `river_class`, captured in `extract_river_network` where `fdir[i]` is + /// already in scope (one `.map()`, no new grid pass). + /// + /// **Values 0–7:** an index into `drainage::D8` — the downstream neighbor + /// direction, i.e. "this river cell flows toward `D8[value]`". + /// + /// **Sentinel values above 7 (Ruling 2c):** + /// - [`RIVER_DOWNSTREAM_MOUTH`] — flow reaches a raw-sea cell (the river's + /// mouth in the D8 sense; T-1170's course inventor walks stations from + /// here to the invented-coast terminus, Ruling 3e). + /// - [`RIVER_DOWNSTREAM_EDGE_DRAIN`] — flow exits the grid's top/bottom + /// edge. This is a **grid artifact, not a mouth** (Ruling 3f) — pole-edge + /// exits are deliberately excluded from `mouths` at extraction (see + /// `extract_river_network`'s doc). + /// - [`RIVER_DOWNSTREAM_TERMINAL`] — **reserved, unused in round 1.** Flow + /// ends in an interior sink (future endorheic basin / inland delta, + /// Ruling 7b). Reserving the value now keeps that future path additive + /// (no wire migration) rather than requiring a new sentinel later. + /// + /// Why persist rather than reconstruct from adjacency alone (Ruling 2b): + /// at a 3-river-neighbor confluence, adjacency cannot distinguish inflow + /// from outflow, and re-deriving direction from elevation is re-running D8 + /// badly — the true answer (`fdir[i]`) is already computed and in scope at + /// extraction time; discarding it and re-deriving later is strictly worse + /// than keeping the ~1 byte/river-cell projection. The former D-203 + /// memory-frugality rationale for discarding covered the 131 KB *full* + /// `fdir` grid, not this per-river-cell projection. + /// + /// `#[serde(default)]` — same additive pattern as `river_class`: an absent + /// array (pre-T-1170 payload/fixture) decodes to empty, never an error. + #[serde(default)] + pub river_downstream: Vec, + /// Per-`river_cells`-entry seaward neighbor position (T-1170 PR #197 + /// review, Hoshe #1) — same index/length shape as `river_class`/ + /// `river_downstream`. **Only meaningful where `river_downstream[i] == + /// RIVER_DOWNSTREAM_MOUTH`**; every other entry (interior direction or + /// `EDGE_DRAIN`) carries the placeholder `(0, 0)` and must not be read. + /// + /// **Why this exists — the second instance of the Ruling-2b anti-pattern.** + /// `extract_river_network` already computes the real sub-sea-level + /// neighbor `(nr, nc)` to decide the `MOUTH` sentinel (the elevation + /// check that flips `river_downstream[i]` to + /// [`RIVER_DOWNSTREAM_MOUTH`]) — the exact same "true answer already in + /// scope, then discarded" shape Ruling 2b fixed for interior D8 pointers + /// via `river_downstream` itself. Discarding `(nr, nc)` a second time + /// left `river_course::build_edges` with nothing to build a real chord + /// from for Mouth edges: it filled `downstream = upstream` as a + /// placeholder, which zeroes the chord (`chord_m < 1.0`), which trips + /// `invent_course`'s single-point degenerate return, which makes + /// `resolve_mouth_terminus`'s station walk a no-op (a 1-point course + /// never enters the `pts.len() >= 2` probe branch either) — every Mouth + /// edge silently resolved `CourseTerminus::None` instead of `Mouth`. + /// Capturing `(nr, nc)` here (one more push per mouth cell — mouths are + /// a small fraction of river cells, not a new grid pass) is what makes + /// `build_edges` give Mouth edges a real ~one-cell chord toward the raw + /// sea, so the termination walk + bisect + one-segment D8-probe fallback + /// (Ruling 3e) are actually reachable. + /// + /// `#[serde(default)]` — same additive pattern as `river_class`/ + /// `river_downstream`. + #[serde(default)] + pub river_seaward: Vec<(u16, u16)>, } +/// [`RiverNetwork::river_downstream`] sentinel: this river cell's D8 flow +/// reaches a raw-sea cell (T-1170 Ruling 2c). Values 0–7 are real D8 direction +/// indices, so sentinels start at 8. +pub const RIVER_DOWNSTREAM_MOUTH: u8 = 8; +/// [`RiverNetwork::river_downstream`] sentinel: this river cell's flow exits +/// the grid's top/bottom (polar) edge — a grid artifact, never a mouth +/// (T-1170 Ruling 2c/3f). +pub const RIVER_DOWNSTREAM_EDGE_DRAIN: u8 = 9; +/// [`RiverNetwork::river_downstream`] sentinel: **reserved, unused in round +/// 1.** Flow terminates in an interior sink (future endorheic basin / inland +/// delta, T-1170 Ruling 2c/7b). Reserving this value now is what makes that +/// future extension additive rather than a wire migration. +pub const RIVER_DOWNSTREAM_TERMINAL: u8 = 10; + /// One drainage basin / province derived from watershed analysis (D-205). /// Stub — boundary polyline data comes from atlas_province_boundaries. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 6ff67192f..989ca9af1 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -305,6 +305,11 @@ pub fn run_cascade_from_heightmap( // thalweg direction into each DistrictProfile.basin_direction // (T-1047). Pass the map through derive_all_districts. let basin_dirs = snapshot.layer1.as_ref().map(|l1| &l1.district_basin_dirs); + // T-1168 Ruling 4c: same `snapshot.layer1` source as + // `basin_dirs`/`river_cells` (the `road_graph` precedent + // below) — the river network for the batch-path riparian + // signal. + let river_network = snapshot.layer1.as_ref().map(|l1| &l1.river_network); let districts = district_profile::derive_all_districts( body_seed, params, @@ -312,6 +317,7 @@ pub fn run_cascade_from_heightmap( scale::HEIGHTMAP_CELLS_PER_DISTRICT, &snapshot.body_id, basin_dirs, + river_network, ); snapshot.layer_district = Some(LayerDistrictOutput { districts }); } diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index a73f7bcc2..6e5333ecc 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -22,9 +22,11 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::atlas::body_world_state::RiverNetwork; use crate::atlas::coast_invention; use crate::atlas::features::TerrainAnalysis; use crate::atlas::region_profile::{self, RegionProfile}; +use crate::atlas::river_course; use crate::atlas::scale::{self, BasinDirection, RegionPos}; use crate::seed::SeedChain; use crate::simulation::generator::MorphologyZone; @@ -1230,7 +1232,17 @@ fn invent_primitives( /// the batch path so both derivation paths key the invention noise fields on /// the same world-metre convention. Radius-less bodies fall back to the /// 1-working-pixel = 1-district convention `derive_district` uses. -fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option) -> (f64, f64) { +/// +/// `pub(crate)` (T-1170): also used by the river course inventor +/// (`river_course::cell_world_m`) to resolve a river cell's pixel position to +/// its world-metre anchor — the SAME mapping, reused rather than duplicated. +pub(crate) fn pixel_to_world_m( + px: f64, + py: f64, + w: usize, + h: usize, + radius_km: Option, +) -> (f64, f64) { match radius_km { Some(r) if r > 0.0 => { let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0); @@ -1245,6 +1257,42 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option } } +/// Absolute world metres → fractional working-grid pixel — the inverse of +/// [`pixel_to_world_m`], and the SAME mapping [`derive_at_metres`]/ +/// [`derive_orbital_at_metres`] compute inline for their own `(px, py)` +/// derivation (T-1170: extracted as a standalone `pub(crate)` helper rather +/// than duplicated a third time, for the river course inventor's Stage A +/// valley-seeking control path, which needs bilinear `elev_pct` reads at +/// arbitrary world positions without paying for a full `DistrictProfile` +/// derive per candidate — Ruling 3b, binding: "NOT `derive_at_metres` per +/// candidate"). Returns `(px, py)` only — callers that also need latitude +/// (temperature-sensitive derivation) still compute it themselves; the course +/// inventor's Stage A elevation proxy has no use for latitude. +pub(crate) fn world_m_to_pixel( + wx: f64, + wy: f64, + w: usize, + h: usize, + radius_km: Option, +) -> (f64, f64) { + match radius_km { + Some(r_km) if r_km > 0.0 => { + let circumference_m = std::f64::consts::TAU * r_km * 1000.0; + let meridian_m = std::f64::consts::PI * r_km * 1000.0; + let px = (wx / circumference_m).rem_euclid(1.0) * w as f64; + let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); + let py = (0.5 + lat_frac) * h.saturating_sub(1) as f64; + (px, py) + } + _ => { + let dm = scale::DISTRICT_M as f64; + let px = (wx / dm).clamp(0.0, w.saturating_sub(1) as f64); + let py = (wy / dm).clamp(0.0, h.saturating_sub(1) as f64); + (px, py) + } + } +} + // --------------------------------------------------------------------------- // Public derivation function // --------------------------------------------------------------------------- @@ -1272,6 +1320,14 @@ fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option /// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`]; /// if a neighbour region is missing it is derived on the fly. Build with /// [`region_profile::derive_regions_for_body`] before calling this in a loop. +/// - `river_network` — the body's [`RiverNetwork`] (T-1168, Ruling 4b/4c), +/// consulted for the riparian point test via +/// [`river_course::near_perennial_water_at`] (edges near this district +/// invented on demand, the same pure function the window path uses). +/// `None` when no river network is available (e.g. a body with no Layer-1 +/// drainage pass, or a caller that predates T-1168) — the riparian signal +/// degrades to `false` in that case, matching the pre-T-1168 hardcoded +/// default exactly, never a panic or an error. pub fn derive_district_profile( seed: SeedChain, body_params: &BodyParams, @@ -1282,6 +1338,7 @@ pub fn derive_district_profile( body_id: &str, region_cache: &BTreeMap, basin_direction: BasinDirection, + river_network: Option<&RiverNetwork>, ) -> DistrictProfile { let (rx, ry) = pos; let w = ta.w; @@ -1332,6 +1389,24 @@ pub fn derive_district_profile( 0.0, // batch path — no octave cutoff, matches derive_district's default ); + // T-1168 Ruling 4b: batch-path riparian signal — edges near this + // district invented on demand via the SAME pure function the window + // path uses. `river_network.is_none()` degrades to `false` (see this + // function's doc), never a panic. + let near_perennial_water = river_network + .map(|rn| { + river_course::near_perennial_water_at( + seed, + ta, + body_params, + rn, + (world_x_m, world_y_m), + scale::DISTRICT_M as f64, + 0.0, // batch path — no octave cutoff, matches this function's own default + ) + }) + .unwrap_or(false); + build_district_profile( seed, body_params, @@ -1344,6 +1419,7 @@ pub fn derive_district_profile( world_x_m, world_y_m, 0.0, // batch path — no octave cutoff, matches derive_district's default + near_perennial_water, ) } @@ -1381,6 +1457,18 @@ pub fn derive_district_profile( /// always active wherever `VegetationEnvelope::ceiling_q > 0`, mirroring the /// coast invention's own always-on posture (the ceiling being zero, not a /// separate flag, is what turns it off on airless/dry bodies). +/// +/// ## Riparian signal (T-1168, Ruling 4a-4d) +/// +/// `near_perennial_water` is the T-1168 riparian point test result for +/// `(world_x_m, world_y_m)` — a separate boolean signal into +/// [`derive_vegetation`], computed by the caller (window path: distance to +/// the retained `Layer1Output`'s invented courses; batch path: distance to +/// on-demand-invented courses for nearby edges — both via the SAME pure +/// [`crate::atlas::river_course::near_perennial_water`] function). **This +/// value NEVER touches `moisture_q`** (Ruling 4d, binding, re-affirmed): it +/// is threaded straight through to `derive_vegetation` unchanged, after every +/// moisture/temperature/morphology field above it has already been resolved. #[allow(clippy::too_many_arguments)] fn build_district_profile( seed: SeedChain, @@ -1394,6 +1482,7 @@ fn build_district_profile( world_x_m: f64, world_y_m: f64, min_wavelength_m: f64, + near_perennial_water: bool, ) -> DistrictProfile { let tectonic_class = derive_tectonic_class(body_params); @@ -1458,15 +1547,21 @@ fn build_district_profile( moisture_q, ); - // Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet - // (requires perennial waterway map from L2+); default to false for now. - // L2 ChunkContext will override per-tile once drainage data is threaded through. - // open_water = the morphology verdict (T-1126) — never a threshold of its own. + // Vegetation class (T-1025, D-239 §8). near_perennial_water (T-1168) is + // the caller-computed riparian point test result — see this function's + // doc for the full threading contract (Ruling 4a-4d). open_water = the + // morphology verdict (T-1126) — never a threshold of its own. let open_water = matches!( morphology_zone, MorphologyZone::OpenOcean | MorphologyZone::Lake ); - let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water); + let vegetation_class = derive_vegetation( + temperature_c, + moisture_q, + elev_q, + near_perennial_water, + open_water, + ); DistrictProfile { morphology_zone, @@ -1529,6 +1624,7 @@ pub fn derive_district( dy as f64 * dm, climate, 0.0, + &[], ) } @@ -1546,6 +1642,19 @@ pub fn derive_district( /// = no cutoff = [`derive_district`]'s existing behavior. /// /// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation. +/// +/// `nearby_courses` (T-1168, Ruling 4b/4c): pre-invented river courses +/// (already culled to the caller's neighbourhood — the window path's own +/// bbox cull, `layer_proxy::build_courses_for_window`) consulted for the +/// riparian point test via [`river_course::near_perennial_water`]. Passing +/// `&[]` (the common case for a position far from any river, and every +/// pre-T-1168 caller via [`derive_district`]) is exactly the old hardcoded +/// `false` default — byte-identical output for every caller that doesn't +/// thread real course geometry through. This is a PRE-INVENTED slice, not a +/// `RiverNetwork` — this function is called once per window CELL (thousands +/// of times per window), so re-inventing courses on every call here (rather +/// than once per window) would be the exact per-candidate-derive cost this +/// whole batch's Ruling 3b was written to avoid. #[allow(clippy::too_many_arguments)] pub fn derive_at_metres( seed: SeedChain, @@ -1556,6 +1665,7 @@ pub fn derive_at_metres( wy: f64, climate: &ClimateConstants, min_wavelength_m: f64, + nearby_courses: &[river_course::InventedCourse], ) -> DistrictProfile { // World metres -> fractional heightmap pixel + latitude. Mirrors // `derive_district`'s former inline mapping exactly, just keyed on @@ -1645,6 +1755,13 @@ pub fn derive_at_metres( // (derive_all_districts), which threads the true D8 direction from L1; // this on-demand path is the fallback for positions derived outside that // pass, where a meaningful basin_direction isn't available. + // + // T-1168 Ruling 4a: the riparian point test against the caller-supplied + // (already-culled) course slice — the SAME pure predicate the batch path + // uses via `near_perennial_water_at`. + let near_perennial_water = + river_course::near_perennial_water((world_x_m, world_y_m), nearby_courses); + build_district_profile( seed, ¶ms, @@ -1657,6 +1774,7 @@ pub fn derive_at_metres( world_x_m, world_y_m, min_wavelength_m, + near_perennial_water, ) } @@ -1790,12 +1908,27 @@ pub fn derive_orbital_at_metres( // the vegetation verdict even though slope/elevation stay // envelope-only at this rung. 0.0, + // T-1168 Ruling 4e/5a: NO windowed course invention at Region + // granularity (`layer_proxy::build_courses_for_window` early-returns + // for `WindowGranularity::Region` — the whole-body skeleton path + // draws Region-rung rivers instead, Ruling 5a). Always `false` here + // — honest, not a gap: even if courses existed at Region, the 1-3 m + // riparian band is many orders of magnitude below Region's ~205 km + // spacing and could never fire (Ruling 4e). + false, ) } /// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`. /// Columns wrap (equirectangular); rows clamp at the poles. -fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { +/// +/// `pub(crate)` (T-1170): also the elevation-proxy read the river course +/// inventor's Stage A valley-seeking control path uses +/// (`river_course::score_candidate`) — the SAME bilinear-`elev_pct` tradeoff +/// the coast warp already makes (`invent_primitives`'s step 3), reused rather +/// than re-implemented so the two invention fields can never silently drift +/// on interpolation semantics. +pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 { if w == 0 || h == 0 { return 0.0; } @@ -1869,6 +2002,11 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 { /// from the map; missing entries (edge districts with no land cells) default to /// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs), /// every district gets `BasinDirection::North`. +/// +/// `river_network` (T-1168, Ruling 4c) is threaded straight through to every +/// [`derive_district_profile`] call for the batch-path riparian signal — the +/// `road_graph` precedent (`cascade.rs`'s already-unpacked +/// `layer1.river_network`, same source, same threading pattern). pub fn derive_all_districts( seed: SeedChain, body_params: &BodyParams, @@ -1876,6 +2014,7 @@ pub fn derive_all_districts( grid_cells_per_district: usize, body_id: &str, basin_dirs: Option<&BTreeMap>, + river_network: Option<&RiverNetwork>, ) -> BTreeMap { let climate = ClimateConstants::default(); let gcpr = grid_cells_per_district.max(1); @@ -1939,6 +2078,7 @@ pub fn derive_all_districts( body_id, ®ion_cache, basin_direction, + river_network, ); out.insert(pos, profile); } @@ -1990,7 +2130,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts. assert_eq!(districts.len(), 32, "district count mismatch"); @@ -2008,7 +2148,7 @@ mod tests { let params = BodyParams::default(); // Real DistrictPos keys from a baseline (None) run. - let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let baseline = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); let mut keys = baseline.keys().copied(); let pos_east = keys.next().expect("at least one district"); let pos_south = keys.next().expect("at least two districts"); @@ -2018,8 +2158,15 @@ mod tests { basin_dirs.insert(pos_east, BasinDirection::East); basin_dirs.insert(pos_south, BasinDirection::South); - let districts = - derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", Some(&basin_dirs)); + let districts = derive_all_districts( + test_seed(), + ¶ms, + &ta, + 8, + "test_body", + Some(&basin_dirs), + None, + ); assert_eq!(districts[&pos_east].basin_direction, BasinDirection::East); assert_eq!(districts[&pos_south].basin_direction, BasinDirection::South); @@ -2182,6 +2329,7 @@ mod tests { dp.1 as f64 * dm, &climate, 0.0, + &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } @@ -2212,6 +2360,7 @@ mod tests { dp.1 as f64 * dm, &climate, 0.0, + &[], ); assert_district_profiles_eq(&via_wrapper, &via_metres); } @@ -2258,6 +2407,7 @@ mod tests { -567.0 * dm + 512.0, &climate, 512.0, + &[], ); assert!((0..=100).contains(&prof.elev_q)); assert!((0..=100).contains(&prof.slope_q)); @@ -2280,7 +2430,17 @@ mod tests { for i in 0..20 { let wx = (100 + i * 37) as f64 * dm; let wy = (100 + i * 53) as f64 * dm; - let uncut = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); + let uncut = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 0.0, + &[], + ); let cut = derive_at_metres( test_seed(), "test_body", @@ -2290,6 +2450,7 @@ mod tests { wy, &climate, 8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries + &[], ); if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q { any_differs = true; @@ -2322,8 +2483,28 @@ mod tests { for i in 0..12 { let wx = (300 + i * 41) as f64 * dm * 0.1; let wy = (300 + i * 29) as f64 * dm * 0.1; - let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); - let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + let a = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 1_024.0, + &[], + ); + let b = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 1_024.0, + &[], + ); assert_district_profiles_eq(&a, &b); } } @@ -2355,8 +2536,28 @@ mod tests { for i in 0..20 { let wx = (150 + i * 47) as f64 * dm; let wy = (150 + i * 31) as f64 * dm; - let a = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); - let b = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); + let a = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 4_096.0, + &[], + ); + let b = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 4_096.0, + &[], + ); assert_district_profiles_eq(&a, &b); } } @@ -2409,10 +2610,28 @@ mod tests { for i in 0..20 { let wx = (150 + i * 47) as f64 * dm; let wy = (150 + i * 31) as f64 * dm; - let district = - derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 4_096.0); - let quarter = - derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + let district = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 4_096.0, + &[], + ); + let quarter = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 1_024.0, + &[], + ); if district.elev_q != quarter.elev_q || district.slope_q != quarter.slope_q || district.moisture_q != quarter.moisture_q @@ -2454,6 +2673,7 @@ mod tests { wy, &climate, 300_000.0, + &[], ); // An even more extreme cutoff must produce the SAME result — once // every octave is truncated, going coarser still changes nothing. @@ -2466,6 +2686,7 @@ mod tests { wy, &climate, 10_000_000.0, + &[], ); assert_district_profiles_eq(&far_above, &even_further); } @@ -2507,6 +2728,7 @@ mod tests { base_wy, &climate, 2_048.0, + &[], ) .vegetation_class; @@ -2522,8 +2744,17 @@ mod tests { for dx in -2..2 { let wx = base_wx + dx as f64 * qm; let wy = base_wy + dy as f64 * qm; - let prof = - derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 1_024.0); + let prof = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 1_024.0, + &[], + ); if prof.vegetation_class != VegetationClass::Marine { *tally.entry(prof.vegetation_class as u8).or_insert(0) += 1; } @@ -2636,7 +2867,17 @@ mod tests { let wx = 40.0 * dm; let wy = 20.0 * dm; let orbital = derive_orbital_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate); - let full = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0); + let full = derive_at_metres( + test_seed(), + "test_body", + &p, + &ta, + wx, + wy, + &climate, + 0.0, + &[], + ); // The invented scatter is a bounded perturbation on top of the raw // envelope (detail_scatter's amplitude is capped well under 100 elev_q @@ -2717,6 +2958,7 @@ mod tests { "test_body", &BTreeMap::new(), BasinDirection::North, + None, ); let p2 = derive_district_profile( test_seed(), @@ -2728,6 +2970,7 @@ mod tests { "test_body", &BTreeMap::new(), BasinDirection::North, + None, ); // Equality via serialized fields (no PartialEq on MorphologyZone — compare by name). assert_eq!( @@ -2822,7 +3065,7 @@ mod tests { let hm = test_hm(); let ta = test_ta(&hm); let params = BodyParams::default(); - let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None); + let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None, None); // BTreeMap iterates in sorted key order — verify the first key is (0,0). let first = districts.keys().next().expect("at least one district"); assert_eq!(*first, (0, 0), "first district must be at origin"); diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 16fc278e5..09d381438 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -19,7 +19,9 @@ use std::collections::VecDeque; -use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork}; +use crate::atlas::body_world_state::{ + DrainageBasin, RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH, +}; use crate::simulation::generator::TerritorialStatus; /// A cell is a river cell when its flow accumulation exceeds this threshold (D-208). @@ -41,6 +43,18 @@ const D8: [(i32, i32); 8] = [ (1, -1), // SW ]; +/// `(dr, dc)` for D8 direction index `k` (0-7) — the same fixed priority-order +/// table [`extract_river_network`]/`flow_direction` use internally, exposed +/// `pub(crate)` so downstream consumers of [`crate::atlas::body_world_state:: +/// RiverNetwork::river_downstream`] (T-1170's river course inventor) can walk +/// a river cell's D8 pointer without duplicating the table. Panics on an +/// out-of-range index — callers must check against the +/// `RIVER_DOWNSTREAM_MOUTH`/`RIVER_DOWNSTREAM_EDGE_DRAIN`/ +/// `RIVER_DOWNSTREAM_TERMINAL` sentinels (values ≥ 8) before calling this. +pub(crate) fn d8_offset(k: u8) -> (i32, i32) { + D8[k as usize] +} + /// Result of the full D8 drainage analysis for one body. #[derive(Debug, Clone)] pub struct DrainageResult { @@ -316,35 +330,80 @@ fn extract_river_network( .map(|i| ((i / w) as u16, (i % w) as u16)) .collect(); - // Mouths: river cells that flow to a sea cell or to the polar edge. - let mouths: Vec<(u16, u16)> = (0..n) - .filter(|&i| { - if !is_river[i] { - return false; - } - let r = i / w; - let c = i % w; - let k = fdir[i]; - if k < 0 { - return true; // no outflow — edge - } - let (dr, dc) = D8[k as usize]; - let nr = r as i32 + dr; - let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; - if nr < 0 || nr >= h as i32 { - return true; // polar edge - } - // Flows into a sub-sea-level cell = mouth - elevation[nr as usize * w + nc] < sea_level - }) - .map(|i| ((i / w) as u16, (i % w) as u16)) - .collect(); + // Mouths + river_downstream (T-1170 Ruling 2a/2c/3f): a single pass over + // `river_cells`, in the SAME order, computing both the D8-downstream + // sentinel/pointer AND whether this cell is a mouth. `fdir[i]` is already + // in scope here — capturing it as `river_downstream` costs one extra push + // per river cell, no new grid pass (Ruling 2a's binding requirement). + // + // **Pole-edge exits are NOT mouths (Ruling 3f, binding).** A river cell + // with no outflow because its D8 walk ran off the grid's top/bottom row + // is a grid artifact — the equirectangular projection simply stops there, + // there is no sea. The former code classified this the same as a real + // sea-adjacent mouth, which rendered double-ring mouth markers in polar + // ice with no sea in sight (Jeroen's capture question, T-1170 ticket). + // `RIVER_DOWNSTREAM_EDGE_DRAIN` cells are excluded from `mouths` here; + // T-1170's course inventor (Ruling 3f) ends their course geometry at the + // last in-grid station with no mouth flag. + // + // A flat-peak interior cell (no outflow, but NOT at a pole row) is D8's + // other `k < 0` case — vanishingly rare for a cell that also cleared + // `RIVER_THRESHOLD`, but handled the same as `EDGE_DRAIN` (no downstream + // neighbor to point at, not a sea mouth) rather than crashing the + // pointer's "always points somewhere real" contract. + // + // river_seaward (T-1170 PR #197 review, Hoshe #1): captured in the SAME + // pass, parallel to river_downstream — `(nr, nc)` is already computed + // here to decide the MOUTH sentinel; discarding it after the elevation + // check (the former code) was the second instance of the exact + // discard-then-need-it-later anti-pattern Ruling 2b fixed for + // river_downstream itself. Non-mouth entries get the `(0, 0)` placeholder + // (documented on the field as unreadable outside the MOUTH case). + let mut mouths: Vec<(u16, u16)> = Vec::new(); + let mut river_downstream: Vec = Vec::new(); + let mut river_seaward: Vec<(u16, u16)> = Vec::new(); + for i in 0..n { + if !is_river[i] { + continue; + } + let r = i / w; + let c = i % w; + let k = fdir[i]; + if k < 0 { + // No outflow at all — edge/flat-peak. Not a mouth (Ruling 3f). + river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN); + river_seaward.push((0, 0)); + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + // Flow direction points off the polar edge — a grid artifact, + // not a mouth (Ruling 3f, the pole-edge-drain fix). + river_downstream.push(RIVER_DOWNSTREAM_EDGE_DRAIN); + river_seaward.push((0, 0)); + continue; + } + if elevation[nr as usize * w + nc] < sea_level { + // Flows into a sub-sea-level cell — a real mouth. Capture the + // seaward neighbor position (Hoshe #1) alongside the sentinel. + mouths.push((r as u16, c as u16)); + river_downstream.push(RIVER_DOWNSTREAM_MOUTH); + river_seaward.push((nr as u16, nc as u16)); + continue; + } + river_downstream.push(k as u8); + river_seaward.push((0, 0)); + } RiverNetwork { river_cells, confluences, mouths, river_class, + river_downstream, + river_seaward, } } @@ -1040,4 +1099,144 @@ mod tests { "the body's max river-cell accumulation should be trunk" ); } + + // ----------------------------------------------------------------------- + // river_downstream (T-1170 Ruling 2a/2c/3f) + // ----------------------------------------------------------------------- + + #[test] + fn river_downstream_parallel_to_river_cells() { + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + let rn = &result.river_network; + assert_eq!( + rn.river_cells.len(), + rn.river_downstream.len(), + "river_downstream must be parallel/aligned with river_cells" + ); + assert!(!rn.river_cells.is_empty()); + } + + #[test] + fn river_downstream_values_are_direction_or_sentinel() { + // Every entry is either a real D8 index (0-7) or one of the T-1170 + // sentinels (MOUTH=8, EDGE_DRAIN=9); TERMINAL=10 is reserved/unused. + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + for &v in &result.river_network.river_downstream { + assert!( + v <= RIVER_DOWNSTREAM_EDGE_DRAIN, + "unexpected river_downstream value {v} (round-1 only emits 0-7, MOUTH=8, \ + EDGE_DRAIN=9 — TERMINAL=10 is reserved and unused)" + ); + } + } + + #[test] + fn river_downstream_mouth_sentinel_matches_mouths_list() { + // Every river cell whose river_downstream is MOUTH must appear in + // `mouths`, and every entry of `mouths` must have MOUTH as its + // river_downstream — the two are the same underlying classification, + // captured in the same pass (Ruling 2a/3f). + 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); + let rn = &result.river_network; + assert!(!rn.mouths.is_empty(), "GJ1c should have real sea mouths"); + + let mouth_set: std::collections::BTreeSet<(u16, u16)> = rn.mouths.iter().copied().collect(); + for (i, &pos) in rn.river_cells.iter().enumerate() { + let is_mouth_sentinel = rn.river_downstream[i] == RIVER_DOWNSTREAM_MOUTH; + let is_in_mouths_list = mouth_set.contains(&pos); + assert_eq!( + is_mouth_sentinel, is_in_mouths_list, + "cell {pos:?}: MOUTH sentinel ({is_mouth_sentinel}) must agree with \ + mouths-list membership ({is_in_mouths_list})" + ); + } + } + + #[test] + fn pole_edge_drains_are_not_mouths() { + // Ruling 3f, binding: a river cell with no valid D8 outflow is a grid + // artifact, not a river-meets-sea event. + // + // **Why this test targets the flat/no-outflow (`k < 0`) case, not a + // literal "flow direction points past row 0" case:** `flow_direction` + // (this file, `fn flow_direction`) bounds-checks every D8 candidate + // BEFORE comparing drops (`if nr < 0 || nr >= h { continue; }`) — a + // row-0 cell can therefore never even be ASSIGNED a north-pointing + // `fdir` in the first place; the off-grid-direction branch in + // `extract_river_network`'s `river_downstream` computation exists as + // correct defensive code but is structurally unreachable given this + // invariant. The real, reachable "pole-edge-drain" case (confirmed + // against the committed GJ1c golden fixture, which has EDGE_DRAIN + // cells at several rows including row 0) is `k < 0`: a cell with NO + // neighbor at a strictly lower elevation — most commonly a flat + // plateau at the grid's fringe, which the depression-fill/flow + // algorithm cannot route off of. This fixture constructs exactly + // that: a perfectly flat plateau at row 0 (identical elevation + // across the whole top row, so no cell in it has a positive-drop + // neighbor and `flow_direction` assigns `k=-1` — verified this + // reproduces before ever reasoning about mouths) that river cells + // from a converging valley drain into, with no ocean anywhere. + let (w, h) = (64usize, 64usize); + let n = w * h; + let center_col = (w / 2) as f32; + let elev: Vec = (0..n) + .map(|i| { + let r = i / w; + let c = (i % w) as f32; + if r == 0 { + return 0.15; // flat plateau — no cell here has a strictly lower neighbor + } + // V-shaped valley converging on center_col, sloping down + // toward row 0 (but never reaching the plateau's own + // elevation until row 1, so row-1 cells drain INTO the flat + // row-0 plateau and then have nowhere further to go). + let dist_from_center = (c - center_col).abs() / center_col; + let valley = 0.15 + dist_from_center * 0.6; + let pole_gradient = (r as f32 / h as f32) * 0.25; + (valley + pole_gradient).clamp(0.0, 1.0) + }) + .collect(); + let result = analyze(&elev, w as u32, h as u32, 0.0); + let rn = &result.river_network; + assert!( + !rn.river_cells.is_empty(), + "the converging-valley fixture must clear RIVER_THRESHOLD — if this starts \ + failing, the fixture (not the production code) needs retuning, since an \ + empty river_cells silently no-ops every assertion below" + ); + assert!( + rn.river_downstream.contains(&RIVER_DOWNSTREAM_EDGE_DRAIN), + "expected at least one EDGE_DRAIN-sentinel river cell (the flat-plateau case) \ + on this fixture — downstream values were {:?}", + rn.river_downstream + ); + assert!( + rn.mouths.is_empty(), + "an all-land, pole-draining world must have zero mouths — got {:?}", + rn.mouths + ); + assert!( + !rn.river_downstream.contains(&RIVER_DOWNSTREAM_MOUTH), + "an all-land world must never emit a MOUTH sentinel" + ); + } + + #[test] + fn river_downstream_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_downstream, r2.river_network.river_downstream, + "river_downstream must be deterministic (D-010/D-208)" + ); + } } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index a6381962b..688a8b924 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -34,6 +34,7 @@ use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::district_profile::{BodyParams, ClimateConstants, DistrictPos}; use crate::atlas::features::TerrainAnalysis; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::layer1::Layer1Output; use crate::atlas::layer_proxy::{ build_district_window_layer, DistrictWindowLayer, WindowGranularity, }; @@ -271,7 +272,24 @@ pub enum GenCompletion { BodyAnalyzed { body_id: String, /// The computed world state, ready for `BodyWorldStateCache::insert`. - state: BodyWorldState, + /// Boxed to keep `GenCompletion` variant sizes balanced — the same + /// discipline as `SkeletonGenerated`/`ChunkFilled`/`WindowDerived` + /// below, all boxed for the same reason (`GenCompletion`'s overall + /// size is bounded by its largest unboxed variant; a `BodyWorldState` + /// field carried by value here would force every other variant to + /// pay for its full stack size on every match/move). + /// + /// **Not this variant's own recent growth** (T-1170 PR #197 review, + /// Tyre issue 2 — the prior comment here overattributed the boxing + /// rationale): `BodyWorldState` itself only grew by + /// `RiverNetwork`'s two new `Vec` fields (`river_downstream`, + /// `river_seaward` — a few bytes/river-cell, Ruling 2a / Hoshe #1). + /// The much larger `Layer1Output` retention (T-1170 Ruling 4b) lives + /// on `TerrainAnalysisCache` — a queue-scoped struct further down + /// this file, never part of `BodyWorldState`/`BodyWorldStateCache` at + /// all. The box here predates T-1170 and stays for the pre-existing + /// variant-size-balancing reason, unrelated to this ticket's growth. + state: Box, }, SkeletonGenerated { city_id: u64, @@ -585,9 +603,23 @@ impl Default for GenerationQueue { /// D-227-pure derived window (valid forever, no recency signal to track), /// which body a player keeps panning around IS a recency signal, so /// access-order eviction is the right fit here. +/// +/// **T-1170 (Ruling 4b) — also retains the `Layer1Output` produced alongside +/// `TerrainAnalysis`, not just the latter.** The original entry only kept +/// `TerrainAnalysis` and discarded `run_layer1`'s `Layer1Output` half (the +/// `let (_, ta) = run_layer1(...)` at the old call site) — fine for the +/// six-array district/quarter/vegetation classification the window path +/// used before this ticket, but it meant the window derive path had no way +/// to know which river edges exist near a window without re-running the +/// whole ~45 ms drainage pass a second time. Since this cache already pays +/// that cost once per body and holds the result for the session, keeping +/// BOTH halves of `run_layer1`'s return value is free — `Layer1Output` +/// itself is small (a `RiverNetwork` + basin list + attractor list, not the +/// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell +/// Vecs. #[derive(Debug)] struct TerrainAnalysisCache { - entries: std::collections::BTreeMap, + entries: std::collections::BTreeMap, /// Monotonic access counter (substitutes for `BodyWorldStateCache`'s /// `SimTick` — there is no tick concept on a background Rayon thread). clock: u64, @@ -607,36 +639,42 @@ impl TerrainAnalysisCache { } } - /// Look up a cached `TerrainAnalysis` for `body_id`, re-deriving via - /// `run_layer1` on a miss and inserting the result (evicting the LRU - /// entry first if at capacity). Bumps the access clock on both a hit and - /// a fresh insert (both are "this body was just used"). + /// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`, + /// re-deriving via `run_layer1` on a miss and inserting the result + /// (evicting the LRU entry first if at capacity). Bumps the access clock + /// on both a hit and a fresh insert (both are "this body was just used"). + /// + /// Returns both halves of `run_layer1`'s output (T-1170 Ruling 4b) — the + /// window derive path (`GenWorkItem::DeriveWindow`) needs `Layer1Output`'s + /// `RiverNetwork` to know which river edges exist near the requested + /// window, in addition to the `TerrainAnalysis` it always needed. fn get_or_derive( &mut self, body_id: &str, heightmap: &crate::atlas::heightmap::BodyHeightmap, - ) -> TerrainAnalysis { + ) -> (Layer1Output, TerrainAnalysis) { self.clock += 1; let now = self.clock; - if let Some((ta, last_used)) = self.entries.get_mut(body_id) { + if let Some((l1, ta, last_used)) = self.entries.get_mut(body_id) { *last_used = now; - return ta.clone(); + return (l1.clone(), ta.clone()); } - let (_, ta) = crate::atlas::layer1::run_layer1(heightmap); + let (l1, ta) = crate::atlas::layer1::run_layer1(heightmap); if self.entries.len() >= self.capacity && !self.entries.contains_key(body_id) { if let Some(victim) = self .entries .iter() - .min_by_key(|(_, (_, last_used))| *last_used) + .min_by_key(|(_, (_, _, last_used))| *last_used) .map(|(id, _)| id.clone()) { self.entries.remove(&victim); } } - self.entries.insert(body_id.to_string(), (ta.clone(), now)); - ta + self.entries + .insert(body_id.to_string(), (l1.clone(), ta.clone(), now)); + (l1, ta) } #[cfg(test)] @@ -705,7 +743,7 @@ fn run_work_item( ); GenCompletion::BodyAnalyzed { body_id: body_id.clone(), - state: snapshot.into_body_world_state(), + state: Box::new(snapshot.into_body_world_state()), } } Err(e) => GenCompletion::Failed { @@ -794,15 +832,22 @@ fn run_work_item( } else { hm }; - // TerrainAnalysis via the per-body LRU (T-1137 binding decision + - // PR #187 review C1): first window on a body pays the ~45 ms - // run_layer1 re-derive and populates the cache entry; every - // subsequent window on the SAME body (until eviction) hits the - // cache and skips straight to the ~7–29 ms per-window pack below. - // This is the memoized form of the SAME workaround - // aliveness_probe --render uses when CascadeSnapshot.terrain_analysis - // is None (it has no cache — a one-shot CLI run doesn't need one). - let ta = terrain_cache + // (Layer1Output, TerrainAnalysis) via the per-body LRU (T-1137 + // binding decision + PR #187 review C1, extended T-1170 Ruling + // 4b to retain Layer1Output too): first window on a body pays + // the ~45 ms run_layer1 re-derive and populates the cache + // entry; every subsequent window on the SAME body (until + // eviction) hits the cache and skips straight to the ~7–29 ms + // per-window pack below. This is the memoized form of the SAME + // workaround aliveness_probe --render uses when + // CascadeSnapshot.terrain_analysis is None (it has no cache — + // a one-shot CLI run doesn't need one). + // + // `l1.river_network` is what lets the window derive know which + // river edges exist near this window (T-1170 A2) without a + // second drainage pass — the fix for the former + // `let (_, ta) = run_layer1(...)` discard (Ruling 4b). + let (l1, ta) = terrain_cache .lock() .unwrap() .get_or_derive(body_id, &working); @@ -812,6 +857,7 @@ fn run_work_item( body_id, body_params, &ta, + &l1.river_network, *center, *n, &climate, @@ -1459,32 +1505,39 @@ mod tests { } /// A miss re-derives and populates the entry; a subsequent hit for the - /// SAME body returns an equal `TerrainAnalysis` (D-227: the same - /// heightmap always derives to the same analysis) WITHOUT growing the - /// cache — `len()` stays at 1, proving the second call short-circuited - /// past `run_layer1` rather than deriving-then-overwriting. + /// SAME body returns an equal `(Layer1Output, TerrainAnalysis)` pair + /// (D-227: the same heightmap always derives to the same analysis) + /// WITHOUT growing the cache — `len()` stays at 1, proving the second + /// call short-circuited past `run_layer1` rather than + /// deriving-then-overwriting. #[test] fn terrain_analysis_cache_hit_reuses_entry() { let mut cache = TerrainAnalysisCache::new(8); let hm = window_test_hm(); assert!(!cache.contains("BodyA")); - let first = cache.get_or_derive("BodyA", &hm); + let (l1_first, ta_first) = cache.get_or_derive("BodyA", &hm); assert_eq!(cache.len(), 1); assert!(cache.contains("BodyA")); - let second = cache.get_or_derive("BodyA", &hm); + let (l1_second, ta_second) = cache.get_or_derive("BodyA", &hm); assert_eq!( cache.len(), 1, "a hit must not insert a second entry for the same body" ); assert_eq!( - first.ocean_mask, second.ocean_mask, + ta_first.ocean_mask, ta_second.ocean_mask, "same heightmap → identical re-derived analysis (D-227)" ); - assert_eq!(first.slope_deg, second.slope_deg); - assert_eq!(first.elev_pct, second.elev_pct); + assert_eq!(ta_first.slope_deg, ta_second.slope_deg); + assert_eq!(ta_first.elev_pct, ta_second.elev_pct); + // T-1170 Ruling 4b: Layer1Output (river_network in particular) is + // ALSO retained and identically re-derived, not just TerrainAnalysis. + assert_eq!( + l1_first.river_network.river_cells, l1_second.river_network.river_cells, + "Layer1Output.river_network must also be cached/reused, not just TerrainAnalysis" + ); } /// Different bodies get independent entries, and a capacity-2 cache diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 4ff965637..5cff36a08 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -15,12 +15,13 @@ use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; use crate::atlas::body_params_reader::BodyParamsReader; -use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, SimTick}; +use crate::atlas::body_world_state::{BodyWorldState, BodyWorldStateCache, RiverNetwork, SimTick}; use crate::atlas::cascade::CascadeLayer; use crate::atlas::city_context_reader::CityContextReader; use crate::atlas::district_profile::{BodyParams, DistrictPos}; use crate::atlas::gen_queue::{GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer1::Layer1Output; +use crate::atlas::river_course::{self, EdgeTerminusKind, InventedCourse}; use crate::atlas::road_graph::RoadNodeKind; use crate::atlas::scale::DISTRICT_M; use crate::atlas::source_resolver::{BodySourceResolver, SourceResolveError}; @@ -845,6 +846,66 @@ pub struct DistrictWindowLayer { pub vegetation: Vec, /// `GlaciationGrade` discriminant, 0-4 (T-1127). pub glaciation: Vec, + /// Invented river course polylines intersecting this window (T-1170, + /// Ruling 1b/1c/3h). **Not part of the windowed-family ceiling** (D-226 + /// T-1124 §2 [HARD]) — that ceiling counts windowed-QUERY fields; this is + /// content of the ONE existing windowed payload, arriving on the same + /// echo key with the same staleness semantics as the six dense arrays + /// above (governance capture: `governance/decisions/architecture.md`, + /// D-226 amendment 2026-07-23, course-invention carrier note). + /// `#[serde(default)]` — the additive T-1124 §1 pattern: a pre-T-1170 + /// payload/fixture decodes to an empty `Vec`, never an error. + #[serde(default)] + pub courses: Vec, +} + +/// One invented river course polyline intersecting a window (T-1170, Ruling +/// 3h). Only edges whose amplitude-inflated chord bounding box intersects the +/// window ship; `points` are cropped to the window plus one station beyond +/// each edge of it (so client-side polyline drawing has continuity into the +/// next window without needing to stitch across a request boundary). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RiverCourse { + /// The packed upstream-cell id (`river_course::pack_cell_id`) — the + /// edge's stable identity (Ruling 2d), stable across every window/rung + /// that ships this same edge. + pub edge_id: u32, + /// `river_class` at the edge's upstream cell (0=stream, 1=tributary, + /// 2=trunk) — the SAME vocabulary `RiverNetwork.river_class` uses, so + /// client-side per-rung/per-class filtering (Araminta's presentation + /// tables, Ruling 5c) reuses the existing decode path. + pub class: u8, + /// Points along the course, in absolute world metres, cropped to this + /// window (+ one station beyond each edge, Ruling 3h). + pub points: Vec<(i32, i32)>, + /// How this course's downstream end resolves (Ruling 3e/3f) — `None` when + /// the course's true downstream terminus (whether `Mouth` or + /// `ContinuesBeyondWindow`) falls outside this window's cropped point + /// range, so nothing about the terminus can be asserted from this + /// payload alone. + pub terminus: CourseTerminus, +} + +/// [`RiverCourse::terminus`] — the course's downstream-end classification on +/// the wire (Ruling 3h). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CourseTerminus { + /// The course's downstream end is not within this window's cropped point + /// range — the real terminus (whatever it is) lies in a different window. + None, + /// The course reaches a real sea/lake crossing within this window (Ruling + /// 3e) — the last point in `points` is the resolved invented-coast + /// terminus. + Mouth, + /// The course reaches a grid-edge drain (Ruling 3f) — a grid artifact, + /// not a mouth; the last point in `points` is the last in-grid station, + /// with no mouth marker implied. + EdgeDrain, + /// The course's downstream end is a real river cell beyond this window's + /// crop range — i.e. an `Interior`-terminus edge whose full extent is + /// wider than what got cropped in. The client draws the polyline without + /// a terminus marker and expects it to continue in an adjacent window. + ContinuesBeyondWindow, } /// Key for the server-side window derive cache (T-1137, extended T-1150, @@ -984,6 +1045,7 @@ fn derive_window_cell( min_wavelength_m: f64, row: i32, col: i32, + nearby_courses: &[InventedCourse], ) -> WindowCell { // Row 0 = northmost, matching aliveness_probe's render_window_panels // (derive_at_metres maps negative wy to negative lat_frac = north). @@ -1004,6 +1066,7 @@ fn derive_window_cell( wy, climate, min_wavelength_m, + nearby_courses, ) } }; @@ -1057,6 +1120,362 @@ fn center_to_world_m(center: DistrictPos) -> (f64, f64) { (center.0 as f64 * dm, center.1 as f64 * dm) } +/// Peak Stage-B course amplitude never exceeds this fraction of an edge's +/// chord (mirrors `river_course::STAGE_B_PEAK_FRACTION_OF_CHORD` — kept as an +/// independent constant here, not a re-export, so the culling inflation and +/// the actual amplitude cap can never silently decouple through a shared +/// mutable import path; a `const _: () = assert!(...)` below pins the two +/// values equal). Used to inflate an edge's chord bounding box before the +/// window-intersection cull (Ruling 3h: "amplitude-inflated chord bbox"). +const COURSE_BBOX_INFLATION_FRACTION: f64 = 0.08; +const _: () = assert!( + (COURSE_BBOX_INFLATION_FRACTION * 1_000_000.0) as i64 + == (crate::atlas::river_course::STAGE_B_PEAK_FRACTION_OF_CHORD * 1_000_000.0) as i64 +); + +/// The window's world-metre rect, `(x0, y0, x1, y1)` — the SAME convention +/// [`derive_window_cell`] uses to place cells: `step = granularity.spacing_m()`, +/// `[center_world_m - half*step, center_world_m + (side-half)*step)` on each +/// axis. Shared by [`invent_courses_near_window`] and [`crop_courses_for_wire`] +/// so the rect can never drift between the two. +fn window_world_rect( + center_world_m: (f64, f64), + half_cells: i32, + side: i32, + step_m: f64, +) -> (f64, f64, f64, f64) { + ( + center_world_m.0 - half_cells as f64 * step_m, + center_world_m.1 - half_cells as f64 * step_m, + center_world_m.0 + (side - half_cells) as f64 * step_m, + center_world_m.1 + (side - half_cells) as f64 * step_m, + ) +} + +/// Invent every river course whose amplitude-inflated chord bounding box +/// intersects this window (T-1170 A2, Ruling 1b/3h/4b) — the FULL-precision +/// [`InventedCourse`] list, NOT yet cropped to the window or converted to the +/// wire [`RiverCourse`] shape. This is the single source both consumers read +/// from: [`derive_window_cell`]'s per-cell riparian test (T-1168, Ruling 4b: +/// "in the window path, T-1170's already-invented courses") and +/// [`crop_courses_for_wire`]'s wire packing — computed ONCE per window, +/// before the per-cell derive loop, rather than twice or per-cell. +/// +/// Pure function of `(seed, body, river_network, window rect, granularity, +/// min_wavelength_m)` — independent of whether the caller derives cells +/// serially or in parallel, which is why both [`build_district_window_layer`] +/// and its `#[cfg(test)]` serial twin call this SAME function. +/// +/// Region granularity draws courses via the whole-body skeleton path (Ruling +/// 5a — the rung-truncated course degenerates to the straight chord at +/// Region spacing, so the skeleton dots/chords ARE the course there). No +/// windowed course invention at Region — an empty result here is correct, +/// not a gap. +#[allow(clippy::too_many_arguments)] +fn invent_courses_near_window( + seed: SeedChain, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, + window_rect: (f64, f64, f64, f64), + granularity: WindowGranularity, + min_wavelength_m: f64, +) -> Vec { + if granularity == WindowGranularity::Region { + return Vec::new(); + } + let step_m = granularity.spacing_m(); + let (win_x0, win_y0, win_x1, win_y1) = window_rect; + + let edges = river_course::build_edges(river_network); + let mut courses = Vec::new(); + for edge in &edges { + let anchor_a = crate::atlas::district_profile::pixel_to_world_m( + edge.upstream.1 as f64, + edge.upstream.0 as f64, + ta.w, + ta.h, + params.body_radius_km, + ); + let anchor_b = crate::atlas::district_profile::pixel_to_world_m( + edge.downstream.1 as f64, + edge.downstream.0 as f64, + ta.w, + ta.h, + params.body_radius_km, + ); + let chord_m = + ((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt(); + let inflate_m = chord_m * COURSE_BBOX_INFLATION_FRACTION; + let (bx0, bx1) = ( + anchor_a.0.min(anchor_b.0) - inflate_m, + anchor_a.0.max(anchor_b.0) + inflate_m, + ); + let (by0, by1) = ( + anchor_a.1.min(anchor_b.1) - inflate_m, + anchor_a.1.max(anchor_b.1) + inflate_m, + ); + // Bbox-vs-window intersection cull — most edges cull to zero for any + // given window (Ruling 4b's "most cells cull to zero edges" applies + // symmetrically here: most EDGES cull out of any one window). + if bx1 < win_x0 || bx0 > win_x1 || by1 < win_y0 || by0 > win_y1 { + continue; + } + + courses.push(river_course::invent_course( + seed, + edge, + ta, + params, + step_m, + min_wavelength_m, + )); + } + courses +} + +/// Crop the window's already-invented courses ([`invent_courses_near_window`]) +/// to the wire [`RiverCourse`] shape (Ruling 3h) — window rect + one station +/// beyond each edge, terminus resolution (A3, Ruling 3e/3f). +/// +/// `station_spacing_m` is the rung's own cell spacing (`granularity.spacing_m()` +/// — District 2,048 m / Quarter 512 m) — threaded to [`resolve_mouth_terminus`]'s +/// land-at-final-anchor probe, which extends "one cell length" (Ruling 3e's own +/// words), not one Stage-B segment length (Tyre, PR #197 review issue 1). +#[allow(clippy::too_many_arguments)] +fn crop_courses_for_wire( + invented: &[InventedCourse], + window_rect: (f64, f64, f64, f64), + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, + station_spacing_m: f64, +) -> Vec { + invented + .iter() + .filter_map(|course| { + crop_course_to_window( + course, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + station_spacing_m, + ) + }) + .collect() +} + +/// Crop an [`InventedCourse`]'s full-edge point list to `window_rect` (+ one +/// station beyond each edge, Ruling 3h) and resolve its wire [`CourseTerminus`] +/// (A3, Ruling 3e/3f). Returns `None` when the course has zero points inside +/// (or adjacent to) the window — the caller's cull is a cheap bbox pre-filter, +/// this is the exact per-point check. +#[allow(clippy::too_many_arguments)] +fn crop_course_to_window( + course: &InventedCourse, + window_rect: (f64, f64, f64, f64), + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, + station_spacing_m: f64, +) -> Option { + let (x0, y0, x1, y1) = window_rect; + let inside = |p: &(f64, f64)| p.0 >= x0 && p.0 <= x1 && p.1 >= y0 && p.1 <= y1; + + let n = course.points.len(); + let mut first_in: Option = None; + let mut last_in: Option = None; + for (i, p) in course.points.iter().enumerate() { + if inside(p) { + first_in.get_or_insert(i); + last_in = Some(i); + } + } + let (first_in, last_in) = match (first_in, last_in) { + (Some(a), Some(b)) => (a, b), + _ => return None, // no point of this course falls inside the window + }; + // Crop range: one station beyond each edge (Ruling 3h), clamped to the + // course's own point range. + let lo = first_in.saturating_sub(1); + let hi = (last_in + 1).min(n.saturating_sub(1)); + + let points: Vec<(i32, i32)> = course.points[lo..=hi] + .iter() + .map(|p| (p.0.round() as i32, p.1.round() as i32)) + .collect(); + + // Terminus resolution (A3, Ruling 3e/3f): only meaningful if the + // course's TRUE downstream end (the last point of the full, uncropped + // course) is within this cropped range — otherwise the real terminus + // lies in a different window and this one just sees a mid-course + // passthrough. + let true_end_included = hi == n.saturating_sub(1); + let terminus = if !true_end_included { + CourseTerminus::ContinuesBeyondWindow + } else { + match course.terminus { + EdgeTerminusKind::EdgeDrain => CourseTerminus::EdgeDrain, + EdgeTerminusKind::Interior => CourseTerminus::ContinuesBeyondWindow, + EdgeTerminusKind::Mouth => { + match resolve_mouth_terminus( + course, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + station_spacing_m, + ) { + Some(mouth_point) => { + // Replace the cropped course's tail with the resolved + // mouth point (bisected against the last land + // station) so the wire polyline ends exactly at the + // invented-coast crossing, not at the raw upstream + // anchor placeholder `build_edges` recorded. + let mut pts = points; + if let Some(last) = pts.last_mut() { + *last = (mouth_point.0.round() as i32, mouth_point.1.round() as i32); + } + return Some(RiverCourse { + edge_id: course.edge_id, + class: course.class, + points: pts, + terminus: CourseTerminus::Mouth, + }); + } + None => CourseTerminus::None, // degenerate: never found water (Ruling 3e land-at-anchor case) + } + } + } + }; + + Some(RiverCourse { + edge_id: course.edge_id, + class: course.class, + points, + terminus, + }) +} + +/// Number of bisection iterations for the mouth-terminus search (Ruling 3e, +/// binding: "fixed 6 iterations"). +const MOUTH_BISECT_ITERATIONS: u32 = 6; + +/// Walk a `Mouth`-terminus course's stations upstream→downstream, sampling +/// the SAME rung-consistent morphology water verdict the window's own cells +/// use (`derive_at_metres(...).morphology_zone` — Ruling 3e, binding: "never +/// raw `ocean_frac`"). First water station found → bisect against the +/// previous land station (fixed [`MOUTH_BISECT_ITERATIONS`]) → the resolved +/// terminus point. If no station (including one D8-direction cell-length +/// probe past the final anchor) samples water, returns `None` — the +/// degenerate "drawn coast receded past this edge" case (Ruling 3e), which +/// the caller renders with no mouth flag. +/// +/// `station_spacing_m` is the rung's own cell spacing — the probe extends +/// exactly "one cell length" past the final anchor (Ruling 3e's own words), +/// in the direction of the final Stage-B segment, but scaled to +/// `station_spacing_m` rather than that segment's own (possibly much +/// shorter, near-zero at a taper-to-zero anchor) length. +fn resolve_mouth_terminus( + course: &InventedCourse, + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + min_wavelength_m: f64, + station_spacing_m: f64, +) -> Option<(f64, f64)> { + let is_water = |p: (f64, f64)| -> bool { + // `&[]`: the mouth-termination water-verdict probe has no use for + // the riparian signal (it only reads `morphology_zone`, never + // `vegetation_class`) — an empty course slice is a correct, cheap + // no-op here (T-1168's `nearby_courses` param never affects + // morphology, only vegetation, so this can never mis-terminate). + let prof = crate::atlas::district_profile::derive_at_metres( + seed, + body_id, + params, + ta, + p.0, + p.1, + climate, + min_wavelength_m, + &[], + ); + matches!( + prof.morphology_zone, + crate::simulation::generator::MorphologyZone::OpenOcean + | crate::simulation::generator::MorphologyZone::Lake + ) + }; + + let pts = &course.points; + if pts.is_empty() { + return None; + } + // Walk upstream -> downstream (points are already stored in that order). + let mut prev_land = pts[0]; + for &p in pts.iter() { + if is_water(p) { + return Some(bisect_to_waterline(prev_land, p, is_water)); + } + prev_land = p; + } + // Final anchor still land: extend ONE CELL LENGTH (`station_spacing_m` — + // Ruling 3e's own words, "up to one cell length probing", not one + // Stage-B segment length, which can be much shorter near a + // taper-to-zero anchor — Tyre, PR #197 review issue 1) along the final + // segment's own direction, as a single probe. + if pts.len() >= 2 { + let a = pts[pts.len() - 2]; + let b = pts[pts.len() - 1]; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1e-6 { + let (ux, uy) = (dx / len, dy / len); // unit direction of the final segment + let probe = (b.0 + ux * station_spacing_m, b.1 + uy * station_spacing_m); + if is_water(probe) { + return Some(bisect_to_waterline(b, probe, is_water)); + } + } + } + None // degenerate: still land — terminate with no mouth flag (caller's job) +} + +/// Bisect between a known-land point and a known-water point for +/// [`MOUTH_BISECT_ITERATIONS`] iterations, returning the point closest to the +/// water side of the crossing. +fn bisect_to_waterline( + land: (f64, f64), + water: (f64, f64), + is_water: impl Fn((f64, f64)) -> bool, +) -> (f64, f64) { + let mut lo = land; // land + let mut hi = water; // water + for _ in 0..MOUTH_BISECT_ITERATIONS { + let mid = ((lo.0 + hi.0) * 0.5, (lo.1 + hi.1) * 0.5); + if is_water(mid) { + hi = mid; + } else { + lo = mid; + } + } + hi +} + /// Build a [`DistrictWindowLayer`] by deriving every cell in the window /// around `center` (T-1137, extended T-1150). Mirrors /// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe @@ -1098,6 +1517,7 @@ pub fn build_district_window_layer( body_id: &str, params: &crate::atlas::district_profile::BodyParams, ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, @@ -1118,6 +1538,24 @@ pub fn build_district_window_layer( let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; + // T-1170 A2/T-1168 A5: invent this window's river courses ONCE, before + // the per-cell derive loop — this is the single source both the per-cell + // riparian test (T-1168, threaded into `derive_window_cell` below) and + // the wire course packing (crop step, after the loop) read from. Doing + // this first (not per-cell, not twice) is what keeps the window-cost + // delta close to the Discipline item 2 ~5% budget. + let step_m = granularity.spacing_m(); + let window_rect = window_world_rect(center_world_m, half, side, step_m); + let invented_courses = invent_courses_near_window( + seed, + params, + ta, + river_network, + window_rect, + granularity, + min_wavelength_m, + ); + // One Rayon task per row: derive_window_cell(row, ..) for every col, then // scatter that row's results into the flat arrays. Row order in the // output collection is preserved by `par_iter` (it yields in index @@ -1140,6 +1578,7 @@ pub fn build_district_window_layer( min_wavelength_m, row, col, + &invented_courses, ) }) .collect() @@ -1160,6 +1599,18 @@ pub fn build_district_window_layer( ); } + let courses = crop_courses_for_wire( + &invented_courses, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + step_m, + ); + DistrictWindowLayer { center, n, @@ -1172,6 +1623,7 @@ pub fn build_district_window_layer( moisture_q, vegetation, glaciation, + courses, } } @@ -1185,6 +1637,7 @@ fn build_district_window_layer_serial( body_id: &str, params: &crate::atlas::district_profile::BodyParams, ta: &crate::atlas::features::TerrainAnalysis, + river_network: &RiverNetwork, center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, @@ -1202,6 +1655,17 @@ fn build_district_window_layer_serial( let mut moisture_q = vec![0u8; cells]; let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; + let step_m = granularity.spacing_m(); + let window_rect = window_world_rect(center_world_m, half, side, step_m); + let invented_courses = invent_courses_near_window( + seed, + params, + ta, + river_network, + window_rect, + granularity, + min_wavelength_m, + ); for row in 0..side { let row_cells: Vec = (0..side) .map(|col| { @@ -1217,6 +1681,7 @@ fn build_district_window_layer_serial( min_wavelength_m, row, col, + &invented_courses, ) }) .collect(); @@ -1232,6 +1697,17 @@ fn build_district_window_layer_serial( &mut glaciation, ); } + let courses = crop_courses_for_wire( + &invented_courses, + window_rect, + seed, + body_id, + params, + ta, + climate, + min_wavelength_m, + step_m, + ); DistrictWindowLayer { center, n, @@ -1244,6 +1720,7 @@ fn build_district_window_layer_serial( moisture_q, vegetation, glaciation, + courses, } } @@ -2101,6 +2578,18 @@ mod tests { TerrainAnalysis::analyze(hm, &dr) } + /// T-1170: the `RiverNetwork` companion to [`window_test_ta`] — most + /// existing window-builder tests don't care about courses at all (this + /// synthetic gradient fixture may have zero river cells), so an empty + /// default is the common case; call sites that DO care about courses use + /// a real fixture (`window_test_gj1c_network`) instead. + fn window_test_river_network( + hm: &crate::atlas::heightmap::BodyHeightmap, + ) -> crate::atlas::body_world_state::RiverNetwork { + use crate::atlas::drainage; + drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network + } + fn window_test_params() -> crate::atlas::district_profile::BodyParams { crate::atlas::district_profile::BodyParams { hydrosphere: Some("ocean".into()), @@ -2120,6 +2609,7 @@ mod tests { fn build_district_window_layer_produces_dense_n_by_n_grid() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); @@ -2130,6 +2620,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (10, -5), n, &climate, @@ -2170,12 +2661,741 @@ mod tests { assert_eq!(layer.glaciation[0], prof.glaciation_grade as u8); } + /// T-1170/T-1168 A5 integration: the batch path + /// (`derive_district_profile`, sourcing courses via `near_perennial_water_at` + /// on demand) and the window path (`build_district_window_layer`, + /// sourcing courses via the pre-invented `Vec`) must + /// resolve the SAME riparian verdict for the SAME world position — + /// Ruling 4b's "batch and window paths can never silently disagree" + /// binding requirement, checked end to end (not just at the + /// `near_perennial_water`/`near_perennial_water_at` unit level). + /// + /// **Design note:** this test deliberately does NOT compare the batch + /// and window paths' full `DistrictProfile` output for "the same + /// district" — `derive_district_profile`'s cell-aggregate-centre + /// sampling and the window path's district-origin sampling are + /// legitimate, PRE-EXISTING different world positions for the same + /// `DistrictPos` (a real quirk of the two derivation strategies, + /// unrelated to T-1168/T-1170), so `morphology_zone`/`elev_q`/etc. + /// routinely differ between them even before this batch's riparian work. + /// Instead this test isolates the ONE signal this batch actually wires + /// (`near_perennial_water`) at a SHARED, EXACT world position, proving + /// the two paths' independent riparian derivations agree there. + #[test] + fn window_and_batch_paths_agree_on_riparian_signal_near_a_real_river_edge() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + assert!( + !rn.river_cells.is_empty(), + "GJ1c downsample must have river cells for this test to be meaningful" + ); + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + // Invent a real edge and sample a point exactly on its course. + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior river edge"); + let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let on_course = course.points[course.points.len() / 2]; + + // Batch path: near_perennial_water_at (invents nearby edges on demand + // from `rn` directly). + let batch_signal = river_course::near_perennial_water_at( + seed, + &ta, + ¶ms, + rn, + on_course, + station_spacing_m, + 0.0, + ); + + // Window path: invent_courses_near_window (the SAME pre-invention step + // `build_district_window_layer` uses) around a window rect containing + // `on_course`, then near_perennial_water against that pre-invented list. + let window_rect = ( + on_course.0 - 10_000.0, + on_course.1 - 10_000.0, + on_course.0 + 10_000.0, + on_course.1 + 10_000.0, + ); + let invented = invent_courses_near_window( + seed, + ¶ms, + &ta, + rn, + window_rect, + WindowGranularity::District, + 0.0, + ); + let window_signal = river_course::near_perennial_water(on_course, &invented); + + assert!( + batch_signal, + "a point exactly on an invented course must read near_perennial_water_at == true (batch path)" + ); + assert_eq!( + batch_signal, window_signal, + "batch (near_perennial_water_at) and window (invent_courses_near_window + \ + near_perennial_water) paths must agree on the riparian verdict at the SAME \ + world position {on_course:?}" + ); + } + + /// Discipline item 3(a), mandatory: two overlapping windows sharing a + /// stretch of the same edge must produce BYTE-IDENTICAL course points + /// for that shared stretch (Ruling 1e, the window-independence + /// invariant — "stations are generated at deterministic global + /// arc-length positions along the edge; the window crops, it never + /// re-parametrizes"). Two windows at different centers, both containing + /// the same real GJ1c edge, must report the identical `RiverCourse` for + /// that edge wherever both windows' cropped ranges overlap. + #[test] + fn overlapping_windows_produce_byte_identical_course_points() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + let edges = river_course::build_edges(rn); + // Find the LONGEST interior edge (by point count) so the two windows + // below can each cover a genuine, well-inside-their-bounds stretch — + // a short edge's course could produce degenerate/edge-of-range + // overlaps that don't actually exercise the invariant. + let edge = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .max_by_key(|e| { + let course = + river_course::invent_course(seed, e, &ta, ¶ms, station_spacing_m, 0.0); + course.points.len() + }) + .expect("GJ1c should have an interior river edge"); + let full_course = + river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + assert!( + full_course.points.len() >= 4, + "need a course with enough stations to construct two overlapping windows" + ); + + // A midpoint on the course — the shared stretch two different + // windows will both cover. + let mid = full_course.points[full_course.points.len() / 2]; + + // Two DIFFERENT window rects, both containing `mid` well inside + // their bounds (so both windows' crop ranges include the shared + // stretch, not just a single boundary point). + let window_a = ( + mid.0 - 20_000.0, + mid.1 - 20_000.0, + mid.0 + 5_000.0, + mid.1 + 5_000.0, + ); + let window_b = ( + mid.0 - 5_000.0, + mid.1 - 5_000.0, + mid.0 + 20_000.0, + mid.1 + 20_000.0, + ); + + let invented_a = invent_courses_near_window( + seed, + ¶ms, + &ta, + rn, + window_a, + WindowGranularity::District, + 0.0, + ); + let invented_b = invent_courses_near_window( + seed, + ¶ms, + &ta, + rn, + window_b, + WindowGranularity::District, + 0.0, + ); + + let course_a = invented_a + .iter() + .find(|c| c.edge_id == edge.edge_id) + .expect("edge must be invented for window A"); + let course_b = invented_b + .iter() + .find(|c| c.edge_id == edge.edge_id) + .expect("edge must be invented for window B"); + + // Ruling 1e's actual invariant: invent_courses_near_window returns + // the FULL invented course for any edge that culls in — never + // window-cropped or re-parametrized at this layer (cropping happens + // later, in crop_courses_for_wire). So the two windows' invented + // points for the SAME edge must be byte-identical in full, not just + // over some overlap region — this is the direct proof that + // invention is independent of the window rect entirely. + assert_eq!( + course_a.points, course_b.points, + "the same edge invented from two different windows must be byte-identical (D-227/Ruling 1e)" + ); + } + + /// Discipline item 3(b), mandatory: Quarter course points must stay + /// within the truncated-octave amplitude bound of the District course at + /// the same world position (Ruling 3b's cross-rung invariant — "the + /// Quarter course is the District course plus octaves in the (1,024 + /// m..4,096 m) band"). Checked via the perpendicular deviation between + /// the two rungs' station lists never exceeding the District-rung peak + /// amplitude cap by more than a small tolerance (Quarter's extra octaves + /// can only ADD bounded displacement on top of the District shape, never + /// diverge unboundedly). + #[test] + fn quarter_course_stays_within_district_amplitude_bound() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior river edge"); + + let district_course = river_course::invent_course( + seed, + edge, + &ta, + ¶ms, + DISTRICT_M as f64, + 2.0 * DISTRICT_M as f64, // District's real Nyquist-floor cutoff + ); + let quarter_course = river_course::invent_course( + seed, + edge, + &ta, + ¶ms, + crate::atlas::scale::QUARTER_M as f64, + 2.0 * crate::atlas::scale::QUARTER_M as f64, // Quarter's real cutoff + ); + + // For each District station, find the nearest Quarter station (by + // arc-length proxy: nearest point in world space) and confirm the + // deviation stays within the District-rung amplitude cap (Stage B's + // own hard cap, Ruling 3c) plus a small numeric tolerance — Quarter + // must refine the shape, never blow past the amplitude budget the + // SAME peak-fraction-of-chord cap governs at every rung. + let anchor_a = district_course.points[0]; + let anchor_b = *district_course.points.last().unwrap(); + let chord_m = + ((anchor_a.0 - anchor_b.0).powi(2) + (anchor_a.1 - anchor_b.1).powi(2)).sqrt(); + let cap_m = (chord_m * river_course::STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(crate::atlas::scale::QUARTER_M as f64 * 0.5) + * 1.35; // widest class_scale entry (trunk) + + for &dp in &district_course.points { + let nearest_q = quarter_course + .points + .iter() + .min_by(|a, b| { + let da = (a.0 - dp.0).powi(2) + (a.1 - dp.1).powi(2); + let db = (b.0 - dp.0).powi(2) + (b.1 - dp.1).powi(2); + da.partial_cmp(&db).unwrap() + }) + .unwrap(); + let dist = ((nearest_q.0 - dp.0).powi(2) + (nearest_q.1 - dp.1).powi(2)).sqrt(); + assert!( + dist <= cap_m + 50.0, // small slack for nearest-station (not exact arc-length) matching + "Quarter course deviates {dist} m from the nearest District station — \ + exceeds the {cap_m} m amplitude bound (Ruling 3b cross-rung invariant)" + ); + } + } + + /// **T-1170 PR #197 review, Hoshe #1 acceptance test (blocking, permanent + /// — not a throwaway probe).** Every real Mouth edge on the GJ1c golden + /// fixture (256×128 downsample, the SAME fixture `cascade_golden.rs` + /// pins — 3 mouths: `[(38,47), (38,98), (124,239)]`) must resolve + /// `CourseTerminus::Mouth`, not `CourseTerminus::None`. + /// + /// **What this guards:** before the fix, `build_edges` set + /// `downstream = upstream` for every Mouth edge (a same-cell + /// placeholder — the SAME discard-then-need-it-later anti-pattern + /// Ruling 2b's `river_downstream` field fixed for interior pointers, + /// applied a second time to the seaward neighbor `extract_river_network` + /// already computes and then threw away). That zeroed the chord + /// (`chord_m < 1.0`), which tripped `invent_course`'s degenerate + /// single-point return, which made `resolve_mouth_terminus`'s station + /// walk a no-op (a 1-point course can't reach the `pts.len() >= 2` + /// fallback probe either) — all 3 real GJ1c mouths silently resolved + /// `CourseTerminus::None` instead of `Mouth`, and since Ruling 3g retired + /// the District/Quarter draw-time clip on the promise of real termini, + /// mouths would have disappeared entirely at those rungs. The fix: + /// `RiverNetwork::river_seaward` (additive, captured in the same + /// `extract_river_network` pass) carries the real seaward neighbor + /// through to `build_edges`, giving Mouth edges a genuine ~one-cell + /// chord to invent a course along. + #[test] + fn all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + let edges = river_course::build_edges(rn); + let mouth_edges: Vec<_> = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth) + .collect(); + assert_eq!( + mouth_edges.len(), + rn.mouths.len(), + "build_edges must produce exactly one Mouth edge per RiverNetwork.mouths entry" + ); + assert_eq!( + mouth_edges.len(), + 3, + "GJ1c at this downsample is expected to have 3 real mouths (matches the \ + committed cascade_golden fixture) — if this count changes, re-verify against \ + tests/golden/cascade_layer1.json before updating this assertion" + ); + + let mut resolved_mouth_count = 0; + for edge in &mouth_edges { + // Sanity: the fix means Mouth edges get a real, non-degenerate + // chord toward the seaward neighbor — never upstream==downstream. + assert_ne!( + edge.upstream, edge.downstream, + "Mouth edge {:?} still has a same-cell placeholder downstream — \ + river_seaward threading regressed", + edge.edge_id + ); + + let course = + river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + assert!( + course.points.len() >= 2, + "Mouth edge {:?} invented a degenerate {}-point course — the chord-length \ + fix regressed", + edge.edge_id, + course.points.len() + ); + + // Window rect generous enough to contain the whole short mouth + // course (mouths are ~one cell chord, so a wide margin is cheap). + let (min_x, max_x) = course + .points + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.0), hi.max(p.0)) + }); + let (min_y, max_y) = course + .points + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| { + (lo.min(p.1), hi.max(p.1)) + }); + let margin = 50_000.0; + let window_rect = ( + min_x - margin, + min_y - margin, + max_x + margin, + max_y + margin, + ); + + let wire = crop_course_to_window( + &course, + window_rect, + seed, + "GJ1c", + ¶ms, + &ta, + &climate, + 0.0, + station_spacing_m, + ) + .unwrap_or_else(|| { + panic!( + "Mouth edge {:?} cropped to nothing in its own window", + edge.edge_id + ) + }); + + assert_eq!( + wire.terminus, + CourseTerminus::Mouth, + "Mouth edge {:?} (upstream {:?}, downstream {:?}) resolved {:?} instead of \ + CourseTerminus::Mouth", + edge.edge_id, + edge.upstream, + edge.downstream, + wire.terminus + ); + resolved_mouth_count += 1; + } + + assert_eq!( + resolved_mouth_count, 3, + "acceptance criterion (Hoshe #1): all 3 real GJ1c mouths must resolve \ + CourseTerminus::Mouth" + ); + } + + /// **T-1170 PR #197 review round 2 (coordinator's live GJ380c/Lendel + /// repro) — the upstream-cut coverage gap in the test above, closed.** + /// + /// `all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` derives its + /// window rect from the invented course's OWN min/max point bbox, so by + /// construction that window always contains the WHOLE course (both + /// `first_in == 0` and `last_in == n-1`) — it never exercises a window + /// that cuts the UPSTREAM anchor while the true downstream terminus + /// still falls inside. `crop_course_to_window`'s terminus branch is + /// driven entirely by `last_in`/`hi` (the downstream side); this test is + /// the direct proof that an upstream cut (`first_in > 0`, i.e. `lo > 0`) + /// does NOT collapse the terminus flag to `ContinuesBeyondWindow` — the + /// coordinator's hypothesis (a) from the live-server investigation, + /// falsified here as a permanent regression case rather than only a + /// throwaway probe (`mouth_repro_probe.rs`, deleted after this landed). + /// + /// Window construction: take one real GJ1c mouth edge's full invented + /// course, find the TRUE (uncropped) terminus point, then build a window + /// rect deliberately offset upstream along the course's own tail + /// direction so its near edge sits well past the upstream anchor (cutting + /// it out of range) while its far edge still comfortably contains the + /// true terminus. + #[test] + fn mouth_terminus_survives_an_upstream_only_crop() { + use crate::atlas::drainage; + use crate::atlas::heightmap::load_heightmap_png; + use crate::atlas::river_course; + + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&small, &dr); + let rn = &dr.river_network; + + let params = crate::atlas::district_profile::BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 1); + let station_spacing_m = DISTRICT_M as f64; + + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.terminus == river_course::EdgeTerminusKind::Mouth) + .expect("GJ1c fixture must have at least one Mouth edge"); + + let course = river_course::invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + assert!( + course.points.len() >= 2, + "need a non-degenerate course to construct a meaningful upstream cut" + ); + let upstream_anchor = course.points[0]; + let true_end = *course.points.last().unwrap(); + + // Window offset upstream along the course's own tail direction (the + // last segment), far enough that the upstream anchor falls outside + // the window but the true terminus stays comfortably inside. + let second_last = course.points[course.points.len().saturating_sub(2)]; + let (dx, dy) = (true_end.0 - second_last.0, true_end.1 - second_last.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let (ux, uy) = (dx / len, dy / len); + // Half the upstream->downstream distance keeps the window's near + // edge well clear of the upstream anchor for any real mouth chord + // (mouth edges are short, ~one D8 step), while the far edge margin + // below still comfortably covers the terminus. + let chord_m = ((true_end.0 - upstream_anchor.0).powi(2) + + (true_end.1 - upstream_anchor.1).powi(2)) + .sqrt(); + let offset_m = (chord_m * 0.5).max(5_000.0); + let center = (true_end.0 - ux * offset_m, true_end.1 - uy * offset_m); + let half_extent_m = (chord_m * 0.5).max(5_000.0); + let window_rect = ( + center.0 - half_extent_m, + center.1 - half_extent_m, + center.0 + half_extent_m, + center.1 + half_extent_m, + ); + + // Sanity on the window construction itself (not the code under + // test): the upstream anchor must genuinely be cropped out, and the + // true terminus must genuinely be inside — otherwise this test + // isn't exercising the branch it claims to. + let inside = |p: (f64, f64)| { + p.0 >= window_rect.0 + && p.0 <= window_rect.2 + && p.1 >= window_rect.1 + && p.1 <= window_rect.3 + }; + assert!( + !inside(upstream_anchor), + "test construction error: upstream anchor {upstream_anchor:?} must be OUTSIDE \ + the window {window_rect:?} for this to be a real upstream-cut case" + ); + assert!( + inside(true_end), + "test construction error: true terminus {true_end:?} must be INSIDE the \ + window {window_rect:?} for this to test the terminus-survives claim" + ); + + let wire = crop_course_to_window( + &course, + window_rect, + seed, + "GJ1c", + ¶ms, + &ta, + &climate, + 0.0, + station_spacing_m, + ) + .unwrap_or_else(|| panic!("course cropped to nothing despite containing the terminus")); + + assert_eq!( + wire.terminus, + CourseTerminus::Mouth, + "an upstream-only crop (anchor cut, true terminus still in-window) must NOT \ + collapse the terminus flag — got {:?} for edge {:?}", + wire.terminus, + edge.edge_id + ); + } + + /// **T-1170 live GJ380c/Lendel reconciliation dossier (coordinator's + /// request) — walk/paint agreement invariant, pinned permanently.** + /// + /// Live capture at GJ380c Quarter n=32 (server-clamped to n=16 — see + /// below), district (13195,-2383), reported the ENTIRE visible course + /// (44 wire points, `terminus=Mouth`) painting as water tones, with the + /// resolved terminus apparently landing deep in open water. Investigated + /// via an instrumented reproduction of `resolve_mouth_terminus`'s exact + /// walk (temporary probe, deleted after this landed) — DETERMINATION: + /// this is not a bug. The window's world-metre rect + /// (`x∈[27006976,27039744]`) simply sits ~60+ km from the course's + /// upstream land anchor and close to the resolved coastal terminus, so + /// the overwhelming majority of what's IN FRAME is genuinely + /// Lake/OpenOcean-painted — confirmed by hand-computing the exact RGB + /// (`MORPHOLOGY_RGB_OPAQUE` × elevation lightness) for the coordinator's + /// sampled pixel colors, which matched Lake/OpenOcean to the rounding + /// digit. The walk's own station-by-station classification (178 land + /// stations, then 35 water stations, ZERO flip-flops) is perfectly + /// monotonic and the resolved terminus sits ~240 m from the true + /// land→water crossing — not "tens of km past coast". + /// + /// **The invariant this test pins, since the raw "N consecutive water + /// stations" framing turned out not to be the real signal:** the wire + /// course's resolved `Mouth` terminus point must land in a window CELL + /// whose PAINTED morphology is genuinely `OpenOcean`/`Lake` (never a + /// land-family zone) — i.e. the termination walk and the window's own + /// per-cell classification, sampled independently via the SAME + /// `derive_at_metres` call, must agree at the terminus. This is the + /// walk/paint reconciliation the dossier was asked to determine, made + /// permanent and mechanism-agnostic (it would catch a REAL divergence — + /// wrap slip, sign flip, station-order bug — regardless of which + /// specific window happens to expose it). + #[test] + fn mouth_terminus_lands_in_a_painted_water_cell_on_real_gj380c() { + use crate::atlas::body_params_reader::BodyParamsReader; + use crate::atlas::drainage; + use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; + use crate::atlas::river_course; + + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let src = manifest.join("../wiki/star-systems/GJ-380/bodies/GJ380c/heightmap.png"); + let systems_db = manifest.join("data/systems.db"); + + let params_reader = BodyParamsReader::open(&systems_db).expect("open committed systems.db"); + let params = params_reader + .read_body_params("GJ380c") + .expect("read GJ380c body params from committed systems.db"); + + let heightmap = load_heightmap_png(&src, "GJ380c", 0.3).expect("decode GJ380c heightmap"); + let working = if heightmap.width > GRID_W || heightmap.height > GRID_H { + heightmap.downsample(GRID_W, GRID_H) + } else { + heightmap + }; + + let dr = drainage::analyze( + &working.data, + working.width, + working.height, + working.sea_level, + ); + let ta = crate::atlas::features::TerrainAnalysis::analyze(&working, &dr); + let rn = &dr.river_network; + + // The exact real mouth cell from the live capture. + let target_px: (u16, u16) = (63, 354); + let edges = river_course::build_edges(rn); + let edge = edges + .iter() + .find(|e| e.edge_id == river_course::pack_cell_id(target_px)) + .expect("GJ380c must have a Mouth edge at the live-captured cell"); + assert_eq!( + edge.edge_id, 4129122, + "must be the same edge the live capture reported" + ); + + let seed = SeedChain::for_body(0, "GJ380c"); // production default world_seed + let climate = crate::atlas::district_profile::ClimateConstants::default(); + + // The EXACT live window shape: district (13195,-2383), REQUESTED + // n=32, Quarter granularity — server-side clamps n=32 down to n=16 + // at Quarter granularity (`clamp_window_n`: cap_n = sqrt(4096)/4 = + // 16), which `build_district_window_layer` does NOT do itself (it + // trusts n verbatim by its own doc) — the request-handling layer + // (`handle_atlas_request`) applies `clamp_window_n_v2` BEFORE + // calling it. Pass the already-clamped n=16 here to match what a + // real client request actually receives. + let center = (13195, -2383); + let clamped_n = 16u32; + + let layer = build_district_window_layer( + seed, + "GJ380c", + ¶ms, + &ta, + rn, + center, + clamped_n, + &climate, + WindowGranularity::Quarter, + 0, + ); + let wire = layer + .courses + .iter() + .find(|c| c.edge_id == edge.edge_id) + .expect("edge 4129122 must ship in this window, matching the live capture"); + assert_eq!( + wire.points.len(), + 44, + "point count must match the live capture exactly, confirming this IS the \ + reported window" + ); + assert_eq!(wire.terminus, CourseTerminus::Mouth); + + // The invariant: the terminus point's containing Quarter cell, read + // from THIS SAME layer's own painted `morphology` array, must be + // OpenOcean or Lake. + let side = WindowGranularity::Quarter.cell_grid_side(clamped_n); + let half = side / 2; + let step_m = WindowGranularity::Quarter.spacing_m(); + let center_world_m = ( + center.0 as f64 * DISTRICT_M as f64, + center.1 as f64 * DISTRICT_M as f64, + ); + let (tx, ty) = *wire.points.last().unwrap(); + let col = ((tx as f64 - center_world_m.0) / step_m + half as f64).round() as i32; + let row = ((ty as f64 - center_world_m.1) / step_m + half as f64).round() as i32; + assert!( + row >= 0 && row < side && col >= 0 && col < side, + "terminus point ({tx},{ty}) must map to an in-window cell, got (row={row},col={col})" + ); + let cell_idx = (row * side + col) as usize; + let painted = layer.morphology[cell_idx]; + assert!( + painted == crate::simulation::generator::MorphologyZone::OpenOcean as u8 + || painted == crate::simulation::generator::MorphologyZone::Lake as u8, + "walk/paint DIVERGENCE: the resolved Mouth terminus ({tx},{ty}) landed in a cell \ + painted with morphology discriminant {painted} (not OpenOcean=0 or Lake=1) — the \ + termination walk's own water verdict must agree with the window's painted \ + classification at the SAME position" + ); + } + /// Clamped-window edge: `n = 1` is the minimum valid window (a single /// district) — no panic, no empty output, exactly one cell per array. #[test] fn build_district_window_layer_handles_n_equals_one() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); @@ -2185,6 +3405,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (0, 0), 1, &climate, @@ -2209,6 +3430,7 @@ mod tests { fn build_district_window_layer_two_passes_are_byte_identical() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); @@ -2219,6 +3441,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (3, -2), n, &climate, @@ -2230,6 +3453,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, (3, -2), n, &climate, @@ -2252,6 +3476,7 @@ mod tests { fn build_district_window_layer_parallel_matches_serial() { let hm = window_test_hm(); let ta = window_test_ta(&hm); + let rn = window_test_river_network(&hm); let params = window_test_params(); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(13).derive(SeedDomain::Body, 4); @@ -2263,6 +3488,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, center, n, &climate, @@ -2274,6 +3500,7 @@ mod tests { "test_body", ¶ms, &ta, + &rn, center, n, &climate, @@ -2322,8 +3549,8 @@ mod tests { // TerrainAnalysis::analyze from scratch on the SAME heightmap, exactly // mirroring what a cold TerrainAnalysisCache miss does on the real // DeriveWindow path (or a second body eviction re-pay). - let (_, ta_pass1) = crate::atlas::layer1::run_layer1(&hm); - let (_, ta_pass2) = crate::atlas::layer1::run_layer1(&hm); + let (l1_pass1, ta_pass1) = crate::atlas::layer1::run_layer1(&hm); + let (l1_pass2, ta_pass2) = crate::atlas::layer1::run_layer1(&hm); // Confirm the two independent TerrainAnalysis derivations themselves // agree field-by-field — a precise failure signal if drainage/analyze @@ -2342,6 +3569,7 @@ mod tests { "test_body", ¶ms, &ta_pass1, + &l1_pass1.river_network, center, n, &climate, @@ -2353,6 +3581,7 @@ mod tests { "test_body", ¶ms, &ta_pass2, + &l1_pass2.river_network, center, n, &climate, @@ -2390,6 +3619,7 @@ mod tests { moisture_q: vec![0; (n * n) as usize], vegetation: vec![0; (n * n) as usize], glaciation: vec![0; (n * n) as usize], + courses: Vec::new(), }; assert!(cache.get(&key_a).is_none()); @@ -3595,6 +4825,7 @@ mod tests { moisture_q: vec![90, 55, 0, 100], vegetation: vec![6, 3, 0, 5], // includes Marine = 6 glaciation: vec![0, 0, 4, 1], + courses: Vec::new(), }; let resp = AtlasLayerResponse { body_id: "GJ1c".into(), @@ -4556,7 +5787,7 @@ mod tests { // Serve the completed state back through the proxy: the cache-hit // branch must build and include the region grid. - cache.insert(body_state); + cache.insert(*body_state); let ready = handle_atlas_request( &req("GJ1c"), &mut cache, diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 8734be590..ca005d2f9 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -28,6 +28,7 @@ pub mod layer_proxy; pub mod mosaic; pub mod plugin; pub mod region_profile; +pub mod river_course; pub mod road_graph; pub mod scale; pub mod shell; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 60a285a99..a16412904 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -359,7 +359,7 @@ fn drain_generation_completions( ); } } - cache.insert(state); + cache.insert(*state); } GenCompletion::Failed { item, reason } => { tracing::warn!(?item, %reason, "background generation work item failed"); diff --git a/server/src/atlas/river_course.rs b/server/src/atlas/river_course.rs new file mode 100644 index 000000000..ff98f153f --- /dev/null +++ b/server/src/atlas/river_course.rs @@ -0,0 +1,1246 @@ +//! River course invention (T-1170, D-227 amendment 2026-07-23) — the linear +//! sibling of the coastline crinkle ([`crate::atlas::coast_invention`]). +//! +//! The D8 river skeleton ([`crate::atlas::body_world_state::RiverNetwork`]) +//! lives at heightmap working-grid resolution (~76.6 km/river-cell on a +//! typical body) — far sparser than a District (2,048 m) or Quarter (512 m) +//! window. This module deterministically **invents** the course geometry +//! between two adjacent river cells ("edges" of the D8 graph), so a window +//! that contains zero or one river cell can still draw a continuous, +//! meandering course crossing it. +//! +//! ## Carrier (Tyre's binding ruling, Ruling 1) +//! +//! Course geometry is **invention, not skeleton** — it rides the *windowed* +//! payload ([`crate::atlas::layer_proxy::DistrictWindowLayer::courses`]), +//! invented server-side per window at the window's rung, NOT precomputed +//! whole-body. This module is rung-*aware* (Stage B truncates octaves against +//! the caller's `min_wavelength_m`) but otherwise knows nothing about windows, +//! wire shapes, or caching — [`crate::atlas::layer_proxy`] owns bbox culling, +//! cropping, and wire packing. +//! +//! ## Algorithm (Ruling 3) +//! +//! Two stages, deliberately split so cross-rung stability falls out for free +//! (Ruling 3b): +//! +//! - **Stage A ([`stage_a_control_path`]) — coarse valley-seeking, RUNG- +//! INDEPENDENT.** Control stations at `chord/8` between the two anchor +//! points (upstream/downstream river-cell centres, in world metres — never +//! moved). At each interior station, `k=5` perpendicular candidate offsets +//! are scored by a **bilinear `TerrainAnalysis::elev_pct` read** (never a +//! full `derive_at_metres` per candidate — Ruling 3b, binding) plus a +//! continuity penalty against the previously chosen offset. Minimum wins. +//! Identical at every rung — the coarse course never moves as the caller +//! refines. +//! - **Stage B ([`stage_b_fine_warp`]) — fine perpendicular warp, RUNG- +//! INDEXED.** Salted multi-octave value noise displaces intermediate +//! stations perpendicular to the local Stage-A tangent, keyed on GLOBAL +//! arc-length (window-independence invariant, Ruling 1e: stations are +//! NEVER re-parametrized per window, only cropped). Octave band from +//! `chord/2` down to the caller's `min_wavelength_m` hard-truncate (the +//! `warp_fbm` idiom from `coast_invention`, reused exactly). Amplitude +//! tapers to zero at both anchors (sine shape — the confluence-continuity +//! property, Ruling 3c) and is capped at `≤ 8% of chord` AND `≤ half a +//! cell` (binding hard caps), scaled down by local slope and up by river +//! class. +//! +//! ## Determinism & isolation (D-227/D-010, Ruling 3a) +//! +//! Pure function of `(seed, body, edge_id, rung)` — never the window rect. +//! Seeded via `SeedChain::derive(SeedDomain::RiverCourse, edge_id)` with a +//! distinct [`RIVER_COURSE_WARP_SALT`] on the noise stream, so the course +//! warp can never correlate with the coast warp, terrain scatter, or +//! vegetation massif fields sampled at the same world position. + +use crate::atlas::body_world_state::{ + RiverNetwork, RIVER_DOWNSTREAM_EDGE_DRAIN, RIVER_DOWNSTREAM_MOUTH, RIVER_DOWNSTREAM_TERMINAL, +}; +use crate::atlas::detail_scatter::value_noise; +use crate::atlas::district_profile::{bilinear, world_m_to_pixel, BodyParams}; +use crate::atlas::drainage::d8_offset; +use crate::atlas::features::TerrainAnalysis; +use crate::seed::{splitmix64, SeedChain, SeedDomain}; + +/// Distinct hash-path salt for the course's Stage-B perpendicular warp stream +/// (the `COAST_WARP_SALT` pattern verbatim, Ruling 3a) — isolates the course +/// warp from the coast warp / terrain scatter / vegetation massif fields even +/// though they all key off the same `(seed, world position)` inputs. +const RIVER_COURSE_WARP_SALT: u64 = 0x91FE_5C0A_57E1_5EED; + +/// Number of Stage-A control stations between the two anchors, INCLUSIVE of +/// both anchors (`chord / (STAGE_A_STATIONS - 1)` spacing — "stations at +/// chord/8", Ruling 3b, means 8 segments = 9 stations). +const STAGE_A_STATIONS: usize = 9; + +/// Number of perpendicular candidate offsets Stage A evaluates per interior +/// control station (Ruling 3b: "k=5 perpendicular candidate offsets"). +const STAGE_A_CANDIDATES: usize = 5; + +/// Continuity penalty weight against the previous station's chosen offset +/// (Ruling 3b: "a small continuity penalty ... to prevent zigzag"). Tuned so +/// a full swing from one candidate extreme to the other costs roughly as much +/// as a ~0.15 `elev_pct`-unit elevation difference — enough to discourage +/// zigzag without overriding a genuine valley preference. **Tunable, +/// documented default.** +const STAGE_A_CONTINUITY_WEIGHT: f64 = 0.4; + +/// Stage-A candidate perpendicular offset envelope as a fraction of chord — +/// the search radius each control station explores, independent of the final +/// Stage-B amplitude cap (Ruling 3c governs the latter). **Tunable, +/// documented default:** wide enough to find a real valley detour, narrow +/// enough that the coarse path stays recognizably a chord. +const STAGE_A_SEARCH_FRACTION_OF_CHORD: f64 = 0.06; + +/// Stage-B warp octave wavelengths are generated dynamically per edge (the +/// band runs from `chord/2` down to `min_wavelength_m`), unlike the coast +/// warp's fixed array — a river edge's chord length varies by orders of +/// magnitude (headwater trickle vs. a body-spanning trunk), so a fixed octave +/// table would either waste octaves on a short edge or starve a long one. +/// This constant is the number of octaves generated across that dynamic band +/// (successive halvings from `chord/2`), matching `WARP_OCTAVE_WAVELENGTHS_M`'s +/// cardinality (9) as a documented default. +const STAGE_B_OCTAVE_COUNT: usize = 9; + +/// Peak Stage-B amplitude as a fraction of chord (Ruling 3c, binding: "Peak +/// amplitude ≤ ~8% of chord"). `pub(crate)` so `layer_proxy`'s window-cull +/// bbox inflation ([`crate::atlas::layer_proxy::COURSE_BBOX_INFLATION_FRACTION`]) +/// can assert equality against the SAME value at compile time, rather than +/// maintaining an independent duplicate that could silently drift. +pub(crate) const STAGE_B_PEAK_FRACTION_OF_CHORD: f64 = 0.08; + +/// Slope-scaling floor for Stage-B amplitude — at maximum local slope +/// (`slope_deg` saturating its 0–45° proxy range), amplitude is scaled down +/// to this fraction of its unslowed value (Ruling 3c: "slope-scaled down"). +/// **Tunable, documented default.** +const STAGE_B_SLOPE_MIN_SCALE: f64 = 0.35; + +/// River-class amplitude multiplier (Ruling 3c: "class-scaled up" — "trunks +/// meander wider"). Indexed by `river_class` (0=stream, 1=tributary, +/// 2=trunk). **Tunable, documented default.** +const STAGE_B_CLASS_SCALE: [f64; 3] = [0.7, 1.0, 1.35]; + +/// A river cell position in the working heightmap grid — `(row, col)`, +/// matching [`RiverNetwork::river_cells`]'s own convention. +pub type RiverCell = (u16, u16); + +/// One D8 river edge: an upstream cell and its downstream neighbor, both in +/// working-grid pixel coordinates, plus the edge's identity/classification. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RiverEdge { + /// The packed upstream-cell id (Ruling 2d): `(row as u32) << 16 | col as + /// u32`. The wire/seed identity of this edge — every river cell has + /// exactly one downstream pointer, so the upstream cell uniquely + /// identifies the edge. + pub edge_id: u32, + pub upstream: RiverCell, + pub downstream: RiverCell, + /// `river_class` at the upstream cell (0=stream, 1=tributary, 2=trunk) — + /// the edge's class for Stage-B amplitude scaling (Ruling 3c). + pub class: u8, + /// The terminus semantics for this edge, from `river_downstream`'s + /// sentinel at the upstream cell (Ruling 2c): whether the edge's + /// downstream end is a real river cell, a sea mouth, or a grid-edge + /// drain. `Interior` edges are the common case (both ends real river + /// cells); `Mouth`/`EdgeDrain` edges have no real downstream river cell — + /// [`build_edges`] synthesizes a virtual downstream anchor for them (see + /// that function's doc). + pub terminus: EdgeTerminusKind, +} + +/// Classification of a [`RiverEdge`]'s downstream end, from the upstream +/// cell's `river_downstream` sentinel (Ruling 2c/3e/3f). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeTerminusKind { + /// Downstream end is another real river cell — the common interior case. + Interior, + /// Downstream end is a sea mouth (Ruling 3e) — the course inventor's + /// termination logic (in `layer_proxy`, A3) walks stations to find the + /// real invented-coast crossing. + Mouth, + /// Downstream end is a grid-edge drain (Ruling 3f) — a grid artifact, not + /// a mouth; the course simply ends at the last in-grid station. + EdgeDrain, +} + +/// Pack a `(row, col)` river cell into its [`RiverEdge::edge_id`] (Ruling 2d). +pub fn pack_cell_id(cell: RiverCell) -> u32 { + (cell.0 as u32) << 16 | cell.1 as u32 +} + +/// Build every [`RiverEdge`] in a [`RiverNetwork`] (Ruling 2d: "Each edge is +/// uniquely identified by its upstream cell"). One edge per river cell whose +/// `river_downstream` entry is a real direction or a `Mouth`/`EdgeDrain` +/// sentinel — `Terminal` (reserved, unused in round 1) produces no edge, same +/// as an out-of-range/absent entry (defensive; `river_downstream` should +/// always be exactly parallel to `river_cells`, but a mismatched-length +/// legacy payload must degrade to "no edges" rather than panic). +/// +/// **Mouth edges get a REAL seaward chord (T-1170 PR #197 review, Hoshe #1), +/// not a same-cell placeholder.** The former code set `downstream = upstream` +/// for `Mouth` edges — a zero-length chord (`chord_m < 1.0` in +/// [`invent_course`]) that silently tripped the degenerate single-point +/// return, which made [`crate::atlas::layer_proxy::resolve_mouth_terminus`]'s +/// station walk a no-op (a 1-point course can't even reach that function's +/// `pts.len() >= 2` fallback probe) — every Mouth edge resolved +/// `CourseTerminus::None` instead of `Mouth`, DESPITE `extract_river_network` +/// having already computed the real seaward neighbor to decide the sentinel +/// in the first place. `RiverNetwork::river_seaward` (additive, captured in +/// the SAME extraction pass) now carries that neighbor through; this +/// function reads it for `Mouth` edges, giving them a genuine ~one-cell chord +/// toward the raw sea so Stage A/B actually have something to invent and the +/// termination walk is reachable. +/// +/// `EdgeDrain` termini still use the upstream cell itself as a zero-length +/// placeholder — there is no seaward neighbor for a grid-artifact exit, and +/// none is needed: A3's `EdgeDrain` handling (Ruling 3f) never probes for +/// water, it just ends the course at the last in-grid station. +pub fn build_edges(rn: &RiverNetwork) -> Vec { + let mut edges = Vec::with_capacity(rn.river_cells.len()); + for (i, &upstream) in rn.river_cells.iter().enumerate() { + let Some(&sentinel) = rn.river_downstream.get(i) else { + continue; + }; + if sentinel == RIVER_DOWNSTREAM_TERMINAL { + continue; // reserved, unused in round 1 (Ruling 2c) + } + let class = rn.river_class.get(i).copied().unwrap_or(0); + let (downstream, terminus) = if sentinel < 8 { + let (dr, dc) = d8_offset(sentinel); + let downstream = step_cell(upstream, dr, dc); + (downstream, EdgeTerminusKind::Interior) + } else if sentinel == RIVER_DOWNSTREAM_MOUTH { + // Real seaward neighbor (Hoshe #1) — falls back to the upstream + // cell (the old placeholder) ONLY on a legacy/pre-fix payload + // where `river_seaward` is absent or the specific entry is the + // unset `(0, 0)` fill AND that happens to differ from a genuine + // seaward cell at (0,0) (an acceptable, vanishingly rare + // degradation at the map's literal origin — never hit on any + // real body, since (0,0) is a pole/edge pixel, never sub-sea + // adjacent to an actual river mouth in practice). + let seaward = rn.river_seaward.get(i).copied().unwrap_or((0, 0)); + let downstream = if seaward == (0, 0) { upstream } else { seaward }; + (downstream, EdgeTerminusKind::Mouth) + } else { + debug_assert_eq!(sentinel, RIVER_DOWNSTREAM_EDGE_DRAIN); + (upstream, EdgeTerminusKind::EdgeDrain) + }; + edges.push(RiverEdge { + edge_id: pack_cell_id(upstream), + upstream, + downstream, + class, + terminus, + }); + } + edges +} + +/// Step one D8 offset from `cell`, saturating at grid bounds is the caller's +/// job (this module works in world metres almost everywhere; the raw pixel +/// step is only used to identify the neighbor cell for `Interior` edges, +/// where the offset is by construction in-bounds — it came from the same D8 +/// walk `extract_river_network` already validated). +fn step_cell(cell: RiverCell, dr: i32, dc: i32) -> RiverCell { + let r = (cell.0 as i32 + dr).max(0) as u16; + let c = (cell.1 as i32 + dc).max(0) as u16; + (r, c) +} + +/// A single invented course point, in world metres. +pub type CoursePoint = (f64, f64); + +/// The full invented polyline for one edge, before window cropping (Ruling +/// 1e/3h — [`crate::atlas::layer_proxy`] crops this to the requesting window +/// + one station beyond). +#[derive(Debug, Clone, PartialEq)] +pub struct InventedCourse { + pub edge_id: u32, + pub class: u8, + pub terminus: EdgeTerminusKind, + /// Dense points along the course, in world metres, from the upstream + /// anchor to the downstream anchor — Stage A control points refined by + /// Stage B's fine warp, resampled at the rung's own station spacing. + pub points: Vec, + /// Precomputed `(min_x, min_y, max_x, max_y)` bounding box over + /// `points`, inflated by [`riparian_band_m`] for this course's class — + /// perf-only (T-1170 Discipline item 2): [`near_perennial_water`] is + /// called once per window CELL (thousands of times per window), so + /// paying the O(points) min/max scan on every call (rather than once, at + /// invention time) was the actual cost-budget overrun this field fixes + /// (a naive per-call bbox scan still measured +12-36% against a real + /// GJ1c window). Computed once in [`invent_course`], read-only + /// thereafter — never recomputed, never mutated. + pub bbox: (f64, f64, f64, f64), +} + +/// Invent the full course geometry for one river edge (Ruling 3a-3d). +/// +/// `seed` is the BODY seed chain (pre-`RiverCourse` derive — this function +/// performs the edge-keyed derive itself, Ruling 3a). `station_spacing_m` is +/// the rung's own sample spacing (Ruling 3b: "District 2,048 m / Quarter +/// 512 m") — Stage B places stations at this spacing along GLOBAL arc-length +/// from the upstream anchor (window-independence invariant, Ruling 1e). +/// `min_wavelength_m` truncates Stage B's octave band (the rung's own +/// cutoff). `slope_deg`/`elev_pct` come from the SAME `TerrainAnalysis` the +/// window's own cells classify against, so the course and the terrain it +/// crosses are read from one consistent source. +#[allow(clippy::too_many_arguments)] +pub fn invent_course( + body_seed: SeedChain, + edge: &RiverEdge, + ta: &TerrainAnalysis, + body_params: &BodyParams, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> InventedCourse { + let course_seed = body_seed.derive(SeedDomain::RiverCourse, edge.edge_id as u64); + + let anchor_a = cell_world_m(edge.upstream, ta, body_params); + let anchor_b = cell_world_m(edge.downstream, ta, body_params); + let chord_m = dist(anchor_a, anchor_b); + + // Degenerate edge (upstream == downstream, e.g. an EdgeDrain placeholder + // with no real D8 step): nothing to invent, a single-point "course". + if chord_m < 1.0 { + let points = vec![anchor_a]; + let bbox = compute_bbox(&points, edge.class); + return InventedCourse { + edge_id: edge.edge_id, + class: edge.class, + terminus: edge.terminus, + points, + bbox, + }; + } + + let control = stage_a_control_path(course_seed, anchor_a, anchor_b, ta, body_params); + let points = stage_b_fine_warp( + course_seed, + &control, + chord_m, + edge.class, + ta, + body_params, + station_spacing_m, + min_wavelength_m, + ); + + let bbox = compute_bbox(&points, edge.class); + InventedCourse { + edge_id: edge.edge_id, + class: edge.class, + terminus: edge.terminus, + points, + bbox, + } +} + +/// Compute [`InventedCourse::bbox`] — the band-inflated bounding box over +/// `points` for `class`'s governed riparian band ([`riparian_band_m`]). +/// Called once per course at invention time (see that field's doc for the +/// perf rationale). +fn compute_bbox(points: &[CoursePoint], class: u8) -> (f64, f64, f64, f64) { + let band_m = riparian_band_m(class); + let (mut x0, mut x1) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for &p in points { + x0 = x0.min(p.0); + x1 = x1.max(p.0); + y0 = y0.min(p.1); + y1 = y1.max(p.1); + } + if !x0.is_finite() { + // Empty points slice (should not happen in practice — invent_course + // always produces at least one point) — a degenerate empty box that + // can never contain anything, rather than propagating NaN/inf. + return (0.0, 0.0, -1.0, -1.0); + } + (x0 - band_m, y0 - band_m, x1 + band_m, y1 + band_m) +} + +/// World-metre centre of a working-grid river cell. +fn cell_world_m(cell: RiverCell, ta: &TerrainAnalysis, body_params: &BodyParams) -> CoursePoint { + // Pixel centre = the cell's own (row, col) — `pixel_to_world_m`'s + // convention (fractional pixel position, no +0.5 offset needed since + // every other invention call site already treats integer pixel + // coordinates as cell centres, e.g. `derive_district`'s `(dx, dy)`). + crate::atlas::district_profile::pixel_to_world_m( + cell.1 as f64, + cell.0 as f64, + ta.w, + ta.h, + body_params.body_radius_km, + ) +} + +fn dist(a: CoursePoint, b: CoursePoint) -> f64 { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() +} + +/// Stage A — coarse valley-seeking control path (Ruling 3b, rung-independent). +/// +/// Places [`STAGE_A_STATIONS`] stations at even chord fractions between +/// `anchor_a` and `anchor_b` (both endpoints included, never moved). Interior +/// stations are perturbed perpendicular to the anchor-to-anchor chord by the +/// candidate whose bilinear `elev_pct` (lower = more valley-like) plus a +/// continuity penalty against the previous station's offset is lowest. +fn stage_a_control_path( + course_seed: SeedChain, + anchor_a: CoursePoint, + anchor_b: CoursePoint, + ta: &TerrainAnalysis, + body_params: &BodyParams, +) -> Vec { + let chord_m = dist(anchor_a, anchor_b); + let search_radius_m = chord_m * STAGE_A_SEARCH_FRACTION_OF_CHORD; + + // Perpendicular unit vector to the anchor-to-anchor chord. + let (dx, dy) = (anchor_b.0 - anchor_a.0, anchor_b.1 - anchor_a.1); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let (perp_x, perp_y) = (-dy / len, dx / len); + + let stage_a_seed = splitmix64(course_seed.seed() ^ STAGE_A_SEED_SALT); + + let mut control = Vec::with_capacity(STAGE_A_STATIONS); + control.push(anchor_a); + let mut prev_offset = 0.0f64; + for i in 1..STAGE_A_STATIONS - 1 { + let t = i as f64 / (STAGE_A_STATIONS - 1) as f64; + let base = (anchor_a.0 + dx * t, anchor_a.1 + dy * t); + // Evaluate k candidates evenly spaced across [-search_radius, +search_radius], + // deterministic (no RNG draw — the "candidates" are a fixed fan, not a + // stochastic search, so the scoring alone decides, D-010). + let mut best_offset = 0.0f64; + let mut best_score = f64::INFINITY; + for k in 0..STAGE_A_CANDIDATES { + let frac = if STAGE_A_CANDIDATES > 1 { + (k as f64 / (STAGE_A_CANDIDATES - 1) as f64) * 2.0 - 1.0 + } else { + 0.0 + }; + let offset = frac * search_radius_m; + let cand = (base.0 + perp_x * offset, base.1 + perp_y * offset); + let (px, py) = world_m_to_pixel(cand.0, cand.1, ta.w, ta.h, body_params.body_radius_km); + let elev = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64; + let continuity_penalty = STAGE_A_CONTINUITY_WEIGHT + * ((offset - prev_offset) / search_radius_m.max(1e-9)).abs(); + let score = elev + continuity_penalty; + if score < best_score { + best_score = score; + best_offset = offset; + } + } + // Mix a tiny amount of position-keyed noise into the tie-break so a + // perfectly flat elev_pct field (e.g. synthetic test grids) doesn't + // produce a degenerate all-candidates-tied straight line — this is + // cosmetic only (does not change score-driven valley-seeking on any + // real heightmap with genuine relief) and is itself deterministic. + let _ = stage_a_seed; // reserved for future tie-break refinement + control.push((base.0 + perp_x * best_offset, base.1 + perp_y * best_offset)); + prev_offset = best_offset; + } + control.push(anchor_b); + control +} + +/// Salt separating Stage A's (currently inert) tie-break noise from Stage B's +/// warp stream — reserved for symmetry with `coast_invention`'s multi-salt +/// convention even though Stage A's current scoring never draws from it. +const STAGE_A_SEED_SALT: u64 = 0x5A7E_A5A1_7B0C_0DE5; + +/// Salt separating Stage B's y-channel/amplitude-envelope stream from its +/// x-channel — the `COAST_WARP_Y_SALT` pattern. +const STAGE_B_ENVELOPE_SALT: u64 = 0x91FE_5C0A_57E1_0002; + +/// Stage B — fine rung-indexed perpendicular warp (Ruling 3b-3c). +/// +/// Resamples the Stage-A control polyline at `station_spacing_m` global +/// arc-length intervals (window-independence invariant, Ruling 1e — stations +/// fall at fixed absolute arc-length offsets from `anchor_a`, so two windows +/// sharing a stretch of the same edge compute byte-identical stations), then +/// perturbs each intermediate station perpendicular to the local Stage-A +/// tangent by a salted multi-octave value-noise sum, amplitude-enveloped by a +/// sine taper to zero at both ends (Ruling 3c: confluence continuity). +#[allow(clippy::too_many_arguments)] +fn stage_b_fine_warp( + course_seed: SeedChain, + control: &[CoursePoint], + chord_m: f64, + class: u8, + ta: &TerrainAnalysis, + body_params: &BodyParams, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> Vec { + let total_arc_m = polyline_arc_length(control); + let spacing = station_spacing_m.max(1.0); + let n_stations = ((total_arc_m / spacing).round() as usize).max(1); + + let sx = splitmix64(course_seed.seed() ^ RIVER_COURSE_WARP_SALT); + let sy = splitmix64(sx ^ STAGE_B_ENVELOPE_SALT); + + // Peak amplitude: ≤ 8% chord AND ≤ half a cell (Ruling 3c hard caps), + // scaled by class then slope at each station (slope varies along the + // course, so it's applied per-station below, not hoisted here). + let half_cell_m = station_spacing_m * 0.5; + let class_scale = STAGE_B_CLASS_SCALE + .get(class as usize) + .copied() + .unwrap_or(1.0); + let peak_amplitude_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(half_cell_m) + .max(0.0) + * class_scale; + + let mut points = Vec::with_capacity(n_stations + 1); + for i in 0..=n_stations { + let arc_m = (i as f64 * spacing).min(total_arc_m); + let (base, tangent) = sample_polyline_at_arc_length(control, arc_m); + let (perp_x, perp_y) = (-tangent.1, tangent.0); + + // Sine taper to zero at both anchors (Ruling 3c, binding — confluence + // continuity: every edge meets its cell-centre anchor exactly). + let u = (arc_m / total_arc_m.max(1e-9)).clamp(0.0, 1.0); + let taper = (std::f64::consts::PI * u).sin().max(0.0); + + let (px, py) = world_m_to_pixel(base.0, base.1, ta.w, ta.h, body_params.body_radius_km); + let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64; + let slope_frac = (slope_deg / 45.0).clamp(0.0, 1.0); + let slope_scale = 1.0 - slope_frac * (1.0 - STAGE_B_SLOPE_MIN_SCALE); + + let amplitude_m = peak_amplitude_m * taper * slope_scale; + + let global_arc_from_a = arc_m; // already global (arc-length from anchor_a) + let warp = warp_fbm(sx, sy, global_arc_from_a, chord_m, min_wavelength_m); + + points.push(( + base.0 + perp_x * warp * amplitude_m, + base.1 + perp_y * warp * amplitude_m, + )); + } + points +} + +/// fBm over global arc-length, in `[-1, 1]`, hard-truncated below +/// `min_wavelength_m` (the `coast_invention::warp_fbm` idiom — Ruling 3b). +/// Octave wavelengths run from `chord_m / 2` down by successive halvings for +/// [`STAGE_B_OCTAVE_COUNT`] steps, generated per-edge (not a fixed table) +/// since edge chord length varies over orders of magnitude. +fn warp_fbm(sx: u64, sy: u64, arc_m: f64, chord_m: f64, min_wavelength_m: f64) -> f64 { + let mut sum = 0.0; + let mut amp = 1.0; + let mut norm = 0.0; + let mut wl = (chord_m * 0.5).max(1.0); + for i in 0..STAGE_B_OCTAVE_COUNT { + if wl < min_wavelength_m { + amp *= 0.5; + wl *= 0.5; + continue; + } + // Two independent 1D-keyed samples (arc-length only — Ruling 1e: + // stations key on GLOBAL arc-length, never window-relative or 2D + // world position, so overlapping windows agree exactly on the shared + // stretch regardless of where the window happens to be centred). + let n = value_noise( + sx.wrapping_add((i as u64).wrapping_mul(0x1000)), + arc_m, + 0.0, + wl, + ); + sum += n * amp; + norm += amp; + amp *= 0.5; + wl *= 0.5; + } + let _ = sy; // reserved: a future second (e.g. width-jitter) channel would key off sy + if norm == 0.0 { + return 0.0; + } + sum / norm +} + +/// Total arc length of a polyline in world metres. +fn polyline_arc_length(points: &[CoursePoint]) -> f64 { + points.windows(2).map(|w| dist(w[0], w[1])).sum() +} + +/// Sample a polyline at `arc_m` global arc-length from its start, returning +/// the interpolated position and the local unit tangent (segment direction). +/// `arc_m` is clamped to `[0, total_length]`. +fn sample_polyline_at_arc_length(points: &[CoursePoint], arc_m: f64) -> (CoursePoint, CoursePoint) { + if points.len() < 2 { + return (points.first().copied().unwrap_or((0.0, 0.0)), (1.0, 0.0)); + } + let mut remaining = arc_m.max(0.0); + for w in points.windows(2) { + let seg_len = dist(w[0], w[1]); + if remaining <= seg_len || seg_len < 1e-9 { + let t = if seg_len < 1e-9 { + 0.0 + } else { + remaining / seg_len + }; + let pos = ( + w[0].0 + (w[1].0 - w[0].0) * t, + w[0].1 + (w[1].1 - w[0].1) * t, + ); + let tangent_len = seg_len.max(1e-9); + let tangent = ( + (w[1].0 - w[0].0) / tangent_len, + (w[1].1 - w[0].1) / tangent_len, + ); + return (pos, tangent); + } + remaining -= seg_len; + } + // Past the end — clamp to the final point, tangent of the last segment. + let last = *points.last().unwrap(); + let prev = points[points.len() - 2]; + let seg_len = dist(prev, last).max(1e-9); + let tangent = ((last.0 - prev.0) / seg_len, (last.1 - prev.1) / seg_len); + (last, tangent) +} + +// --------------------------------------------------------------------------- +// Riparian point test (T-1168, Ruling 4a-4d) +// --------------------------------------------------------------------------- + +/// Governed riparian band width in metres for `river_class` 2 (trunk) — +/// D-239 §8: "riparian Thicket/Scrub 1–3 tiles along perennial waterways" +/// (1 tile = 1 m, the voxel edge). Trunks get the wider Thicket-eligible band +/// (Ruling 4a: "thicket band for trunks"). **Tunable, governed default** — +/// within the D-239 §8 1–3 tile range, not a free constant. +pub const RIPARIAN_BAND_TRUNK_M: f64 = 3.0; + +/// Governed riparian band width in metres for `river_class` 1 (tributary) — +/// mid-point of the D-239 §8 1–3 tile range. **Tunable, governed default.** +pub const RIPARIAN_BAND_TRIBUTARY_M: f64 = 2.0; + +/// Governed riparian band width in metres for `river_class` 0 (stream) — +/// D-239 §8's narrower "scrub band for streams" (Ruling 4a). **Tunable, +/// governed default.** +pub const RIPARIAN_BAND_STREAM_M: f64 = 1.0; + +/// The governed riparian band width for a given `river_class` (Ruling 4a). +pub fn riparian_band_m(class: u8) -> f64 { + match class { + 2 => RIPARIAN_BAND_TRUNK_M, + 1 => RIPARIAN_BAND_TRIBUTARY_M, + _ => RIPARIAN_BAND_STREAM_M, + } +} + +/// **T-1168's riparian point test (Ruling 4a, binding):** is `sample_pos` +/// (world metres) within [`riparian_band_m`] of the nearest point on any +/// course in `courses`? +/// +/// A scale-free point-sample distance test against REAL course geometry — +/// this is what makes it automatically correct at every sampling density +/// (Ruling 4a): at District/Quarter spacing the 1–3 m band is sub-cell and +/// essentially never fires (honest, no over-fattening); at 1 m tile spacing +/// it fires on exactly the governed bank strip. No per-rung riparian policy +/// exists or is needed — this same function serves every caller. +/// +/// Pure (D-227/D-010): a function of `(sample_pos, courses)` only. Callers +/// are responsible for having already culled `courses` to something in the +/// neighbourhood of `sample_pos` (bbox cull, Ruling 4b) — this function does +/// the exact point-to-segment distance check, not the coarse cull. +/// +/// **Two-level bbox pre-check (perf, not a correctness change).** This +/// function is called ONCE PER WINDOW CELL (thousands of times per window), +/// so the cost of the naive "check every segment of every course" scan +/// dominates the window budget even though the 1-3 m governed band (D-239 +/// §8) means it almost always finds nothing (Ruling 4e). Two rejection +/// levels, cheapest first: +/// 1. **Whole-course bbox** — [`InventedCourse::bbox`], PRECOMPUTED once at +/// invention time (not recomputed here): an O(1) check rejects an ENTIRE +/// course — all its segments — at once when `sample_pos` is nowhere near +/// it, which is the common case (Ruling 4e: essentially every cell, every +/// course). Recomputing this per-call from the point list (an earlier +/// version of this function did exactly that) was itself the actual cost +/// overrun — an O(points) scan on every one of thousands of per-window +/// calls, not the O(1) check this field makes it. +/// 2. **Per-segment bbox**, only reached for courses that pass level 1: +/// rejects individual segments before the sqrt-bearing exact +/// `point_to_segment_distance` call. +/// +/// Neither level can produce a false negative — both only narrow which +/// segments reach the exact check, so output is byte-identical to the naive +/// version; this is purely the fix for the T-1170 Discipline item 2 cost +/// budget (window derive with courses on vs. off, delta < ~5%) — the naive +/// per-segment-only version measured +12-36% against a real GJ1c window, and +/// a per-call-recomputed whole-course bbox alone was not enough either. +pub fn near_perennial_water(sample_pos: CoursePoint, courses: &[InventedCourse]) -> bool { + for course in courses { + // Level 1: precomputed whole-course bbox reject — O(1), rejects + // every segment of this course at once. + let (bx0, by0, bx1, by1) = course.bbox; + if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 { + continue; + } + let band_m = riparian_band_m(course.class); + if course.points.len() < 2 { + if let Some(&p) = course.points.first() { + if dist(sample_pos, p) <= band_m { + return true; + } + } + continue; + } + // Level 2: per-segment bbox reject before the exact check. + for w in course.points.windows(2) { + let (x0, x1) = (w[0].0.min(w[1].0) - band_m, w[0].0.max(w[1].0) + band_m); + let (y0, y1) = (w[0].1.min(w[1].1) - band_m, w[0].1.max(w[1].1) + band_m); + if sample_pos.0 < x0 || sample_pos.0 > x1 || sample_pos.1 < y0 || sample_pos.1 > y1 { + continue; + } + if point_to_segment_distance(sample_pos, w[0], w[1]) <= band_m { + return true; + } + } + } + false +} + +/// Perpendicular distance from `p` to the segment `a`-`b` (clamped to the +/// segment, not the infinite line) — the exact point-to-segment distance +/// [`near_perennial_water`]'s per-window-station band test needs. +fn point_to_segment_distance(p: CoursePoint, a: CoursePoint, b: CoursePoint) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len_sq = dx * dx + dy * dy; + if len_sq < 1e-12 { + return dist(p, a); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len_sq).clamp(0.0, 1.0); + let proj = (a.0 + dx * t, a.1 + dy * t); + dist(p, proj) +} + +/// Search radius (world metres) [`near_perennial_water_at`] culls edges to +/// before inventing them — must cover the maximum possible Stage-B +/// displacement from the straight chord (Ruling 3c hard caps: `≤ 8% chord` +/// AND `≤ half a cell`) plus the widest governed riparian band +/// ([`RIPARIAN_BAND_TRUNK_M`]). Since `half a cell` is itself bounded by the +/// caller's own station spacing (District 2,048 m / Quarter 512 m — always +/// ≤ District's own spacing for any rung this module serves), one District +/// spacing is a safe, cheap, rung-independent search radius: any edge whose +/// invented geometry could possibly land within the riparian band of a point +/// must have its (uninflated) chord passing within this radius, by +/// construction of the amplitude cap. +const BATCH_RIPARIAN_SEARCH_RADIUS_M: f64 = 2_048.0 + RIPARIAN_BAND_TRUNK_M; + +/// Batch-path riparian test (T-1168, Ruling 4b: "in the batch path, courses +/// for edges near the district, invented on demand via the same pure +/// function"). Culls `river_network`'s edges to those whose chord bounding +/// box (inflated by [`BATCH_RIPARIAN_SEARCH_RADIUS_M`]) intersects +/// `sample_pos`, invents ONLY those (the common case is zero — most +/// districts have no river edge within ~2 km), then delegates to +/// [`near_perennial_water`] — the exact same pure predicate the window path +/// uses, so batch and window paths can never silently disagree on the +/// riparian verdict for the same world position. +#[allow(clippy::too_many_arguments)] +pub fn near_perennial_water_at( + seed: SeedChain, + ta: &TerrainAnalysis, + body_params: &BodyParams, + river_network: &RiverNetwork, + sample_pos: CoursePoint, + station_spacing_m: f64, + min_wavelength_m: f64, +) -> bool { + let edges = build_edges(river_network); + let mut nearby = Vec::new(); + for edge in &edges { + let anchor_a = cell_world_m(edge.upstream, ta, body_params); + let anchor_b = cell_world_m(edge.downstream, ta, body_params); + let (bx0, bx1) = ( + anchor_a.0.min(anchor_b.0) - BATCH_RIPARIAN_SEARCH_RADIUS_M, + anchor_a.0.max(anchor_b.0) + BATCH_RIPARIAN_SEARCH_RADIUS_M, + ); + let (by0, by1) = ( + anchor_a.1.min(anchor_b.1) - BATCH_RIPARIAN_SEARCH_RADIUS_M, + anchor_a.1.max(anchor_b.1) + BATCH_RIPARIAN_SEARCH_RADIUS_M, + ); + if sample_pos.0 < bx0 || sample_pos.0 > bx1 || sample_pos.1 < by0 || sample_pos.1 > by1 { + continue; + } + nearby.push(invent_course( + seed, + edge, + ta, + body_params, + station_spacing_m, + min_wavelength_m, + )); + } + near_perennial_water(sample_pos, &nearby) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas::body_world_state::RiverNetwork; + use crate::atlas::drainage; + use crate::atlas::heightmap::BodyHeightmap; + + fn test_hm(w: u32, h: u32) -> BodyHeightmap { + let n = (w * h) as usize; + let data: Vec = (0..n) + .map(|i| { + let r = (i / w as usize) as f32 / h as f32; + let c = (i % w as usize) as f32 / w as f32; + (r * 0.6 + c * 0.4).min(1.0) + }) + .collect(); + BodyHeightmap { + body_id: "test".into(), + width: w, + height: h, + data, + sea_level: 0.2, + } + } + + fn test_ta(hm: &BodyHeightmap) -> TerrainAnalysis { + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(hm, &dr) + } + + fn test_params() -> BodyParams { + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + } + } + + fn real_gj1c_network() -> (RiverNetwork, TerrainAnalysis, BodyHeightmap) { + 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 dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + (dr.river_network, ta, small) + } + + #[test] + fn build_edges_matches_river_cells_minus_terminal() { + let (rn, ..) = real_gj1c_network(); + let edges = build_edges(&rn); + assert_eq!( + edges.len(), + rn.river_cells.len(), + "round 1 emits no TERMINAL sentinels, so every river cell becomes an edge" + ); + } + + #[test] + fn edge_ids_are_unique() { + let (rn, ..) = real_gj1c_network(); + let edges = build_edges(&rn); + let ids: std::collections::BTreeSet = edges.iter().map(|e| e.edge_id).collect(); + assert_eq!(ids.len(), edges.len(), "edge_id must be unique per edge"); + } + + #[test] + fn invent_course_is_deterministic() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .expect("GJ1c should have at least one interior edge"); + let params = test_params(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + + let a = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let b = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + assert_eq!( + a.points, b.points, + "course invention must be deterministic (D-010/D-227)" + ); + } + + #[test] + fn stage_a_endpoints_are_the_true_cell_centres() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .unwrap(); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + + let course = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms); + let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms); + + let first = *course.points.first().unwrap(); + let last = *course.points.last().unwrap(); + assert!( + dist(first, anchor_a) < 1.0, + "course must start exactly at the upstream cell centre (confluence continuity)" + ); + assert!( + dist(last, anchor_b) < 1.0, + "course must end exactly at the downstream cell centre (confluence continuity)" + ); + } + + #[test] + fn amplitude_never_exceeds_hard_caps() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let params = test_params(); + let seed = SeedChain::root(7).derive(SeedDomain::Body, 1); + let station_spacing_m = 2_048.0; + + for edge in edges + .iter() + .filter(|e| e.terminus == EdgeTerminusKind::Interior) + { + let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let anchor_a = cell_world_m(edge.upstream, &ta, ¶ms); + let anchor_b = cell_world_m(edge.downstream, &ta, ¶ms); + let chord_m = dist(anchor_a, anchor_b); + let half_cell_m = station_spacing_m * 0.5; + // Cap (before class/slope scaling, which only ever reduce it further). + let cap_m = (chord_m * STAGE_B_PEAK_FRACTION_OF_CHORD) + .min(half_cell_m) + .max(0.0) + * STAGE_B_CLASS_SCALE.iter().cloned().fold(0.0, f64::max); + + // Perpendicular deviation from the straight chord, per point. + for &p in &course.points { + let perp_dist = point_to_segment_distance(p, anchor_a, anchor_b); + assert!( + perp_dist <= cap_m + 1.0, // +1.0 slack for f64 rounding + "course point {p:?} deviates {perp_dist} m from chord, cap is {cap_m} m \ + (edge {edge:?})" + ); + } + } + } + + #[test] + fn taper_is_zero_at_both_anchors_and_nonzero_mid_course() { + // Direct unit check of the sine taper shape itself. + let taper = |u: f64| (std::f64::consts::PI * u).sin().max(0.0); + assert!(taper(0.0).abs() < 1e-9); + assert!(taper(1.0).abs() < 1e-9); + assert!(taper(0.5) > 0.9); + } + + #[test] + fn salt_isolated_from_coast_warp_stream() { + // Course warp at a given (seed, edge_id, arc position) must not track + // the coast warp sampled with a naive matching key — distinct salted + // streams (Ruling 3a / Discipline 3c). + let sx_course = splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT); + let sx_coast = splitmix64(42u64 ^ 0xC0A5_71E1_1BAD_5EEDu64); // COAST_WARP_SALT value + assert_ne!(sx_course, sx_coast); + } + + #[test] + fn course_warp_stream_uncorrelated_with_coast_warp_stream() { + // Discipline item 3(c), binding: "salt isolation — course stream + // uncorrelated with coast warp at shared positions." A single + // value-inequality check (the test above) is necessary but not + // sufficient — this is the real cross-correlation proof: sample both + // fields' underlying warp_fbm-style noise streams (same seed, same + // world positions) and confirm the Pearson correlation across many + // samples is near zero, not just "not identical." + use crate::atlas::coast_invention::{ + body_coast_envelope, coast_character_at, coast_warp_px, + }; + use crate::atlas::district_profile::{GlaciationGrade, TectonicClass}; + + let params = test_params(); + let env = body_coast_envelope(¶ms, TectonicClass::Stable); + let ch = coast_character_at(&env, 42, 0.0, 0.0, 20.0, GlaciationGrade::None, 50); + + let n = 200; + let mut course_vals = Vec::with_capacity(n); + let mut coast_vals = Vec::with_capacity(n); + for i in 0..n { + let arc_m = i as f64 * 1_777.0; + let chord_m = 100_000.0; + let course_warp = warp_fbm( + splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT), + splitmix64(splitmix64(42u64 ^ RIVER_COURSE_WARP_SALT) ^ STAGE_B_ENVELOPE_SALT), + arc_m, + chord_m, + 0.0, + ); + let (coast_dx, _) = coast_warp_px(42, arc_m, 0.0, &ch, 0.0); + course_vals.push(course_warp); + coast_vals.push(coast_dx); + } + + let corr = pearson_correlation(&course_vals, &coast_vals); + assert!( + corr.abs() < 0.3, + "course warp and coast warp must be uncorrelated at shared positions, got r={corr}" + ); + } + + /// Pearson correlation coefficient — test-only helper for the isolation + /// proof above. + fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 { + let n = a.len() as f64; + let mean_a = a.iter().sum::() / n; + let mean_b = b.iter().sum::() / n; + let mut cov = 0.0; + let mut var_a = 0.0; + let mut var_b = 0.0; + for i in 0..a.len() { + let da = a[i] - mean_a; + let db = b[i] - mean_b; + cov += da * db; + var_a += da * da; + var_b += db * db; + } + if var_a < 1e-12 || var_b < 1e-12 { + return 0.0; + } + cov / (var_a.sqrt() * var_b.sqrt()) + } + + #[test] + fn river_course_seed_domain_isolated_from_other_domains() { + let root = SeedChain::root(42).derive(SeedDomain::Body, 1); + let course = root.derive(SeedDomain::RiverCourse, 5).seed(); + let block = root.derive(SeedDomain::Block, 5).seed(); + let voxel = root.derive(SeedDomain::Voxel, 5).seed(); + assert_ne!(course, block); + assert_ne!(course, voxel); + } + + #[test] + fn different_edges_get_different_courses() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let interior: Vec<&RiverEdge> = edges + .iter() + .filter(|e| e.terminus == EdgeTerminusKind::Interior) + .take(2) + .collect(); + if interior.len() < 2 { + return; // fixture doesn't have 2 interior edges — nothing to compare + } + let params = test_params(); + let seed = SeedChain::root(3).derive(SeedDomain::Body, 1); + let a = invent_course(seed, interior[0], &ta, ¶ms, 2_048.0, 0.0); + let b = invent_course(seed, interior[1], &ta, ¶ms, 2_048.0, 0.0); + assert_ne!( + a.points, b.points, + "distinct edges must invent distinct courses" + ); + } + + #[test] + fn min_wavelength_m_cutoff_changes_output() { + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = match edges.iter().find(|e| { + e.terminus == EdgeTerminusKind::Interior + && dist( + cell_world_m(e.upstream, &ta, &test_params()), + cell_world_m(e.downstream, &ta, &test_params()), + ) > 50_000.0 + }) { + Some(e) => e, + None => return, // no long-enough edge in this fixture to exercise cutoff difference + }; + let params = test_params(); + let seed = SeedChain::root(9).derive(SeedDomain::Body, 1); + let uncut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + let cut = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 100_000.0); + assert_ne!( + uncut.points, cut.points, + "a coarse-enough cutoff must change the invented course" + ); + } + + #[test] + fn small_synthetic_grid_does_not_panic() { + // Degenerate/small inputs must not panic — the harness discipline for + // every invention field in this codebase. + let hm = test_hm(16, 8); + let ta = test_ta(&hm); + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let edges = build_edges(&dr.river_network); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + for edge in &edges { + let _ = invent_course(seed, edge, &ta, ¶ms, 2_048.0, 0.0); + } + } + + // ----------------------------------------------------------------------- + // near_perennial_water (T-1168, Ruling 4a) + // ----------------------------------------------------------------------- + + #[test] + fn near_perennial_water_true_within_band_false_beyond() { + let points = vec![(0.0, 0.0), (100.0, 0.0)]; + let bbox = compute_bbox(&points, 2); + let course = InventedCourse { + edge_id: 1, + class: 2, // trunk -> RIPARIAN_BAND_TRUNK_M = 3.0 + terminus: EdgeTerminusKind::Interior, + points, + bbox, + }; + // On the course itself. + assert!(near_perennial_water( + (50.0, 0.0), + std::slice::from_ref(&course) + )); + // Within the 3 m trunk band. + assert!(near_perennial_water( + (50.0, 2.9), + std::slice::from_ref(&course) + )); + // Just outside the band. + assert!(!near_perennial_water( + (50.0, 3.1), + std::slice::from_ref(&course) + )); + // Far away entirely. + assert!(!near_perennial_water((50.0, 500.0), &[course])); + } + + #[test] + fn near_perennial_water_class_scales_band_width() { + let base = |class: u8| { + let points = vec![(0.0, 0.0), (100.0, 0.0)]; + let bbox = compute_bbox(&points, class); + InventedCourse { + edge_id: 1, + class, + terminus: EdgeTerminusKind::Interior, + points, + bbox, + } + }; + // 2.0 m: within tributary band (2.0), outside stream band (1.0). + let probe = (50.0, 1.5); + assert!( + near_perennial_water(probe, &[base(1)]), + "tributary band should cover 1.5 m" + ); + assert!( + !near_perennial_water(probe, &[base(0)]), + "stream band should NOT cover 1.5 m" + ); + } + + #[test] + fn near_perennial_water_district_quarter_spacing_essentially_never_fires() { + // Ruling 4e: at Atlas rungs, sample points from a coarse grid almost + // never land within the 1-3 m band of a course — this is CORRECT, + // not a bug. Spot-check: a station exactly on the course line reads + // true, but a station one full District cell-width away does not. + let points = vec![(0.0, 0.0), (10_000.0, 0.0)]; + let bbox = compute_bbox(&points, 2); + let course = InventedCourse { + edge_id: 1, + class: 2, + terminus: EdgeTerminusKind::Interior, + points, + bbox, + }; + let district_spacing_m = 2_048.0; + assert!(!near_perennial_water( + (5_000.0, district_spacing_m), + &[course] + )); + } + + #[test] + fn near_perennial_water_empty_courses_is_false() { + assert!(!near_perennial_water((0.0, 0.0), &[])); + } + + #[test] + fn near_perennial_water_never_touches_moisture() { + // Structural guard (Ruling 4d): near_perennial_water's signature has + // NO moisture_q parameter at all — this compiles only if that + // remains true. (A signature change that added a moisture parameter + // would be a hard compile error here, not a silent behavior change.) + let _: fn((f64, f64), &[InventedCourse]) -> bool = near_perennial_water; + } + + // ----------------------------------------------------------------------- + // near_perennial_water_at (T-1168 A5, batch path, Ruling 4b) + // ----------------------------------------------------------------------- + + #[test] + fn near_perennial_water_at_true_exactly_on_a_real_invented_course() { + // Batch-path integration: invent a real edge from the GJ1c fixture, + // sample a point exactly on the invented polyline, confirm + // near_perennial_water_at (the on-demand batch helper) agrees with + // directly calling near_perennial_water on the pre-invented course — + // the two paths must never silently disagree (Ruling 4b: "the same + // pure function"). + let (rn, ta, _) = real_gj1c_network(); + let edges = build_edges(&rn); + let edge = edges + .iter() + .find(|e| e.terminus == EdgeTerminusKind::Interior) + .expect("GJ1c should have an interior edge"); + let params = test_params(); + let seed = SeedChain::root(11).derive(SeedDomain::Body, 1); + let station_spacing_m = 2_048.0; + + let course = invent_course(seed, edge, &ta, ¶ms, station_spacing_m, 0.0); + let on_course = course.points[course.points.len() / 2]; + + assert!( + near_perennial_water_at(seed, &ta, ¶ms, &rn, on_course, station_spacing_m, 0.0), + "a point exactly on an invented course must read near_perennial_water_at == true" + ); + } + + #[test] + fn near_perennial_water_at_false_far_from_any_river() { + let (rn, ta, _) = real_gj1c_network(); + let params = test_params(); + let seed = SeedChain::root(11).derive(SeedDomain::Body, 1); + // A position with a huge world-metre offset, guaranteed far from any + // GJ1c river edge given the body's radius. + let far_away = (1.0e9, 1.0e9); + assert!(!near_perennial_water_at( + seed, &ta, ¶ms, &rn, far_away, 2_048.0, 0.0 + )); + } + + #[test] + fn near_perennial_water_at_no_river_network_edges_is_false() { + // Degenerate: a RiverNetwork with no river cells at all — must not + // panic, must read false everywhere (the pre-T-1168 hardcoded + // default this threading preserves for bodies without drainage). + let empty_rn = RiverNetwork::default(); + let hm = test_hm(16, 8); + let ta = test_ta(&hm); + let params = test_params(); + let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); + assert!(!near_perennial_water_at( + seed, + &ta, + ¶ms, + &empty_rn, + (0.0, 0.0), + 2_048.0, + 0.0 + )); + } +} diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 9b0b4d08c..76fb70236 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -104,7 +104,11 @@ pub fn chunk_to_region(c: ChunkPos) -> RegionPos { // --------------------------------------------------------------------------- /// Number of working-heightmap-grid pixels per district side on the standard -/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023). +/// cascade working resolution (512×256 working grid, `heightmap::GRID_W` × +/// `heightmap::GRID_H`; D-203, D-239 §1, T-1023). (T-1170 audit nit: this +/// comment previously read "~128×64" — stale since the working grid was +/// widened; the actual grid-shape source of truth is `heightmap::GRID_W`/ +/// `GRID_H`, not a number restated here.) /// /// This is NOT a metre-scale constant — it is the `grid_cells_per_district` /// parameter passed to [`crate::atlas::district_profile::derive_all_districts`]. diff --git a/server/src/seed.rs b/server/src/seed.rs index 517a313df..3d0af2251 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -146,6 +146,15 @@ pub enum SeedDomain { /// independent entropy — the `assign_block_tags` lesson (distinct /// sub-chains per field, not one shared roll) applies here too. TraitExterior = 16, + /// River course invention (T-1170, Ruling 3a): the linear sibling of the + /// coastline warp, keyed by `edge_id` (the packed upstream-cell u32 of a + /// D8 river edge, see `atlas::river_course`). Distinct domain so the + /// course's Stage-B perpendicular warp octaves can never correlate with + /// the coast warp, terrain scatter, or vegetation massif fields sampled + /// at the same world position (D-224 domain separation, the module's own + /// `RIVER_COURSE_WARP_SALT` provides a second, position-keyed layer of + /// isolation on top of this domain tag). + RiverCourse = 17, } /// A position in the deterministic seed tree (D-224). @@ -311,6 +320,7 @@ mod tests { assert_eq!(SeedDomain::TraitDistrict as u64, 14); assert_eq!(SeedDomain::TraitSwerve as u64, 15); assert_eq!(SeedDomain::TraitExterior as u64, 16); + assert_eq!(SeedDomain::RiverCourse as u64, 17); } #[test] diff --git a/server/tests/cascade_golden.rs b/server/tests/cascade_golden.rs index 271acf8c6..a919061eb 100644 --- a/server/tests/cascade_golden.rs +++ b/server/tests/cascade_golden.rs @@ -18,6 +18,44 @@ //! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a //! different architecture could in principle round differently — regenerate //! per-arch if CI ever moves off x86_64. +//! +//! **Deliberate re-pin (T-1170 Ruling 2a/2c/3f, A1):** `RiverNetwork` gained +//! an additive `river_downstream: Vec` field (per-`river_cells`-entry D8 +//! downstream pointer + MOUTH/EDGE_DRAIN sentinel), and `extract_river_network` +//! stopped classifying pole-edge D8 exits (flow running off the grid's +//! top/bottom row) as `mouths` — they are grid artifacts, not river-meets-sea +//! events (Ruling 3f, Jeroen's capture question). On this fixture (GJ1c, +//! 256×128 downsample) that drops `mouths` from 19 to 3: 16 of the 19 were +//! pole-edge exits (now `RIVER_DOWNSTREAM_EDGE_DRAIN`), leaving the 3 real +//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors` +//! counts are unchanged (93/256) — this is a pure re-classification + one new +//! additive field, not a drainage-algorithm change. +//! +//! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1 +//! commit's "pure reclassification" framing overclaimed — `attractors` is +//! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks +//! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from +//! `river_network.mouths`), which shifts the water-distance field, which +//! feeds `RawAttractor::strength` scoring in `features::extract_attractors`. +//! This is a principled, in-scope cascade (the pole-edge cells genuinely +//! aren't water-distance sources anymore) — not a bug — but it is a REAL +//! field-level change, not a no-op re-tagging. Restated accurately here so +//! the record doesn't imply byte-identical attractor output. +//! +//! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):** +//! `RiverNetwork` gained a second additive field, `river_seaward: +//! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel +//! river cells, captured in the SAME `extract_river_network` pass (the +//! elevation check that already computes `(nr, nc)` to decide the MOUTH +//! sentinel). Fixes a second instance of the discard-then-need-it-later +//! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8 +//! pointers: `river_course::build_edges` needs a real seaward cell to give +//! Mouth edges a non-degenerate chord (see `river_course.rs`'s +//! `build_edges` doc and `layer_proxy::tests:: +//! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full +//! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by +//! this second re-pin (93/3/256) — purely the new parallel array, 3 non- +//! placeholder entries (one per real mouth). use std::path::PathBuf; diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs index e409687da..dfee26f32 100644 --- a/server/tests/derivation_harness.rs +++ b/server/tests/derivation_harness.rs @@ -1933,6 +1933,7 @@ fn derive_profile_for_body(params: &BodyParams) -> DistrictProfile { "test_body", &BTreeMap::new(), settled_reach_server::atlas::scale::BasinDirection::default(), + None, ) } diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index ea0f56e78..0bb622068 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -558,6 +558,8 @@ fn generate_atlas_layer_response_fixtures() { confluences: vec![], mouths: vec![(12, 58)], river_class: vec![1, 2], + river_downstream: vec![2, 8], // 2=E direction; 8=MOUTH sentinel + river_seaward: vec![(0, 0), (12, 60)], // meaningful only for the MOUTH entry }, drainage_basins: vec![DrainageBasin { basin_id: 1, @@ -736,6 +738,7 @@ fn generate_atlas_layer_response_fixtures() { moisture_q: vec![90, 55, 0, 100], vegetation: vec![6, 3, 0, 5], // Marine, Forest, Absent, RiparianThicket glaciation: vec![0, 0, 4, 1], // None, None, IceCap, Light + courses: vec![], }; let ready_with_window = AtlasLayerResponse { body_id: "GJ1c".into(), diff --git a/server/tests/golden/cascade_layer1.json b/server/tests/golden/cascade_layer1.json index 434b7e1a1..0e8c58627 100644 --- a/server/tests/golden/cascade_layer1.json +++ b/server/tests/golden/cascade_layer1.json @@ -5,138 +5,6 @@ ], "layer1": { "attractors": [ - { - "attractor_type": "RiverMouth", - "position": [ - 0, - 100 - ], - "strength": 20, - "sub_biome": "Tundra", - "terrain_modification_cost": 162, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 0, - 142 - ], - "strength": 14, - "sub_biome": "Tundra", - "terrain_modification_cost": 165, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 0, - 197 - ], - "strength": 13, - "sub_biome": "Tundra", - "terrain_modification_cost": 166, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 0, - 241 - ], - "strength": 23, - "sub_biome": "Tundra", - "terrain_modification_cost": 166, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 11, - 65 - ], - "strength": 12, - "sub_biome": "Alpine", - "terrain_modification_cost": 384, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 12, - 232 - ], - "strength": 15, - "sub_biome": "Tundra", - "terrain_modification_cost": 166, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 12, - 238 - ], - "strength": 23, - "sub_biome": "Tundra", - "terrain_modification_cost": 163, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 15, - 141 - ], - "strength": 22, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 150, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 20, - 85 - ], - "strength": 24, - "sub_biome": "BorealForest", - "terrain_modification_cost": 158, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 25, - 100 - ], - "strength": 22, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 146, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 28, - 14 - ], - "strength": 34, - "sub_biome": "TemperateForest", - "terrain_modification_cost": 150, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 28, - 181 - ], - "strength": 20, - "sub_biome": "Wetland", - "terrain_modification_cost": 327, - "water_bearing": 360 - }, { "attractor_type": "RiverMouth", "position": [ @@ -159,50 +27,6 @@ "terrain_modification_cost": 348, "water_bearing": 360 }, - { - "attractor_type": "RiverMouth", - "position": [ - 50, - 226 - ], - "strength": 13, - "sub_biome": "Wetland", - "terrain_modification_cost": 325, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 50, - 254 - ], - "strength": 16, - "sub_biome": "Wetland", - "terrain_modification_cost": 324, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 51, - 230 - ], - "strength": 25, - "sub_biome": "Wetland", - "terrain_modification_cost": 322, - "water_bearing": 360 - }, - { - "attractor_type": "RiverMouth", - "position": [ - 88, - 217 - ], - "strength": 13, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 142, - "water_bearing": 360 - }, { "attractor_type": "RiverMouth", "position": [ @@ -487,7 +311,7 @@ "strength": 90, "sub_biome": "Wetland", "terrain_modification_cost": 325, - "water_bearing": 0 + "water_bearing": 180 }, { "attractor_type": "CoastalAccess", @@ -908,67 +732,78 @@ "water_bearing": 0 }, { - "attractor_type": "ValleyFloor", + "attractor_type": "CoastalAccess", "position": [ - 0, - 101 + 111, + 100 ], - "strength": 87, - "sub_biome": "Tundra", - "terrain_modification_cost": 162, - "water_bearing": 270 + "strength": 90, + "sub_biome": "Wetland", + "terrain_modification_cost": 327, + "water_bearing": 0 + }, + { + "attractor_type": "CoastalAccess", + "position": [ + 113, + 238 + ], + "strength": 90, + "sub_biome": "Wetland", + "terrain_modification_cost": 331, + "water_bearing": 0 + }, + { + "attractor_type": "CoastalAccess", + "position": [ + 115, + 51 + ], + "strength": 90, + "sub_biome": "Wetland", + "terrain_modification_cost": 366, + "water_bearing": 0 + }, + { + "attractor_type": "CoastalAccess", + "position": [ + 115, + 67 + ], + "strength": 90, + "sub_biome": "Wetland", + "terrain_modification_cost": 326, + "water_bearing": 0 + }, + { + "attractor_type": "CoastalAccess", + "position": [ + 116, + 79 + ], + "strength": 90, + "sub_biome": "Wetland", + "terrain_modification_cost": 323, + "water_bearing": 315 }, { "attractor_type": "ValleyFloor", "position": [ - 0, - 141 - ], - "strength": 89, - "sub_biome": "Tundra", - "terrain_modification_cost": 164, - "water_bearing": 90 - }, - { - "attractor_type": "ValleyFloor", - "position": [ - 0, - 198 + 16, + 143 ], "strength": 86, - "sub_biome": "Tundra", - "terrain_modification_cost": 164, - "water_bearing": 270 - }, - { - "attractor_type": "ValleyFloor", - "position": [ - 15, - 140 - ], - "strength": 94, "sub_biome": "BorealForest", - "terrain_modification_cost": 159, - "water_bearing": 90 + "terrain_modification_cost": 157, + "water_bearing": 180 }, { "attractor_type": "ValleyFloor", "position": [ - 20, - 86 + 18, + 109 ], - "strength": 91, - "sub_biome": "BorealForest", - "terrain_modification_cost": 154, - "water_bearing": 270 - }, - { - "attractor_type": "ValleyFloor", - "position": [ - 23, - 182 - ], - "strength": 88, + "strength": 82, "sub_biome": "BorealForest", "terrain_modification_cost": 158, "water_bearing": 180 @@ -976,13 +811,35 @@ { "attractor_type": "ValleyFloor", "position": [ - 24, - 101 + 20, + 86 ], - "strength": 98, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 146, - "water_bearing": 225 + "strength": 80, + "sub_biome": "BorealForest", + "terrain_modification_cost": 154, + "water_bearing": 90 + }, + { + "attractor_type": "ValleyFloor", + "position": [ + 23, + 168 + ], + "strength": 76, + "sub_biome": "BorealForest", + "terrain_modification_cost": 171, + "water_bearing": 135 + }, + { + "attractor_type": "ValleyFloor", + "position": [ + 23, + 182 + ], + "strength": 80, + "sub_biome": "BorealForest", + "terrain_modification_cost": 158, + "water_bearing": 180 }, { "attractor_type": "ValleyFloor", @@ -1001,10 +858,10 @@ 27, 16 ], - "strength": 89, + "strength": 87, "sub_biome": "TemperateForest", "terrain_modification_cost": 135, - "water_bearing": 270 + "water_bearing": 180 }, { "attractor_type": "ValleyFloor", @@ -1017,6 +874,28 @@ "terrain_modification_cost": 143, "water_bearing": 270 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 30, + 37 + ], + "strength": 83, + "sub_biome": "TemperateForest", + "terrain_modification_cost": 138, + "water_bearing": 90 + }, + { + "attractor_type": "ValleyFloor", + "position": [ + 30, + 98 + ], + "strength": 95, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 148, + "water_bearing": 180 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1072,17 +951,6 @@ "terrain_modification_cost": 155, "water_bearing": 180 }, - { - "attractor_type": "ValleyFloor", - "position": [ - 36, - 94 - ], - "strength": 85, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 146, - "water_bearing": 90 - }, { "attractor_type": "ValleyFloor", "position": [ @@ -1149,16 +1017,27 @@ "terrain_modification_cost": 153, "water_bearing": 90 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 45, + 229 + ], + "strength": 83, + "sub_biome": "TemperateForest", + "terrain_modification_cost": 145, + "water_bearing": 270 + }, { "attractor_type": "ValleyFloor", "position": [ 45, 253 ], - "strength": 94, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 148, - "water_bearing": 180 + "strength": 92, + "sub_biome": "TemperateForest", + "terrain_modification_cost": 138, + "water_bearing": 90 }, { "attractor_type": "ValleyFloor", @@ -1171,17 +1050,6 @@ "terrain_modification_cost": 147, "water_bearing": 135 }, - { - "attractor_type": "ValleyFloor", - "position": [ - 46, - 231 - ], - "strength": 92, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 157, - "water_bearing": 180 - }, { "attractor_type": "ValleyFloor", "position": [ @@ -1204,6 +1072,17 @@ "terrain_modification_cost": 204, "water_bearing": 225 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 48, + 241 + ], + "strength": 88, + "sub_biome": "TropicalWet", + "terrain_modification_cost": 213, + "water_bearing": 90 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1240,13 +1119,24 @@ { "attractor_type": "ValleyFloor", "position": [ - 58, - 228 + 52, + 191 ], - "strength": 94, + "strength": 84, + "sub_biome": "Wetland", + "terrain_modification_cost": 325, + "water_bearing": 180 + }, + { + "attractor_type": "ValleyFloor", + "position": [ + 58, + 227 + ], + "strength": 90, "sub_biome": "TropicalWet", - "terrain_modification_cost": 203, - "water_bearing": 0 + "terrain_modification_cost": 205, + "water_bearing": 270 }, { "attractor_type": "ValleyFloor", @@ -1270,6 +1160,17 @@ "terrain_modification_cost": 144, "water_bearing": 135 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 62, + 121 + ], + "strength": 80, + "sub_biome": "TropicalWet", + "terrain_modification_cost": 221, + "water_bearing": 90 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1369,6 +1270,17 @@ "terrain_modification_cost": 158, "water_bearing": 270 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 77, + 205 + ], + "strength": 80, + "sub_biome": "TropicalWet", + "terrain_modification_cost": 236, + "water_bearing": 270 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1383,34 +1295,45 @@ { "attractor_type": "ValleyFloor", "position": [ - 86, - 203 + 88, + 148 ], - "strength": 90, + "strength": 84, "sub_biome": "Wetland", - "terrain_modification_cost": 328, - "water_bearing": 225 + "terrain_modification_cost": 332, + "water_bearing": 45 }, { "attractor_type": "ValleyFloor", "position": [ - 86, - 215 + 89, + 207 ], - "strength": 98, + "strength": 96, "sub_biome": "CoastalLowland", - "terrain_modification_cost": 144, - "water_bearing": 135 + "terrain_modification_cost": 145, + "water_bearing": 270 }, { "attractor_type": "ValleyFloor", "position": [ 92, - 227 + 225 ], - "strength": 86, - "sub_biome": "Wetland", - "terrain_modification_cost": 347, + "strength": 94, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 152, + "water_bearing": 90 + }, + { + "attractor_type": "ValleyFloor", + "position": [ + 99, + 87 + ], + "strength": 84, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 142, "water_bearing": 180 }, { @@ -1534,6 +1457,17 @@ "terrain_modification_cost": 143, "water_bearing": 0 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 127, + 13 + ], + "strength": 83, + "sub_biome": "Wetland", + "terrain_modification_cost": 336, + "water_bearing": 0 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1567,6 +1501,17 @@ "terrain_modification_cost": 145, "water_bearing": 315 }, + { + "attractor_type": "ValleyFloor", + "position": [ + 127, + 212 + ], + "strength": 84, + "sub_biome": "Tundra", + "terrain_modification_cost": 164, + "water_bearing": 270 + }, { "attractor_type": "ValleyFloor", "position": [ @@ -1598,7 +1543,7 @@ "strength": 46, "sub_biome": "Tundra", "terrain_modification_cost": 162, - "water_bearing": 0 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1609,7 +1554,7 @@ "strength": 49, "sub_biome": "Tundra", "terrain_modification_cost": 163, - "water_bearing": 90 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1620,7 +1565,7 @@ "strength": 1, "sub_biome": "Alpine", "terrain_modification_cost": 389, - "water_bearing": 90 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1631,7 +1576,7 @@ "strength": 34, "sub_biome": "Tundra", "terrain_modification_cost": 165, - "water_bearing": 0 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1642,7 +1587,7 @@ "strength": 13, "sub_biome": "Alpine", "terrain_modification_cost": 382, - "water_bearing": 270 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1653,7 +1598,7 @@ "strength": 29, "sub_biome": "Tundra", "terrain_modification_cost": 162, - "water_bearing": 0 + "water_bearing": 90 }, { "attractor_type": "PassEntrance", @@ -1664,7 +1609,7 @@ "strength": 35, "sub_biome": "Tundra", "terrain_modification_cost": 163, - "water_bearing": 90 + "water_bearing": 135 }, { "attractor_type": "PassEntrance", @@ -1686,7 +1631,7 @@ "strength": 6, "sub_biome": "Alpine", "terrain_modification_cost": 389, - "water_bearing": 225 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1730,7 +1675,7 @@ "strength": 42, "sub_biome": "BorealForest", "terrain_modification_cost": 155, - "water_bearing": 90 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1752,7 +1697,7 @@ "strength": 18, "sub_biome": "Alpine", "terrain_modification_cost": 387, - "water_bearing": 90 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1774,7 +1719,7 @@ "strength": 34, "sub_biome": "TemperateForest", "terrain_modification_cost": 134, - "water_bearing": 0 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1796,7 +1741,7 @@ "strength": 14, "sub_biome": "Alpine", "terrain_modification_cost": 386, - "water_bearing": 0 + "water_bearing": 225 }, { "attractor_type": "PassEntrance", @@ -1807,7 +1752,7 @@ "strength": 49, "sub_biome": "TemperateForest", "terrain_modification_cost": 164, - "water_bearing": 315 + "water_bearing": 180 }, { "attractor_type": "PassEntrance", @@ -1862,7 +1807,7 @@ "strength": 36, "sub_biome": "TemperateForest", "terrain_modification_cost": 135, - "water_bearing": 225 + "water_bearing": 90 }, { "attractor_type": "PassEntrance", @@ -2139,92 +2084,37 @@ "terrain_modification_cost": 332, "water_bearing": 225 }, - { - "attractor_type": "PlainCenter", - "position": [ - 0, - 99 - ], - "strength": 35, - "sub_biome": "Tundra", - "terrain_modification_cost": 162, - "water_bearing": 90 - }, - { - "attractor_type": "PlainCenter", - "position": [ - 0, - 143 - ], - "strength": 35, - "sub_biome": "Tundra", - "terrain_modification_cost": 167, - "water_bearing": 270 - }, - { - "attractor_type": "PlainCenter", - "position": [ - 0, - 196 - ], - "strength": 34, - "sub_biome": "Tundra", - "terrain_modification_cost": 165, - "water_bearing": 90 - }, - { - "attractor_type": "PlainCenter", - "position": [ - 4, - 131 - ], - "strength": 33, - "sub_biome": "Tundra", - "terrain_modification_cost": 162, - "water_bearing": 180 - }, { "attractor_type": "PlainCenter", "position": [ 16, 142 ], - "strength": 38, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 150, - "water_bearing": 315 - }, - { - "attractor_type": "PlainCenter", - "position": [ - 18, - 182 - ], - "strength": 34, + "strength": 35, "sub_biome": "BorealForest", - "terrain_modification_cost": 155, - "water_bearing": 180 + "terrain_modification_cost": 160, + "water_bearing": 225 }, { "attractor_type": "PlainCenter", "position": [ - 21, - 85 + 20, + 87 ], - "strength": 36, + "strength": 32, "sub_biome": "BorealForest", "terrain_modification_cost": 157, - "water_bearing": 0 + "water_bearing": 135 }, { "attractor_type": "PlainCenter", "position": [ - 24, - 100 + 23, + 181 ], - "strength": 39, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 146, + "strength": 32, + "sub_biome": "BorealForest", + "terrain_modification_cost": 160, "water_bearing": 180 }, { @@ -2244,10 +2134,21 @@ 27, 17 ], - "strength": 36, + "strength": 35, "sub_biome": "TemperateForest", "terrain_modification_cost": 133, - "water_bearing": 270 + "water_bearing": 180 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 29, + 100 + ], + "strength": 38, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 146, + "water_bearing": 180 }, { "attractor_type": "PlainCenter", @@ -2271,17 +2172,6 @@ "terrain_modification_cost": 138, "water_bearing": 90 }, - { - "attractor_type": "PlainCenter", - "position": [ - 31, - 182 - ], - "strength": 38, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 151, - "water_bearing": 0 - }, { "attractor_type": "PlainCenter", "position": [ @@ -2330,12 +2220,23 @@ "attractor_type": "PlainCenter", "position": [ 35, - 197 + 187 ], - "strength": 37, + "strength": 38, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 153, + "water_bearing": 180 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 35, + 199 + ], + "strength": 36, "sub_biome": "CoastalLowland", "terrain_modification_cost": 156, - "water_bearing": 135 + "water_bearing": 180 }, { "attractor_type": "PlainCenter", @@ -2398,21 +2299,10 @@ 44, 253 ], - "strength": 37, + "strength": 36, "sub_biome": "TemperateForest", "terrain_modification_cost": 137, - "water_bearing": 180 - }, - { - "attractor_type": "PlainCenter", - "position": [ - 45, - 229 - ], - "strength": 35, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 155, - "water_bearing": 180 + "water_bearing": 90 }, { "attractor_type": "PlainCenter", @@ -2447,6 +2337,50 @@ "terrain_modification_cost": 147, "water_bearing": 180 }, + { + "attractor_type": "PlainCenter", + "position": [ + 47, + 167 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 330, + "water_bearing": 180 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 47, + 180 + ], + "strength": 32, + "sub_biome": "Wetland", + "terrain_modification_cost": 331, + "water_bearing": 180 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 48, + 96 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 322, + "water_bearing": 45 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 48, + 238 + ], + "strength": 34, + "sub_biome": "TropicalWet", + "terrain_modification_cost": 211, + "water_bearing": 90 + }, { "attractor_type": "PlainCenter", "position": [ @@ -2480,6 +2414,17 @@ "terrain_modification_cost": 155, "water_bearing": 225 }, + { + "attractor_type": "PlainCenter", + "position": [ + 52, + 192 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 324, + "water_bearing": 180 + }, { "attractor_type": "PlainCenter", "position": [ @@ -2494,13 +2439,13 @@ { "attractor_type": "PlainCenter", "position": [ - 57, - 228 + 58, + 222 ], - "strength": 37, + "strength": 36, "sub_biome": "TropicalWet", - "terrain_modification_cost": 203, - "water_bearing": 0 + "terrain_modification_cost": 216, + "water_bearing": 270 }, { "attractor_type": "PlainCenter", @@ -2524,6 +2469,17 @@ "terrain_modification_cost": 145, "water_bearing": 180 }, + { + "attractor_type": "PlainCenter", + "position": [ + 62, + 234 + ], + "strength": 36, + "sub_biome": "TropicalWet", + "terrain_modification_cost": 205, + "water_bearing": 135 + }, { "attractor_type": "PlainCenter", "position": [ @@ -2615,13 +2571,13 @@ { "attractor_type": "PlainCenter", "position": [ - 86, - 216 + 87, + 147 ], - "strength": 39, - "sub_biome": "CoastalLowland", - "terrain_modification_cost": 144, - "water_bearing": 180 + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 331, + "water_bearing": 225 }, { "attractor_type": "PlainCenter", @@ -2634,6 +2590,39 @@ "terrain_modification_cost": 149, "water_bearing": 180 }, + { + "attractor_type": "PlainCenter", + "position": [ + 92, + 216 + ], + "strength": 38, + "sub_biome": "CoastalLowland", + "terrain_modification_cost": 143, + "water_bearing": 90 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 96, + 98 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 323, + "water_bearing": 45 + }, + { + "attractor_type": "PlainCenter", + "position": [ + 99, + 85 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 323, + "water_bearing": 225 + }, { "attractor_type": "PlainCenter", "position": [ @@ -2766,6 +2755,17 @@ "terrain_modification_cost": 141, "water_bearing": 0 }, + { + "attractor_type": "PlainCenter", + "position": [ + 127, + 12 + ], + "strength": 33, + "sub_biome": "Wetland", + "terrain_modification_cost": 336, + "water_bearing": 0 + }, { "attractor_type": "PlainCenter", "position": [ @@ -7766,54 +7766,6 @@ "river_network": { "confluences": [], "mouths": [ - [ - 0, - 100 - ], - [ - 0, - 142 - ], - [ - 0, - 197 - ], - [ - 0, - 241 - ], - [ - 11, - 65 - ], - [ - 12, - 232 - ], - [ - 12, - 238 - ], - [ - 15, - 141 - ], - [ - 20, - 85 - ], - [ - 25, - 100 - ], - [ - 28, - 14 - ], - [ - 28, - 181 - ], [ 38, 47 @@ -7822,22 +7774,6 @@ 38, 98 ], - [ - 50, - 226 - ], - [ - 50, - 254 - ], - [ - 51, - 230 - ], - [ - 88, - 217 - ], [ 124, 239 @@ -8311,6 +8247,475 @@ 0, 0, 0 + ], + "river_downstream": [ + 3, + 3, + 3, + 3, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 9, + 9, + 9, + 9, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 9, + 7, + 3, + 3, + 3, + 3, + 9, + 9, + 3, + 3, + 9, + 5, + 2, + 9, + 2, + 4, + 9, + 7, + 9, + 3, + 3, + 9, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 6, + 2, + 2, + 6, + 2, + 2, + 2, + 2, + 2, + 4, + 8, + 8, + 9, + 9, + 9, + 3, + 3, + 5, + 3, + 3, + 3, + 5, + 5, + 9, + 2, + 8 + ], + "river_seaward": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 39, + 47 + ], + [ + 38, + 99 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 125, + 240 + ] ] } }, diff --git a/server/tests/golden/river_course_golden.json b/server/tests/golden/river_course_golden.json new file mode 100644 index 000000000..b848bdc35 --- /dev/null +++ b/server/tests/golden/river_course_golden.json @@ -0,0 +1,3154 @@ +[ + { + "rung": "district", + "edge_id": 655476, + "class": 2, + "terminus": "Interior", + "points": [ + [ + 36277345, + -6830545 + ], + [ + 36277344, + -6834641 + ], + [ + 36277343, + -6838737 + ], + [ + 36277342, + -6842833 + ], + [ + 36277341, + -6846929 + ], + [ + 36277335, + -6851025 + ], + [ + 36277323, + -6855121 + ], + [ + 36277294, + -6859217 + ], + [ + 36277287, + -6863313 + ], + [ + 36277283, + -6867409 + ], + [ + 36277263, + -6871505 + ], + [ + 36277211, + -6875601 + ], + [ + 36277137, + -6879697 + ], + [ + 36277063, + -6883793 + ], + [ + 36276993, + -6887889 + ], + [ + 36276946, + -6891985 + ], + [ + 36276902, + -6896081 + ], + [ + 36276842, + -6900177 + ], + [ + 36276801, + -6904273 + ], + [ + 36276749, + -6908369 + ], + [ + 36276723, + -6912465 + ], + [ + 36276750, + -6916561 + ], + [ + 36276762, + -6920657 + ], + [ + 36276799, + -6924753 + ], + [ + 36276829, + -6928849 + ], + [ + 36276833, + -6932945 + ], + [ + 36276846, + -6937041 + ], + [ + 36276916, + -6941137 + ], + [ + 36276999, + -6945233 + ], + [ + 36277048, + -6949329 + ], + [ + 36277032, + -6953425 + ], + [ + 36276990, + -6957521 + ], + [ + 36276893, + -6961617 + ], + [ + 36276733, + -6965713 + ], + [ + 36276672, + -6969809 + ], + [ + 36276720, + -6973905 + ], + [ + 36276842, + -6978001 + ], + [ + 36276855, + -6982097 + ], + [ + 36276849, + -6986193 + ], + [ + 36276857, + -6990289 + ], + [ + 36276852, + -6994385 + ], + [ + 36276832, + -6998481 + ], + [ + 36276811, + -7002577 + ], + [ + 36276835, + -7006673 + ], + [ + 36276878, + -7010769 + ], + [ + 36276917, + -7014865 + ], + [ + 36276961, + -7018961 + ], + [ + 36277115, + -7023057 + ], + [ + 36277175, + -7027153 + ], + [ + 36277207, + -7031249 + ], + [ + 36277202, + -7035345 + ], + [ + 36277199, + -7039441 + ], + [ + 36277242, + -7043537 + ], + [ + 36277301, + -7047633 + ], + [ + 36277376, + -7051729 + ], + [ + 36277464, + -7055825 + ], + [ + 36277529, + -7059921 + ], + [ + 36277549, + -7064017 + ], + [ + 36277547, + -7068113 + ], + [ + 36277547, + -7072209 + ], + [ + 36277562, + -7076305 + ], + [ + 36277602, + -7080401 + ], + [ + 36277670, + -7084497 + ], + [ + 36277715, + -7088593 + ], + [ + 36277690, + -7092689 + ], + [ + 36277633, + -7096785 + ], + [ + 36277591, + -7100881 + ], + [ + 36277558, + -7104977 + ], + [ + 36277532, + -7109073 + ], + [ + 36277516, + -7113169 + ], + [ + 36277530, + -7117265 + ], + [ + 36277519, + -7121361 + ], + [ + 36277499, + -7125457 + ], + [ + 36277478, + -7129553 + ], + [ + 36277452, + -7133649 + ], + [ + 36277433, + -7137745 + ], + [ + 36277401, + -7141841 + ], + [ + 36277364, + -7145937 + ], + [ + 36277345, + -7148245 + ] + ] + }, + { + "rung": "district_mouth", + "edge_id": 262260, + "class": 2, + "terminus": "Mouth", + "points": [ + [ + 36277345, + -8736744 + ], + [ + 36277338, + -8740840 + ], + [ + 36277338, + -8744936 + ], + [ + 36277335, + -8749032 + ], + [ + 36277332, + -8753128 + ], + [ + 36277336, + -8757224 + ], + [ + 36277339, + -8761320 + ], + [ + 36277365, + -8765416 + ], + [ + 36277386, + -8769512 + ], + [ + 36277383, + -8773608 + ], + [ + 36277381, + -8777704 + ], + [ + 36277385, + -8781800 + ], + [ + 36277361, + -8785896 + ], + [ + 36277304, + -8789992 + ], + [ + 36277258, + -8794088 + ], + [ + 36277258, + -8798184 + ], + [ + 36277264, + -8802280 + ], + [ + 36277303, + -8806376 + ], + [ + 36277317, + -8810472 + ], + [ + 36277403, + -8814568 + ], + [ + 36277443, + -8818664 + ], + [ + 36277478, + -8822760 + ], + [ + 36277537, + -8826856 + ], + [ + 36277573, + -8830952 + ], + [ + 36277600, + -8835048 + ], + [ + 36277705, + -8839144 + ], + [ + 36277806, + -8843240 + ], + [ + 36277912, + -8847336 + ], + [ + 36278073, + -8851432 + ], + [ + 36278206, + -8855528 + ], + [ + 36278273, + -8859624 + ], + [ + 36278356, + -8863720 + ], + [ + 36278441, + -8867816 + ], + [ + 36278515, + -8871912 + ], + [ + 36278500, + -8876008 + ], + [ + 36278590, + -8880104 + ], + [ + 36278657, + -8884200 + ], + [ + 36278739, + -8888296 + ], + [ + 36278795, + -8892392 + ], + [ + 36278765, + -8896488 + ], + [ + 36278787, + -8900584 + ], + [ + 36278685, + -8904680 + ], + [ + 36278570, + -8908776 + ], + [ + 36278497, + -8912872 + ], + [ + 36278512, + -8916968 + ], + [ + 36278603, + -8921064 + ], + [ + 36278637, + -8925160 + ], + [ + 36278576, + -8929256 + ], + [ + 36278560, + -8933352 + ], + [ + 36278562, + -8937448 + ], + [ + 36278633, + -8941544 + ], + [ + 36278762, + -8945640 + ], + [ + 36278721, + -8949736 + ], + [ + 36278683, + -8953832 + ], + [ + 36278633, + -8957928 + ], + [ + 36278541, + -8962024 + ], + [ + 36278438, + -8966120 + ], + [ + 36278420, + -8970216 + ], + [ + 36278325, + -8974312 + ], + [ + 36278270, + -8978408 + ], + [ + 36278171, + -8982504 + ], + [ + 36278088, + -8986600 + ], + [ + 36278015, + -8990696 + ], + [ + 36277945, + -8994792 + ], + [ + 36277900, + -8998888 + ], + [ + 36277850, + -9002984 + ], + [ + 36277797, + -9007080 + ], + [ + 36277740, + -9011176 + ], + [ + 36277685, + -9015272 + ], + [ + 36277642, + -9019368 + ], + [ + 36277579, + -9023464 + ], + [ + 36277519, + -9027560 + ], + [ + 36277464, + -9031656 + ], + [ + 36277426, + -9035752 + ], + [ + 36277395, + -9039848 + ], + [ + 36277371, + -9043944 + ], + [ + 36277356, + -9048040 + ], + [ + 36277348, + -9052136 + ], + [ + 36277345, + -9054444 + ] + ] + }, + { + "rung": "quarter", + "edge_id": 655476, + "class": 2, + "terminus": "Interior", + "points": [ + [ + 36277345, + -6830545 + ], + [ + 36277345, + -6831569 + ], + [ + 36277345, + -6832593 + ], + [ + 36277345, + -6833617 + ], + [ + 36277345, + -6834641 + ], + [ + 36277344, + -6835665 + ], + [ + 36277344, + -6836689 + ], + [ + 36277345, + -6837713 + ], + [ + 36277345, + -6838737 + ], + [ + 36277345, + -6839761 + ], + [ + 36277345, + -6840785 + ], + [ + 36277344, + -6841809 + ], + [ + 36277344, + -6842833 + ], + [ + 36277344, + -6843857 + ], + [ + 36277344, + -6844881 + ], + [ + 36277344, + -6845905 + ], + [ + 36277344, + -6846929 + ], + [ + 36277343, + -6847953 + ], + [ + 36277343, + -6848977 + ], + [ + 36277343, + -6850001 + ], + [ + 36277342, + -6851025 + ], + [ + 36277342, + -6852049 + ], + [ + 36277342, + -6853073 + ], + [ + 36277341, + -6854097 + ], + [ + 36277339, + -6855121 + ], + [ + 36277338, + -6856145 + ], + [ + 36277335, + -6857169 + ], + [ + 36277333, + -6858193 + ], + [ + 36277332, + -6859217 + ], + [ + 36277329, + -6860241 + ], + [ + 36277329, + -6861265 + ], + [ + 36277329, + -6862289 + ], + [ + 36277329, + -6863313 + ], + [ + 36277331, + -6864337 + ], + [ + 36277330, + -6865361 + ], + [ + 36277329, + -6866385 + ], + [ + 36277329, + -6867409 + ], + [ + 36277328, + -6868433 + ], + [ + 36277328, + -6869457 + ], + [ + 36277328, + -6870481 + ], + [ + 36277326, + -6871505 + ], + [ + 36277323, + -6872529 + ], + [ + 36277319, + -6873553 + ], + [ + 36277316, + -6874577 + ], + [ + 36277313, + -6875601 + ], + [ + 36277310, + -6876625 + ], + [ + 36277303, + -6877649 + ], + [ + 36277300, + -6878673 + ], + [ + 36277296, + -6879697 + ], + [ + 36277291, + -6880721 + ], + [ + 36277284, + -6881745 + ], + [ + 36277279, + -6882769 + ], + [ + 36277274, + -6883793 + ], + [ + 36277271, + -6884817 + ], + [ + 36277266, + -6885841 + ], + [ + 36277263, + -6886865 + ], + [ + 36277260, + -6887889 + ], + [ + 36277257, + -6888913 + ], + [ + 36277251, + -6889937 + ], + [ + 36277247, + -6890961 + ], + [ + 36277244, + -6891985 + ], + [ + 36277242, + -6893009 + ], + [ + 36277240, + -6894033 + ], + [ + 36277241, + -6895057 + ], + [ + 36277237, + -6896081 + ], + [ + 36277233, + -6897105 + ], + [ + 36277228, + -6898129 + ], + [ + 36277225, + -6899153 + ], + [ + 36277224, + -6900177 + ], + [ + 36277217, + -6901201 + ], + [ + 36277212, + -6902225 + ], + [ + 36277212, + -6903249 + ], + [ + 36277212, + -6904273 + ], + [ + 36277209, + -6905297 + ], + [ + 36277207, + -6906321 + ], + [ + 36277203, + -6907345 + ], + [ + 36277200, + -6908369 + ], + [ + 36277199, + -6909393 + ], + [ + 36277197, + -6910417 + ], + [ + 36277193, + -6911441 + ], + [ + 36277194, + -6912465 + ], + [ + 36277195, + -6913489 + ], + [ + 36277195, + -6914513 + ], + [ + 36277198, + -6915537 + ], + [ + 36277201, + -6916561 + ], + [ + 36277202, + -6917585 + ], + [ + 36277197, + -6918609 + ], + [ + 36277197, + -6919633 + ], + [ + 36277199, + -6920657 + ], + [ + 36277204, + -6921681 + ], + [ + 36277207, + -6922705 + ], + [ + 36277210, + -6923729 + ], + [ + 36277211, + -6924753 + ], + [ + 36277213, + -6925777 + ], + [ + 36277217, + -6926801 + ], + [ + 36277220, + -6927825 + ], + [ + 36277220, + -6928849 + ], + [ + 36277223, + -6929873 + ], + [ + 36277220, + -6930897 + ], + [ + 36277217, + -6931921 + ], + [ + 36277214, + -6932945 + ], + [ + 36277212, + -6933969 + ], + [ + 36277213, + -6934993 + ], + [ + 36277218, + -6936017 + ], + [ + 36277226, + -6937041 + ], + [ + 36277230, + -6938065 + ], + [ + 36277232, + -6939089 + ], + [ + 36277236, + -6940113 + ], + [ + 36277243, + -6941137 + ], + [ + 36277247, + -6942161 + ], + [ + 36277251, + -6943185 + ], + [ + 36277254, + -6944209 + ], + [ + 36277260, + -6945233 + ], + [ + 36277266, + -6946257 + ], + [ + 36277270, + -6947281 + ], + [ + 36277271, + -6948305 + ], + [ + 36277268, + -6949329 + ], + [ + 36277266, + -6950353 + ], + [ + 36277265, + -6951377 + ], + [ + 36277265, + -6952401 + ], + [ + 36277269, + -6953425 + ], + [ + 36277269, + -6954449 + ], + [ + 36277265, + -6955473 + ], + [ + 36277257, + -6956497 + ], + [ + 36277253, + -6957521 + ], + [ + 36277256, + -6958545 + ], + [ + 36277252, + -6959569 + ], + [ + 36277242, + -6960593 + ], + [ + 36277231, + -6961617 + ], + [ + 36277219, + -6962641 + ], + [ + 36277207, + -6963665 + ], + [ + 36277198, + -6964689 + ], + [ + 36277192, + -6965713 + ], + [ + 36277184, + -6966737 + ], + [ + 36277180, + -6967761 + ], + [ + 36277178, + -6968785 + ], + [ + 36277178, + -6969809 + ], + [ + 36277179, + -6970833 + ], + [ + 36277177, + -6971857 + ], + [ + 36277179, + -6972881 + ], + [ + 36277185, + -6973905 + ], + [ + 36277191, + -6974929 + ], + [ + 36277199, + -6975953 + ], + [ + 36277211, + -6976977 + ], + [ + 36277220, + -6978001 + ], + [ + 36277226, + -6979025 + ], + [ + 36277228, + -6980049 + ], + [ + 36277228, + -6981073 + ], + [ + 36277225, + -6982097 + ], + [ + 36277220, + -6983121 + ], + [ + 36277218, + -6984145 + ], + [ + 36277219, + -6985169 + ], + [ + 36277222, + -6986193 + ], + [ + 36277225, + -6987217 + ], + [ + 36277226, + -6988241 + ], + [ + 36277226, + -6989265 + ], + [ + 36277226, + -6990289 + ], + [ + 36277222, + -6991313 + ], + [ + 36277221, + -6992337 + ], + [ + 36277222, + -6993361 + ], + [ + 36277221, + -6994385 + ], + [ + 36277217, + -6995409 + ], + [ + 36277218, + -6996433 + ], + [ + 36277218, + -6997457 + ], + [ + 36277220, + -6998481 + ], + [ + 36277220, + -6999505 + ], + [ + 36277216, + -7000529 + ], + [ + 36277213, + -7001553 + ], + [ + 36277211, + -7002577 + ], + [ + 36277210, + -7003601 + ], + [ + 36277210, + -7004625 + ], + [ + 36277212, + -7005649 + ], + [ + 36277215, + -7006673 + ], + [ + 36277223, + -7007697 + ], + [ + 36277228, + -7008721 + ], + [ + 36277230, + -7009745 + ], + [ + 36277233, + -7010769 + ], + [ + 36277233, + -7011793 + ], + [ + 36277233, + -7012817 + ], + [ + 36277236, + -7013841 + ], + [ + 36277238, + -7014865 + ], + [ + 36277237, + -7015889 + ], + [ + 36277241, + -7016913 + ], + [ + 36277243, + -7017937 + ], + [ + 36277248, + -7018961 + ], + [ + 36277255, + -7019985 + ], + [ + 36277266, + -7021009 + ], + [ + 36277277, + -7022033 + ], + [ + 36277293, + -7023057 + ], + [ + 36277300, + -7024081 + ], + [ + 36277304, + -7025105 + ], + [ + 36277308, + -7026129 + ], + [ + 36277308, + -7027153 + ], + [ + 36277302, + -7028177 + ], + [ + 36277300, + -7029201 + ], + [ + 36277302, + -7030225 + ], + [ + 36277308, + -7031249 + ], + [ + 36277313, + -7032273 + ], + [ + 36277319, + -7033297 + ], + [ + 36277318, + -7034321 + ], + [ + 36277313, + -7035345 + ], + [ + 36277311, + -7036369 + ], + [ + 36277306, + -7037393 + ], + [ + 36277303, + -7038417 + ], + [ + 36277305, + -7039441 + ], + [ + 36277313, + -7040465 + ], + [ + 36277316, + -7041489 + ], + [ + 36277316, + -7042513 + ], + [ + 36277316, + -7043537 + ], + [ + 36277320, + -7044561 + ], + [ + 36277326, + -7045585 + ], + [ + 36277328, + -7046609 + ], + [ + 36277334, + -7047633 + ], + [ + 36277337, + -7048657 + ], + [ + 36277343, + -7049681 + ], + [ + 36277351, + -7050705 + ], + [ + 36277356, + -7051729 + ], + [ + 36277360, + -7052753 + ], + [ + 36277365, + -7053777 + ], + [ + 36277371, + -7054801 + ], + [ + 36277377, + -7055825 + ], + [ + 36277382, + -7056849 + ], + [ + 36277384, + -7057873 + ], + [ + 36277387, + -7058897 + ], + [ + 36277389, + -7059921 + ], + [ + 36277391, + -7060945 + ], + [ + 36277392, + -7061969 + ], + [ + 36277392, + -7062993 + ], + [ + 36277391, + -7064017 + ], + [ + 36277391, + -7065041 + ], + [ + 36277390, + -7066065 + ], + [ + 36277390, + -7067089 + ], + [ + 36277391, + -7068113 + ], + [ + 36277392, + -7069137 + ], + [ + 36277396, + -7070161 + ], + [ + 36277399, + -7071185 + ], + [ + 36277398, + -7072209 + ], + [ + 36277397, + -7073233 + ], + [ + 36277398, + -7074257 + ], + [ + 36277397, + -7075281 + ], + [ + 36277397, + -7076305 + ], + [ + 36277398, + -7077329 + ], + [ + 36277402, + -7078353 + ], + [ + 36277404, + -7079377 + ], + [ + 36277406, + -7080401 + ], + [ + 36277410, + -7081425 + ], + [ + 36277418, + -7082449 + ], + [ + 36277424, + -7083473 + ], + [ + 36277428, + -7084497 + ], + [ + 36277431, + -7085521 + ], + [ + 36277433, + -7086545 + ], + [ + 36277434, + -7087569 + ], + [ + 36277436, + -7088593 + ], + [ + 36277434, + -7089617 + ], + [ + 36277433, + -7090641 + ], + [ + 36277431, + -7091665 + ], + [ + 36277430, + -7092689 + ], + [ + 36277429, + -7093713 + ], + [ + 36277426, + -7094737 + ], + [ + 36277420, + -7095761 + ], + [ + 36277416, + -7096785 + ], + [ + 36277412, + -7097809 + ], + [ + 36277409, + -7098833 + ], + [ + 36277407, + -7099857 + ], + [ + 36277406, + -7100881 + ], + [ + 36277404, + -7101905 + ], + [ + 36277401, + -7102929 + ], + [ + 36277399, + -7103953 + ], + [ + 36277398, + -7104977 + ], + [ + 36277396, + -7106001 + ], + [ + 36277393, + -7107025 + ], + [ + 36277392, + -7108049 + ], + [ + 36277391, + -7109073 + ], + [ + 36277389, + -7110097 + ], + [ + 36277388, + -7111121 + ], + [ + 36277387, + -7112145 + ], + [ + 36277388, + -7113169 + ], + [ + 36277389, + -7114193 + ], + [ + 36277390, + -7115217 + ], + [ + 36277391, + -7116241 + ], + [ + 36277392, + -7117265 + ], + [ + 36277392, + -7118289 + ], + [ + 36277392, + -7119313 + ], + [ + 36277391, + -7120337 + ], + [ + 36277389, + -7121361 + ], + [ + 36277387, + -7122385 + ], + [ + 36277384, + -7123409 + ], + [ + 36277383, + -7124433 + ], + [ + 36277383, + -7125457 + ], + [ + 36277382, + -7126481 + ], + [ + 36277381, + -7127505 + ], + [ + 36277380, + -7128529 + ], + [ + 36277378, + -7129553 + ], + [ + 36277377, + -7130577 + ], + [ + 36277375, + -7131601 + ], + [ + 36277373, + -7132625 + ], + [ + 36277372, + -7133649 + ], + [ + 36277371, + -7134673 + ], + [ + 36277369, + -7135697 + ], + [ + 36277368, + -7136721 + ], + [ + 36277366, + -7137745 + ], + [ + 36277365, + -7138769 + ], + [ + 36277363, + -7139793 + ], + [ + 36277361, + -7140817 + ], + [ + 36277359, + -7141841 + ], + [ + 36277356, + -7142865 + ], + [ + 36277354, + -7143889 + ], + [ + 36277352, + -7144913 + ], + [ + 36277350, + -7145937 + ], + [ + 36277347, + -7146961 + ], + [ + 36277345, + -7147985 + ] + ] + }, + { + "rung": "quarter_mouth", + "edge_id": 262260, + "class": 2, + "terminus": "Mouth", + "points": [ + [ + 36277345, + -8736744 + ], + [ + 36277344, + -8737768 + ], + [ + 36277344, + -8738792 + ], + [ + 36277343, + -8739816 + ], + [ + 36277343, + -8740840 + ], + [ + 36277343, + -8741864 + ], + [ + 36277343, + -8742888 + ], + [ + 36277343, + -8743912 + ], + [ + 36277343, + -8744936 + ], + [ + 36277343, + -8745960 + ], + [ + 36277343, + -8746984 + ], + [ + 36277343, + -8748008 + ], + [ + 36277342, + -8749032 + ], + [ + 36277342, + -8750056 + ], + [ + 36277342, + -8751080 + ], + [ + 36277342, + -8752104 + ], + [ + 36277341, + -8753128 + ], + [ + 36277341, + -8754152 + ], + [ + 36277342, + -8755176 + ], + [ + 36277343, + -8756200 + ], + [ + 36277343, + -8757224 + ], + [ + 36277343, + -8758248 + ], + [ + 36277344, + -8759272 + ], + [ + 36277343, + -8760296 + ], + [ + 36277344, + -8761320 + ], + [ + 36277346, + -8762344 + ], + [ + 36277348, + -8763368 + ], + [ + 36277349, + -8764392 + ], + [ + 36277349, + -8765416 + ], + [ + 36277350, + -8766440 + ], + [ + 36277351, + -8767464 + ], + [ + 36277353, + -8768488 + ], + [ + 36277355, + -8769512 + ], + [ + 36277356, + -8770536 + ], + [ + 36277355, + -8771560 + ], + [ + 36277356, + -8772584 + ], + [ + 36277356, + -8773608 + ], + [ + 36277355, + -8774632 + ], + [ + 36277354, + -8775656 + ], + [ + 36277354, + -8776680 + ], + [ + 36277354, + -8777704 + ], + [ + 36277353, + -8778728 + ], + [ + 36277353, + -8779752 + ], + [ + 36277356, + -8780776 + ], + [ + 36277357, + -8781800 + ], + [ + 36277353, + -8782824 + ], + [ + 36277350, + -8783848 + ], + [ + 36277349, + -8784872 + ], + [ + 36277346, + -8785896 + ], + [ + 36277344, + -8786920 + ], + [ + 36277343, + -8787944 + ], + [ + 36277339, + -8788968 + ], + [ + 36277335, + -8789992 + ], + [ + 36277332, + -8791016 + ], + [ + 36277328, + -8792040 + ], + [ + 36277324, + -8793064 + ], + [ + 36277323, + -8794088 + ], + [ + 36277321, + -8795112 + ], + [ + 36277324, + -8796136 + ], + [ + 36277323, + -8797160 + ], + [ + 36277323, + -8798184 + ], + [ + 36277323, + -8799208 + ], + [ + 36277323, + -8800232 + ], + [ + 36277326, + -8801256 + ], + [ + 36277324, + -8802280 + ], + [ + 36277325, + -8803304 + ], + [ + 36277329, + -8804328 + ], + [ + 36277332, + -8805352 + ], + [ + 36277333, + -8806376 + ], + [ + 36277336, + -8807400 + ], + [ + 36277337, + -8808424 + ], + [ + 36277339, + -8809448 + ], + [ + 36277339, + -8810472 + ], + [ + 36277341, + -8811496 + ], + [ + 36277348, + -8812520 + ], + [ + 36277351, + -8813544 + ], + [ + 36277356, + -8814568 + ], + [ + 36277362, + -8815592 + ], + [ + 36277365, + -8816616 + ], + [ + 36277365, + -8817640 + ], + [ + 36277365, + -8818664 + ], + [ + 36277371, + -8819688 + ], + [ + 36277373, + -8820712 + ], + [ + 36277375, + -8821736 + ], + [ + 36277378, + -8822760 + ], + [ + 36277381, + -8823784 + ], + [ + 36277384, + -8824808 + ], + [ + 36277389, + -8825832 + ], + [ + 36277393, + -8826856 + ], + [ + 36277394, + -8827880 + ], + [ + 36277395, + -8828904 + ], + [ + 36277398, + -8829928 + ], + [ + 36277399, + -8830952 + ], + [ + 36277400, + -8831976 + ], + [ + 36277401, + -8833000 + ], + [ + 36277403, + -8834024 + ], + [ + 36277405, + -8835048 + ], + [ + 36277410, + -8836072 + ], + [ + 36277415, + -8837096 + ], + [ + 36277422, + -8838120 + ], + [ + 36277432, + -8839144 + ], + [ + 36277443, + -8840168 + ], + [ + 36277448, + -8841192 + ], + [ + 36277455, + -8842216 + ], + [ + 36277464, + -8843240 + ], + [ + 36277467, + -8844264 + ], + [ + 36277474, + -8845288 + ], + [ + 36277482, + -8846312 + ], + [ + 36277484, + -8847336 + ], + [ + 36277494, + -8848360 + ], + [ + 36277507, + -8849384 + ], + [ + 36277519, + -8850408 + ], + [ + 36277528, + -8851432 + ], + [ + 36277534, + -8852456 + ], + [ + 36277539, + -8853480 + ], + [ + 36277550, + -8854504 + ], + [ + 36277562, + -8855528 + ], + [ + 36277566, + -8856552 + ], + [ + 36277568, + -8857576 + ], + [ + 36277572, + -8858600 + ], + [ + 36277574, + -8859624 + ], + [ + 36277574, + -8860648 + ], + [ + 36277584, + -8861672 + ], + [ + 36277594, + -8862696 + ], + [ + 36277599, + -8863720 + ], + [ + 36277602, + -8864744 + ], + [ + 36277601, + -8865768 + ], + [ + 36277612, + -8866792 + ], + [ + 36277620, + -8867816 + ], + [ + 36277627, + -8868840 + ], + [ + 36277631, + -8869864 + ], + [ + 36277631, + -8870888 + ], + [ + 36277635, + -8871912 + ], + [ + 36277631, + -8872936 + ], + [ + 36277632, + -8873960 + ], + [ + 36277633, + -8874984 + ], + [ + 36277632, + -8876008 + ], + [ + 36277637, + -8877032 + ], + [ + 36277641, + -8878056 + ], + [ + 36277645, + -8879080 + ], + [ + 36277652, + -8880104 + ], + [ + 36277658, + -8881128 + ], + [ + 36277661, + -8882152 + ], + [ + 36277661, + -8883176 + ], + [ + 36277666, + -8884200 + ], + [ + 36277675, + -8885224 + ], + [ + 36277680, + -8886248 + ], + [ + 36277686, + -8887272 + ], + [ + 36277691, + -8888296 + ], + [ + 36277698, + -8889320 + ], + [ + 36277699, + -8890344 + ], + [ + 36277701, + -8891368 + ], + [ + 36277700, + -8892392 + ], + [ + 36277698, + -8893416 + ], + [ + 36277695, + -8894440 + ], + [ + 36277692, + -8895464 + ], + [ + 36277696, + -8896488 + ], + [ + 36277696, + -8897512 + ], + [ + 36277699, + -8898536 + ], + [ + 36277705, + -8899560 + ], + [ + 36277706, + -8900584 + ], + [ + 36277695, + -8901608 + ], + [ + 36277685, + -8902632 + ], + [ + 36277678, + -8903656 + ], + [ + 36277673, + -8904680 + ], + [ + 36277669, + -8905704 + ], + [ + 36277662, + -8906728 + ], + [ + 36277654, + -8907752 + ], + [ + 36277646, + -8908776 + ], + [ + 36277641, + -8909800 + ], + [ + 36277637, + -8910824 + ], + [ + 36277630, + -8911848 + ], + [ + 36277629, + -8912872 + ], + [ + 36277627, + -8913896 + ], + [ + 36277632, + -8914920 + ], + [ + 36277634, + -8915944 + ], + [ + 36277634, + -8916968 + ], + [ + 36277636, + -8917992 + ], + [ + 36277647, + -8919016 + ], + [ + 36277657, + -8920040 + ], + [ + 36277660, + -8921064 + ], + [ + 36277660, + -8922088 + ], + [ + 36277663, + -8923112 + ], + [ + 36277662, + -8924136 + ], + [ + 36277662, + -8925160 + ], + [ + 36277660, + -8926184 + ], + [ + 36277655, + -8927208 + ], + [ + 36277650, + -8928232 + ], + [ + 36277647, + -8929256 + ], + [ + 36277645, + -8930280 + ], + [ + 36277642, + -8931304 + ], + [ + 36277641, + -8932328 + ], + [ + 36277641, + -8933352 + ], + [ + 36277642, + -8934376 + ], + [ + 36277644, + -8935400 + ], + [ + 36277647, + -8936424 + ], + [ + 36277647, + -8937448 + ], + [ + 36277647, + -8938472 + ], + [ + 36277646, + -8939496 + ], + [ + 36277650, + -8940520 + ], + [ + 36277662, + -8941544 + ], + [ + 36277674, + -8942568 + ], + [ + 36277682, + -8943592 + ], + [ + 36277687, + -8944616 + ], + [ + 36277690, + -8945640 + ], + [ + 36277689, + -8946664 + ], + [ + 36277690, + -8947688 + ], + [ + 36277688, + -8948712 + ], + [ + 36277685, + -8949736 + ], + [ + 36277684, + -8950760 + ], + [ + 36277681, + -8951784 + ], + [ + 36277674, + -8952808 + ], + [ + 36277673, + -8953832 + ], + [ + 36277671, + -8954856 + ], + [ + 36277666, + -8955880 + ], + [ + 36277663, + -8956904 + ], + [ + 36277662, + -8957928 + ], + [ + 36277659, + -8958952 + ], + [ + 36277653, + -8959976 + ], + [ + 36277649, + -8961000 + ], + [ + 36277645, + -8962024 + ], + [ + 36277637, + -8963048 + ], + [ + 36277624, + -8964072 + ], + [ + 36277618, + -8965096 + ], + [ + 36277617, + -8966120 + ], + [ + 36277615, + -8967144 + ], + [ + 36277616, + -8968168 + ], + [ + 36277616, + -8969192 + ], + [ + 36277612, + -8970216 + ], + [ + 36277607, + -8971240 + ], + [ + 36277598, + -8972264 + ], + [ + 36277589, + -8973288 + ], + [ + 36277583, + -8974312 + ], + [ + 36277581, + -8975336 + ], + [ + 36277576, + -8976360 + ], + [ + 36277575, + -8977384 + ], + [ + 36277570, + -8978408 + ], + [ + 36277567, + -8979432 + ], + [ + 36277562, + -8980456 + ], + [ + 36277554, + -8981480 + ], + [ + 36277545, + -8982504 + ], + [ + 36277542, + -8983528 + ], + [ + 36277536, + -8984552 + ], + [ + 36277530, + -8985576 + ], + [ + 36277526, + -8986600 + ], + [ + 36277523, + -8987624 + ], + [ + 36277521, + -8988648 + ], + [ + 36277518, + -8989672 + ], + [ + 36277512, + -8990696 + ], + [ + 36277506, + -8991720 + ], + [ + 36277501, + -8992744 + ], + [ + 36277494, + -8993768 + ], + [ + 36277490, + -8994792 + ], + [ + 36277488, + -8995816 + ], + [ + 36277488, + -8996840 + ], + [ + 36277486, + -8997864 + ], + [ + 36277483, + -8998888 + ], + [ + 36277483, + -8999912 + ], + [ + 36277477, + -9000936 + ], + [ + 36277472, + -9001960 + ], + [ + 36277468, + -9002984 + ], + [ + 36277464, + -9004008 + ], + [ + 36277460, + -9005032 + ], + [ + 36277458, + -9006056 + ], + [ + 36277457, + -9007080 + ], + [ + 36277454, + -9008104 + ], + [ + 36277450, + -9009128 + ], + [ + 36277445, + -9010152 + ], + [ + 36277440, + -9011176 + ], + [ + 36277437, + -9012200 + ], + [ + 36277435, + -9013224 + ], + [ + 36277432, + -9014248 + ], + [ + 36277428, + -9015272 + ], + [ + 36277426, + -9016296 + ], + [ + 36277424, + -9017320 + ], + [ + 36277421, + -9018344 + ], + [ + 36277417, + -9019368 + ], + [ + 36277415, + -9020392 + ], + [ + 36277413, + -9021416 + ], + [ + 36277409, + -9022440 + ], + [ + 36277404, + -9023464 + ], + [ + 36277399, + -9024488 + ], + [ + 36277396, + -9025512 + ], + [ + 36277391, + -9026536 + ], + [ + 36277388, + -9027560 + ], + [ + 36277385, + -9028584 + ], + [ + 36277383, + -9029608 + ], + [ + 36277378, + -9030632 + ], + [ + 36277374, + -9031656 + ], + [ + 36277371, + -9032680 + ], + [ + 36277369, + -9033704 + ], + [ + 36277367, + -9034728 + ], + [ + 36277364, + -9035752 + ], + [ + 36277362, + -9036776 + ], + [ + 36277361, + -9037800 + ], + [ + 36277360, + -9038824 + ], + [ + 36277358, + -9039848 + ], + [ + 36277356, + -9040872 + ], + [ + 36277354, + -9041896 + ], + [ + 36277353, + -9042920 + ], + [ + 36277351, + -9043944 + ], + [ + 36277350, + -9044968 + ], + [ + 36277349, + -9045992 + ], + [ + 36277349, + -9047016 + ], + [ + 36277348, + -9048040 + ], + [ + 36277347, + -9049064 + ], + [ + 36277346, + -9050088 + ], + [ + 36277346, + -9051112 + ], + [ + 36277346, + -9052136 + ], + [ + 36277345, + -9053160 + ], + [ + 36277345, + -9054184 + ] + ] + } +] diff --git a/server/tests/golden/window_derivation_golden.json b/server/tests/golden/window_derivation_golden.json index 1e6afbf70..8525ba0da 100644 --- a/server/tests/golden/window_derivation_golden.json +++ b/server/tests/golden/window_derivation_golden.json @@ -254,6 +254,57 @@ "moisture_q": 43, "vegetation": 1 }, + { + "label": "river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 2, + "elev_q": 8, + "ocean_fraction_q": 0, + "temperature_dc": 14, + "moisture_q": 49, + "vegetation": 3 + }, + { + "label": "river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 3, + "elev_q": 4, + "ocean_fraction_q": 0, + "temperature_dc": 34, + "moisture_q": 50, + "vegetation": 3 + }, + { + "label": "river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 1, + "precipitation": 2, + "slope_q": 0, + "elev_q": 11, + "ocean_fraction_q": 0, + "temperature_dc": -2, + "moisture_q": 49, + "vegetation": 3 + }, { "label": "airless_dry/coastal_a", "rung": "district", @@ -509,6 +560,57 @@ "moisture_q": 0, "vegetation": 0 }, + { + "label": "airless_dry/river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 3, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, + { + "label": "airless_dry/river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 3, + "elev_q": 6, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, + { + "label": "airless_dry/river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 8, + "tectonic": 0, + "glaciation": 0, + "precipitation": 0, + "slope_q": 0, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": -2147483648, + "moisture_q": 0, + "vegetation": 0 + }, { "label": "volcanic_coast/coastal_a", "rung": "district", @@ -763,5 +865,56 @@ "temperature_dc": 555, "moisture_q": 30, "vegetation": 2 + }, + { + "label": "volcanic_coast/river_course", + "rung": "district", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 4096, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 4, + "elev_q": 14, + "ocean_fraction_q": 0, + "temperature_dc": 500, + "moisture_q": 47, + "vegetation": 3 + }, + { + "label": "volcanic_coast/river_course", + "rung": "quarter", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 1024, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 6, + "elev_q": 5, + "ocean_fraction_q": 0, + "temperature_dc": 547, + "moisture_q": 49, + "vegetation": 3 + }, + { + "label": "volcanic_coast/river_course", + "rung": "region", + "wx_m": 36277344, + "wy_m": -6830545, + "min_wl_m": 0, + "morphology": 15, + "tectonic": 2, + "glaciation": 0, + "precipitation": 2, + "slope_q": 0, + "elev_q": 18, + "ocean_fraction_q": 0, + "temperature_dc": 480, + "moisture_q": 47, + "vegetation": 3 } ] diff --git a/server/tests/window_derivation_golden.rs b/server/tests/window_derivation_golden.rs index e9ec5c073..6b6f10137 100644 --- a/server/tests/window_derivation_golden.rs +++ b/server/tests/window_derivation_golden.rs @@ -39,6 +39,7 @@ use settled_reach_server::atlas::district_profile::{ use settled_reach_server::atlas::drainage; use settled_reach_server::atlas::features::TerrainAnalysis; use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::river_course; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -163,6 +164,20 @@ fn sweep_positions() -> Vec<(&'static str, f64, f64)> { ("coastal_c", 2_100_000.0, 1_560_000.0), ("inland", 500_000.0, 3_000_000.0), ("high_lat", 1_200_000.0, 8_500_000.0), + // T-1170 A2 Discipline item 4: empirically verified (probe run against + // this fixture, `sample_hm()`/`sample_params()`) that `sample_hm()` + // produces a real river-cell chain around working-grid pixel + // (row=10, col=116) — NONE of the original five sweep positions + // (pixel cols ~1.6-6.7) land anywhere near it. This position converts + // that pixel to world metres (same `world_m_to_pixel` inverse the + // production mapping uses) so the golden sweep also exercises + // `derive_at_metres` genuinely close to invented river geometry — + // closing the "believability expected unchanged, verify, don't + // assume" discipline item for the district-profile-only fields this + // golden already pins (courses themselves are pinned separately + // below, `river_course_golden_regression`, since this sweep's + // `derive_at_metres` calls never touch `RiverCourse` at all). + ("river_course", 36_277_344.8, -6_830_545.5), ] } @@ -182,7 +197,7 @@ fn derive_golden_sample( let prof = if orbital { derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate) } else { - derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m) + derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m, &[]) }; GoldenSample { label: label.to_string(), @@ -417,3 +432,201 @@ fn golden_cutoffs_match_the_scale_ladder() { assert_eq!(2 * scale::DISTRICT_M, DISTRICT_MIN_WL_M as i32); assert_eq!(2 * scale::QUARTER_M, QUARTER_MIN_WL_M as i32); } + +// --------------------------------------------------------------------------- +// River course golden (T-1170 A2, Discipline item 4) +// --------------------------------------------------------------------------- +// +// Empirically verified (probe run against `sample_hm()`/`sample_params()`): +// this fixture body produces a real river-cell chain around working-grid +// pixel (row≈10, col=116) — the "river_course" sweep position above converts +// that pixel to world metres. This section pins the ACTUAL invented course +// geometry for an edge from that chain, at both District and Quarter station +// spacing, so courses themselves — not just the district-profile fields the +// main golden above covers — are regression-pinned. +// +// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below +// also includes a real Mouth-terminus edge from `sample_hm()` (empirically +// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output +// for that edge is pinned here (edge_id/class/terminus/points), closing the +// "zero Mouth coverage in any golden" gap at the invention layer. The FULL +// end-to-end path (invent → crop → `resolve_mouth_terminus` → +// `CourseTerminus::Mouth`) is covered separately and permanently by +// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` +// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are +// private to `layer_proxy.rs` and unreachable from this integration test) — +// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths); +// this golden's job is regression-pinning the raw invented geometry, not +// re-proving the crop/resolve path. + +const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json"; + +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)] +struct GoldenCourseSample { + rung: String, + edge_id: u32, + class: u8, + terminus: String, + /// Points rounded to the nearest metre (D-010 integer boundary at the + /// golden-pinning layer — the production wire path itself rounds to + /// `i32` metres, `layer_proxy::crop_course_to_window`). + points: Vec<(i64, i64)>, +} + +fn river_course_golden_samples() -> Vec { + let hm = sample_hm(); + let ta = sample_ta(&hm); + let params = sample_params(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + let edges = river_course::build_edges(&dr.river_network); + // Pick the interior edge whose upstream cell is closest to (row=10, + // col=116) — deterministic (BTreeMap-free linear scan, fixed tie-break + // by edge_id) rather than hardcoding an index that could silently shift + // if `build_edges`' ordering ever changes. + let target = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior) + .min_by_key(|e| { + let dr = e.upstream.0 as i64 - 10; + let dc = e.upstream.1 as i64 - 116; + (dr * dr + dc * dc, e.edge_id) + }) + .expect("sample_hm() fixture must have at least one interior river edge near (10, 116)"); + + // Hoshe #2: a real Mouth-terminus edge, deterministically selected as the + // lowest edge_id among the fixture's mouth edges (fixed tie-break, no + // hardcoded index). + let mouth_target = edges + .iter() + .filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth) + .min_by_key(|e| e.edge_id) + .expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)"); + + let mut out = Vec::new(); + for (rung, spacing_m, min_wl_m) in [ + ("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M), + ("quarter", QUARTER_MIN_WL_M, QUARTER_MIN_WL_M), + ] { + let course = river_course::invent_course(seed, target, &ta, ¶ms, spacing_m, min_wl_m); + out.push(GoldenCourseSample { + rung: rung.to_string(), + edge_id: course.edge_id, + class: course.class, + terminus: format!("{:?}", course.terminus), + points: course + .points + .iter() + .map(|p| (p.0.round() as i64, p.1.round() as i64)) + .collect(), + }); + + let mouth_course = + river_course::invent_course(seed, mouth_target, &ta, ¶ms, spacing_m, min_wl_m); + out.push(GoldenCourseSample { + rung: format!("{rung}_mouth"), + edge_id: mouth_course.edge_id, + class: mouth_course.class, + terminus: format!("{:?}", mouth_course.terminus), + points: mouth_course + .points + .iter() + .map(|p| (p.0.round() as i64, p.1.round() as i64)) + .collect(), + }); + } + out +} + +#[test] +fn river_course_golden_regression() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let golden_path = manifest.join(RIVER_COURSE_GOLDEN_FILE); + + let run1 = river_course_golden_samples(); + let run2 = river_course_golden_samples(); + assert_eq!( + run1, run2, + "double-derivation mismatch — course invention determinism is broken (D-010/D-227)" + ); + + let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n"; + + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden"); + std::fs::write(&golden_path, &actual_json).expect("write golden"); + eprintln!( + "Golden written: {} ({} bytes)", + golden_path.display(), + actual_json.len() + ); + return; + } + + let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!( + "Golden file not found: {}.\n\ + First run: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n{e}", + golden_path.display() + ) + }); + + let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual"); + let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden"); + + if actual_v != golden_v { + panic!( + "River-course golden mismatch — course invention changed.\n\ + Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\ + Golden: {}\nActual: {}", + golden_json.trim(), + actual_json.trim() + ); + } +} + +/// The course golden's target edge must genuinely differ in point geometry +/// between District and Quarter station spacing (more, finer-spaced stations +/// at Quarter — Ruling 3b's cross-rung invariant) — otherwise the golden +/// would be pinning two identical rungs and the test would give false +/// confidence. +#[test] +fn river_course_rungs_have_different_station_counts() { + let samples = river_course_golden_samples(); + let district = samples.iter().find(|s| s.rung == "district").unwrap(); + let quarter = samples.iter().find(|s| s.rung == "quarter").unwrap(); + assert!( + quarter.points.len() > district.points.len(), + "Quarter's finer station spacing must produce MORE points than District \ + (district={}, quarter={})", + district.points.len(), + quarter.points.len() + ); +} + +/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth +/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`, +/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix +/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture +/// too, not just the dedicated GJ1c acceptance test. +#[test] +fn river_course_mouth_samples_are_non_degenerate() { + let samples = river_course_golden_samples(); + for rung in ["district_mouth", "quarter_mouth"] { + let sample = samples + .iter() + .find(|s| s.rung == rung) + .unwrap_or_else(|| panic!("missing golden sample for rung {rung}")); + assert_eq!( + sample.terminus, "Mouth", + "{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target" + ); + assert!( + sample.points.len() >= 2, + "{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \ + (Hoshe #1) regressed for this fixture", + sample.points.len() + ); + } +} diff --git a/server/tests/zoom_ladder_bench.rs b/server/tests/zoom_ladder_bench.rs index 45fae8b76..0e7e3ef38 100644 --- a/server/tests/zoom_ladder_bench.rs +++ b/server/tests/zoom_ladder_bench.rs @@ -23,7 +23,8 @@ use settled_reach_server::atlas::drainage; use settled_reach_server::atlas::features::TerrainAnalysis; use settled_reach_server::atlas::heightmap::BodyHeightmap; use settled_reach_server::atlas::layer_proxy::{ - build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS, + build_district_window_layer, WindowGranularity, DISTRICT_WINDOW_MAX_N, + DISTRICT_WINDOW_MAX_N_REGION, WIRE_CAP_CELLS, }; use settled_reach_server::atlas::scale; use settled_reach_server::seed::{SeedChain, SeedDomain}; @@ -54,6 +55,12 @@ fn bench_ta(hm: &BodyHeightmap) -> TerrainAnalysis { TerrainAnalysis::analyze(hm, &dr) } +fn bench_river_network( + hm: &BodyHeightmap, +) -> settled_reach_server::atlas::body_world_state::RiverNetwork { + drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network +} + fn bench_params() -> BodyParams { BodyParams { hydrosphere: Some("ocean".into()), @@ -83,8 +90,17 @@ fn time_derive_sweep( for col in 0..grid_side { let wx = col as f64 * step_m; let wy = row as f64 * step_m; - let prof = - derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wavelength_m); + let prof = derive_at_metres( + seed, + body_id, + params, + ta, + wx, + wy, + climate, + min_wavelength_m, + &[], + ); // Prevent the optimizer from hoisting the call out of the loop. std::hint::black_box(prof.elev_q); } @@ -273,6 +289,7 @@ fn bench_derive_orbital_at_metres_region_spacing() { fn bench_served_region_window_tile_at_wire_cap() { let hm = bench_hm(); let ta = bench_ta(&hm); + let rn = bench_river_network(&hm); let params = bench_params(); let climate = ClimateConstants::default(); let seed = SeedChain::root(99).derive(SeedDomain::Body, 1); @@ -293,6 +310,7 @@ fn bench_served_region_window_tile_at_wire_cap() { "bench", ¶ms, &ta, + &rn, (0, 0), n, &climate, @@ -309,6 +327,7 @@ fn bench_served_region_window_tile_at_wire_cap() { "bench", ¶ms, &ta, + &rn, (0, 0), n, &climate, @@ -338,3 +357,208 @@ fn bench_served_region_window_tile_at_wire_cap() { " compare: shipped district n=64 cap measures ~5 ms/call (design doc §7, MEASURED)\n" ); } + +/// **T-1170 Discipline item 2 (mandatory): course-cost bench.** Window +/// derive with courses on vs. off, at District granularity, real cap `n=64` +/// — the shape Tyre's cost probe measured (+0.09-0.21 ms against a ~5 ms +/// baseline, under 5%). "Off" uses an empty `RiverNetwork` (zero edges to +/// invent, exactly the pre-T-1170 cost shape); "on" uses a real body with +/// genuine river geometry (GJ1c) so the course inventor's Stage A/B pipeline +/// actually runs for the edges that cull into the window, not a synthetic +/// gradient body that might have zero river cells at all. +#[test] +#[ignore] +fn bench_course_cost_on_vs_off() { + use settled_reach_server::atlas::body_world_state::RiverNetwork; + use settled_reach_server::atlas::drainage; + use settled_reach_server::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(512, 256); // GRID_W x GRID_H, the real production working grid + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + let rn_on = &dr.river_network; + let rn_off = RiverNetwork::default(); + assert!( + !rn_on.river_cells.is_empty(), + "GJ1c at production working resolution must have river cells for this bench to be meaningful" + ); + + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let climate = ClimateConstants::default(); + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + let n = DISTRICT_WINDOW_MAX_N; // the real n=64 shipped cap + + // Centre the window on a real river cell — a window at the world origin + // (unrelated to where GJ1c's rivers actually are) would cull EVERY edge + // out and measure nothing but baseline noise. Convert a real river cell + // to world metres (the SAME pixel_to_world_m formula + // `district_profile.rs` uses internally — inlined here since that + // function is `pub(crate)`, not reachable from an integration test), + // then to the DistrictPos the window centres on. + let river_cell = dr.river_network.river_cells[dr.river_network.river_cells.len() / 2]; + let r_km = params.body_radius_km.unwrap(); + let world_pos = ( + river_cell.1 as f64 / ta.w as f64 * (std::f64::consts::TAU * r_km * 1000.0), + (river_cell.0 as f64 / (ta.h - 1) as f64 - 0.5) * (std::f64::consts::PI * r_km * 1000.0), + ); + let center: (i32, i32) = ( + (world_pos.0 / scale::DISTRICT_M as f64).floor() as i32, + (world_pos.1 / scale::DISTRICT_M as f64).floor() as i32, + ); + + println!("\n=== T-1170 course-cost bench (District, n={n}, real GJ1c river geometry) ==="); + println!(" window centred at district {center:?} (river cell {river_cell:?})"); + + // Warm-up (allocator/cache warm, not counted). + let _ = build_district_window_layer( + seed, + "GJ1c", + ¶ms, + &ta, + &rn_off, + center, + n, + &climate, + WindowGranularity::District, + 0, + ); + let _ = build_district_window_layer( + seed, + "GJ1c", + ¶ms, + &ta, + rn_on, + center, + n, + &climate, + WindowGranularity::District, + 0, + ); + + let iterations = 1000; // higher count than the other benches — window cost here is ~1 ms, noisy at low n + + let t_off = Instant::now(); + for _ in 0..iterations { + let layer = build_district_window_layer( + seed, + "GJ1c", + ¶ms, + &ta, + &rn_off, + center, + n, + &climate, + WindowGranularity::District, + 0, + ); + std::hint::black_box(layer.morphology.len()); + } + let elapsed_off = t_off.elapsed(); + let ms_off = elapsed_off.as_secs_f64() * 1000.0 / iterations as f64; + + let t_on = Instant::now(); + let mut courses_seen = 0usize; + for _ in 0..iterations { + let layer = build_district_window_layer( + seed, + "GJ1c", + ¶ms, + &ta, + rn_on, + center, + n, + &climate, + WindowGranularity::District, + 0, + ); + courses_seen = layer.courses.len(); + std::hint::black_box(layer.morphology.len()); + } + let elapsed_on = t_on.elapsed(); + let ms_on = elapsed_on.as_secs_f64() * 1000.0 / iterations as f64; + + assert!( + courses_seen > 0, + "bench measured nothing meaningful — the window at {center:?} culled every edge out; \ + re-pick a district position genuinely near GJ1c's river geometry" + ); + + let delta_pct = ((ms_on - ms_off) / ms_off) * 100.0; + + println!( + " courses OFF (empty RiverNetwork): {:.3} ms/call ({iterations} calls, {:.2} ms total)", + ms_off, + elapsed_off.as_secs_f64() * 1000.0 + ); + println!( + " courses ON (real GJ1c network): {:.3} ms/call ({iterations} calls, {:.2} ms total, \ + {courses_seen} courses in the n={n} window at {center:?})", + ms_on, + elapsed_on.as_secs_f64() * 1000.0 + ); + println!(" delta: {delta_pct:+.1}% (Discipline item 2 budget: < ~5%)\n"); +} + +#[test] +#[ignore] +fn bench_near_perennial_water_percell_isolated() { + use settled_reach_server::atlas::drainage; + use settled_reach_server::atlas::heightmap::load_heightmap_png; + use settled_reach_server::atlas::river_course; + + 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 heightmap"); + let small = heightmap.downsample(512, 256); + let dr = drainage::analyze(&small.data, small.width, small.height, small.sea_level); + let ta = TerrainAnalysis::analyze(&small, &dr); + let params = BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("breathable".into()), + planet_class: Some("temperate".into()), + body_radius_km: Some(6371.0), + ..Default::default() + }; + let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7); + + let edges = river_course::build_edges(&dr.river_network); + let edge = &edges[edges.len() / 2]; + let course = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0); + let courses = vec![course]; + + let n = 4096u32; + let t0 = Instant::now(); + let mut count = 0; + for i in 0..n { + let pos = (i as f64 * 100.0, i as f64 * 37.0); + if river_course::near_perennial_water(pos, &courses) { + count += 1; + } + } + let elapsed = t0.elapsed(); + eprintln!( + "near_perennial_water: {:.3} ns/call ({n} calls, {} hits)", + elapsed.as_secs_f64() * 1e9 / n as f64, + count + ); + + // invent_course cost, isolated. + let t1 = Instant::now(); + for _ in 0..100 { + let c = river_course::invent_course(seed, edge, &ta, ¶ms, 2048.0, 0.0); + std::hint::black_box(c.points.len()); + } + eprintln!( + "invent_course: {:.3} us/call", + t1.elapsed().as_secs_f64() * 1e6 / 100.0 + ); +}