diff --git a/tests/test_hydrology.py b/tests/test_hydrology.py index 649cbb4..452ec40 100644 --- a/tests/test_hydrology.py +++ b/tests/test_hydrology.py @@ -49,8 +49,8 @@ def test_river_paths_connected(hydro_state): def test_rivers_reach_ocean(hydro_state): # Each river must terminate at ocean/lake, a grid border, OR a confluence with - # another river (tributaries now stop at the first claimed hex rather than - # duplicating the downstream trunk). + # another river (tributaries end AT the confluence hex, which is also part of the + # higher-flow trunk, so the polylines visually connect). water_set = { coord for coord, h in hydro_state.hexes.items() @@ -232,8 +232,11 @@ def test_lake_drainage_merges_without_rewiring_existing_river(): def test_no_shared_hexes_between_rivers(hydro_state): - # Each land hex must appear in at most one River segment. Multiple rivers sharing - # the same hex meant the downstream trunk was duplicated, causing visual overdraw. + # Each land hex must appear in at most one River segment, EXCEPT confluence hexes. + # A confluence hex is the last hex of a tributary and simultaneously an interior + # hex of the higher-flow trunk — it is shared by design so the two polylines + # visually connect. Any shared hex that is NOT a tributary endpoint represents + # genuine trunk duplication and is a bug. from collections import defaultdict hex_to_rivers: dict[tuple[int, int], list[int]] = defaultdict(list) @@ -247,8 +250,11 @@ def test_no_shared_hexes_between_rivers(hydro_state): if coord in land_terrain: hex_to_rivers[coord].append(i) - shared = {k: v for k, v in hex_to_rivers.items() if len(v) > 1} + tributary_endpoints = { + river.hexes[-1] for river in hydro_state.rivers if river.hexes[-1] in land_terrain + } + shared = {k: v for k, v in hex_to_rivers.items() if len(v) > 1 and k not in tributary_endpoints} assert not shared, ( - f"{len(shared)} land hexes appear in multiple rivers; " + f"{len(shared)} land hexes appear in multiple rivers outside confluence endpoints; " f"first offender: {next(iter(shared))} in rivers {next(iter(shared.values()))}" ) diff --git a/worldgen/stages/hydrology.py b/worldgen/stages/hydrology.py index 9f883f2..949a9ca 100644 --- a/worldgen/stages/hydrology.py +++ b/worldgen/stages/hydrology.py @@ -717,12 +717,14 @@ def _split_at_confluences( acc: dict[HexCoord, float], max_acc: float, ) -> list[River]: - """Trim each River path to a source-to-confluence segment. + """Trim intersecting tributaries to include their confluence hex. - Higher-flow rivers process first and claim their land hexes. Each subsequent - river is cut at the first land hex already owned by a higher-flow river, converting - source-to-sea duplicates into distinct source-to-confluence segments. Rivers that - shrink below 2 hexes are dropped. Original list order is preserved in the output. + Higher-flow rivers process first and claim their land hexes. A subsequent river + that intersects claimed land is cut at the first claimed land hex after its + headwater, and that confluence hex is kept so the tributary visually connects to + the trunk. Rivers that never intersect claimed land (typically highest-flow + trunks) keep their full source-to-mouth path. Rivers that shrink below 2 hexes + are dropped. Original list order is preserved in the output. This runs after all hydrological computation is final so that per-hex river_flow, flow_dir, and lake drainage connectivity are unaffected. @@ -743,7 +745,7 @@ def _split_at_confluences( cut = len(path) for i, coord in enumerate(path[1:], 1): if coord in land and coord in claimed: - cut = i + cut = i + 1 # for an intersecting tributary, include the confluence hex break trimmed = path[:cut] if len(trimmed) >= 2: