feat(ui): T-1170 B3 — course polyline drawing at District/Quarter; clip restructured per Ruling 3g

_draw() splits into two independent gates: the Layer-1-gated skeleton
path (Region chords, clip retained) and the NEW DistrictWindowLayer-
gated course path — build_course_render_plan() (pure, render-free-
testable) consumed by draw_polyline with _zs-compensated widths and
opacities from the Araminta revisit tables; mouth double-rings at
Mouth termini only (EdgeDrain/ContinuesBeyondWindow/None: three
meanings, one presentation — draw to last point, stop, documented);
zero water clip on the course path by construction (courses carry
rung-consistent termini). CourseTerminus wire vocabulary kept re-
pointable pending A2's real serde names; synthetic Ruling-3h fixtures
mean the suites need zero changes when the server payload lands. Real
gap found and fixed: window arrival never redrew the nature overlay
after the first fit (one line in _on_window_ready — courses would
miss every window swap post-pan). Water-clip header rewritten to
RESTRUCTURED status (retired on course rungs; permanent at Region
until Region goes windowed, T-1143 ruling 2). Revert-verified
(visibility-gate bypass -> 4 named failures). geometry-nature
112/112, nature-overlay 58/58, viewer 78/78, zero collateral; full
sweep 3928/3928 after the full-import bootstrap; gdlint clean (viewer
1016->1017, pre-existing overage rides T-1158).

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:05:40 +02:00
co-authored by Claude Fable 5
parent 9d7c01de02
commit c31cc6220e
6 changed files with 485 additions and 6 deletions
@@ -529,6 +529,210 @@ func test_course_class_visible_at_rung_unknown_tag_falls_back_to_empty() -> void
).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.
@@ -512,3 +512,68 @@ func test_segment_touches_drawn_water_true_when_only_midpoint_is_on_water() -> v
+ " 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()