islaApocalypse-v2/Tools/Scripts/RiverRouting.cs
beezm b5ceca0419 rivers/03b: routing refinement — three deliberate divergences from the faithful port
NOT a port. Each fix corrects a faithful reference behaviour that produced a
physically-wrong result, on the developer's explicit call. basinHasLake is KEPT
as the sort. Courses only: no height mutated, no water filled or created —
asserted per seed by a raw-bit digest of both height fields.

- FIX 1 rim cap (ISLA_RIM_CAP_M, default 30 m): a route to the sea that must
  climb higher than this above its terminal is refused; the river ends at its
  own terminal. Reference routes at any cost (rivers/03 found a 66.7 m one).
- FIX 3 lake targets: a router stops at the nearer of {ocean, significant lake},
  so it cannot skirt a lake to reach a distant coast. Reference targets ocean only.
- FIX 2 confluence: courses laid biggest-first join on TRUE cell intersection
  (never proximity); the smaller becomes a tributary and adopts the bigger one's
  downstream and terminus. Reference lays routes independently — rivers/03 found
  two rivers at the identical ocean cell on every seed.

All three are off by default (RiverRouting.Options.Faithful), so rivers/03 still
reproduces bit-for-bit from the same tool.

Taste gate: nothing locked, nothing graduated.
2026-08-24 22:00:22 -04:00

658 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
///
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
///
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
/// channel over the spill, **with no water filled**.
///
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
/// corridor is the lowest ground even when that wanders. **The locked style.**
///
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
///
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
/// declared default.
/// </summary>
public static class RiverRouting
{
public const byte StyleShort = 0;
public const byte StyleLowground = 1;
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
// heavily for climbing (50 per metre).
public const float ShortUphillPerM = 8f;
public const float LowgroundElevPerM = 1f;
public const float LowgroundBase = 0.05f;
public const float LowgroundUphillPerM = 50f;
/// <summary>The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.</summary>
public const int LakeMinTargetPx = 20_000;
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
private static readonly float[] DIST = {
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
public sealed class Route
{
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
public List<(float x, float y)> Path = new();
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
public List<(float x, float y)> Smoothed = new();
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
public bool Reached;
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
public float Cost;
/// <summary>⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
/// says whether a route crawls over a saddle or vaults a wall.</summary>
public float MaxStepUphillM;
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
public float TotalUphillM;
public int UphillSteps;
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
public float MaxElevM;
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
public float RimClimbM;
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
public (float x, float y) RimPoint;
public float LenPx, StraightPx, WanderRatio;
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
public long Expanded;
public (float x, float y) Target;
}
/// <summary>
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
///
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
///
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
/// lake-ender's extension. One routine, two uses — as the reference has it.
///
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
/// </summary>
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
{
int total = n * n;
var gcost = new float[total];
var parent = new int[total];
var closed = new bool[total];
Array.Fill(gcost, float.MaxValue);
Array.Fill(parent, -1);
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
// level. Without the clamp a route would dive for the deepest hole it could find.
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
var pq = new PriorityQueue<int, (float c, int i)>();
int start = sx * n + sy;
gcost[start] = 0f;
pq.Enqueue(start, (0f, start));
int goal = -1;
long expanded = 0;
while (pq.Count > 0)
{
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
expanded++;
if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n;
float hc = height[cx, cy];
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (closed[ni]) continue;
float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
float step = style == StyleShort
? DIST[k] + dhM * ShortUphillPerM
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
+ dhM * LowgroundUphillPerM;
float nc = gcost[c] + step;
if (nc < gcost[ni])
{
gcost[ni] = nc;
parent[ni] = c;
pq.Enqueue(ni, (nc, ni));
}
}
}
var r = new Route { Expanded = expanded };
if (goal < 0) return r; // no path — an empty route, reported upstream
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
r.Path.Reverse();
r.Reached = true;
r.Cost = gcost[goal];
r.Target = r.Path[^1];
Measure(r, height, n, sea);
r.Smoothed = SmoothCourse(r.Path);
return r;
}
/// <summary>
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
/// </summary>
private static void Measure(Route r, float[,] height, int n, float sea)
{
float startElev = ElevAt(r.Path[0]);
float maxElev = startElev;
r.RimPoint = r.Path[0];
for (int i = 1; i < r.Path.Count; i++)
{
var a = r.Path[i - 1]; var b = r.Path[i];
float dx = b.x - a.x, dy = b.y - a.y;
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
float climb = ElevAt(b) - ElevAt(a);
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
}
r.MaxElevM = maxElev;
r.RimClimbM = maxElev - startElev;
var s = r.Path[0]; var e = r.Path[^1];
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
float ElevAt((float x, float y) p) =>
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
}
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
//
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
// reference took the max cut from 14.6 m to 27.3 m.
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
{
if (raw.Count < 3) return raw;
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
if (dec.Count < 3) return raw;
var sm = dec;
for (int pass = 0; pass < 4; pass++)
{
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
for (int i = 0; i + 1 < sm.Count; i++)
{
var a = sm[i]; var b = sm[i + 1];
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
}
nxt.Add(sm[^1]);
sm = nxt;
}
return sm;
}
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
{
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
var a = pts[i0]; var b = pts[i1];
float abx = b.x - a.x, aby = b.y - a.y;
float abLen = MathF.Sqrt(abx * abx + aby * aby);
float maxD = 0f; int maxI = i0;
for (int i = i0 + 1; i < i1; i++)
{
float d = abLen < 1e-6f
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
if (d > maxD) { maxD = d; maxI = i; }
}
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
var left = Rdp(pts, i0, maxI, tol);
var right = Rdp(pts, maxI, i1, tol);
left.RemoveAt(left.Count - 1);
left.AddRange(right);
return left;
}
/// <summary>The three classes the MIX is made of.</summary>
public enum RiverClass
{
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
OceanTrunk,
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
RoutedGiant,
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
LakeEnder,
// ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══
/// <summary>⭐ rivers/03b (fix 3): a dry-basin router that reached a SIGNIFICANT LAKE before it
/// reached the sea, and terminates there. In the reference a router targets ocean only, so it
/// would skirt the lake and carry on — which is what this corrects. **No water is created.**</summary>
LakeFed,
/// <summary>⚠ rivers/03b (fix 1): a dry-basin router whose cheapest route to the sea had to
/// climb a rim HIGHER THAN THE CAP. The reference routes at any cost, which produced an
/// uphill river over a 66.7 m wall. Refused: the course ends at its own terminal — a real
/// terminal basin. **Nothing is filled; it just ends there.**</summary>
WalledOff,
}
/// <summary>
/// ⚠⚠ THE THREE DELIBERATE DIVERGENCES FROM THE REFERENCE (rivers/03b), off by default.
///
/// Defaults reproduce rivers/03's faithful port EXACTLY — no cap, ocean-only targets, no
/// confluence — so that batch stays re-runnable bit-for-bit. The refinement task turns them on.
/// **None of these is a port. Each is a motivated correction of a faithful behaviour that
/// produced a physically-wrong result**, on the developer's explicit call.
/// </summary>
public sealed class Options
{
/// <summary>⭐ FIX 1 — the rim cap, metres. A route to the sea that must climb higher than
/// this above its terminal is refused and the river becomes a walled-off lake-ender.
/// Infinity = the reference's behaviour (route at any cost).</summary>
public float RimCapM = float.PositiveInfinity;
/// <summary>⭐ FIX 3 — include significant lakes in a ROUTER's target mask, so a river stops
/// at the nearer of {ocean, significant lake} instead of skirting a lake to reach the sea.
/// False = the reference's behaviour (routers target ocean only).</summary>
public bool LakeTargetForRouters;
/// <summary>⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell
/// intersection instead of running as parallel duplicates to the same mouth.
/// False = the reference's behaviour (no dedup, no join).</summary>
public bool Confluence;
/// <summary>rivers/03's faithful settings — every divergence off.</summary>
public static Options Faithful => new();
}
/// <summary>One promoted river, classified, routed and assembled.</summary>
public sealed class RoutedRiver
{
public RiverCandidate Candidate;
public RiverClass Class;
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
/// route for a lake-ender. Null for trunks.</summary>
public Route Lowland;
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
public Route OceanProbe;
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
public bool LakeReached, LakeWasFallback;
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
public List<(float x, float y)> Course;
public string Why = "";
// ═══ rivers/03b ═══
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
public float CappedRimM;
public bool RefusedByCap;
/// <summary>The class this river WOULD have had under the reference's rules — so every
/// reclassification the divergences caused is legible rather than silent.</summary>
public RiverClass FaithfulClass;
/// <summary>The full course rasterised to cells — what the confluence test intersects on.</summary>
public List<(int x, int y)> CellPath;
/// <summary>⭐ What this river draws: its OWN reach, truncated at its junction if it joined.
/// The union of every river's own reach is the dendritic tree.</summary>
public List<(float x, float y)> OwnPath;
/// <summary>The rank of the river this one flows into, or 0 if it keeps its own terminus.</summary>
public int ConfluenceParentRank;
public bool Joined;
public (int x, int y) JunctionCell;
/// <summary>How many leading cells of <see cref="CellPath"/> are the NATURAL upland stem.
/// Everything after is the lowland reach routing added — the plate colours the two apart.</summary>
public int StemCells;
/// <summary>Does this river's own course end at the sea, before any confluence?</summary>
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
}
/// <summary>
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
///
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
///
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
///
/// **So the reference's sort is used, because it is the one that actually discriminates:**
/// <code>
/// Kind = (basinHasLake[id] &amp;&amp; !SouthernCandidate) ? "lake-ender" : "routed"
/// </code>
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
/// (v2 has no towns, so `southernPick` is 1 and the southern override never fires.)
///
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
/// for EVERY giant, lake-enders included (<see cref="RoutedRiver.OceanProbe"/>), so the batch can
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
/// **Nothing is locked here — the classification shown is the reference's.**
/// </summary>
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action<string> log, Options opt = null)
{
opt ??= Options.Faithful;
// ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which
// is the reference. With it on, a significant lake is an equally valid place for a river to
// stop, so the Dijkstra halts at whichever it reaches first and a river can no longer skirt
// a lake on its way to a distant coast.
bool[] routerTargets = isOcean;
if (opt.LakeTargetForRouters)
{
routerTargets = new bool[n * n];
for (int i = 0; i < routerTargets.Length; i++)
routerTargets[i] = isOcean[i] || isSignificantWater[i];
}
var outp = new List<RoutedRiver>();
foreach (var c in promoted)
{
var rr = new RoutedRiver { Candidate = c };
if (c.IsSea)
{
// A natural ocean trunk needs no lowland route: erosion already carried it to the
// coast, and its outlet is ON the coast by construction. The stem IS the course.
rr.Class = RiverClass.OceanTrunk;
rr.Course = new List<(float x, float y)>(c.Course);
rr.Course.Reverse();
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
continue;
}
// ⭐ The ocean probe, for every giant — the affordability evidence (rivers/03).
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
rr.OceanProbe = probe;
// ⚠ `basinHasLake` is KEPT as the sort (rivers/03's finding): a basin that already holds a
// visible lake is a natural lake-ender and its river feeds its own lake — it is not routed
// anywhere. Only DRY basins are candidate routers. None of the three divergences touches this.
bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder)
{
// Route to the nearest of the target mask — {ocean} faithfully, {ocean lakes} refined.
var route = opt.LakeTargetForRouters
? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
: probe;
rr.FaithfulClass = RiverClass.RoutedGiant;
rr.Lowland = route;
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
bool stoppedAtLake = route.Reached
&& isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
&& !isOcean[(int)route.Target.x * n + (int)route.Target.y];
if (!route.Reached)
{
rr.Class = RiverClass.RoutedGiant;
rr.Why = "dry pan → routed, but NO path to a target was found (unexpected — report)";
}
else if (stoppedAtLake)
{
// ⭐ FIX 3 — it met a significant lake first. It terminates there. NO WATER CREATED:
// the course simply ends at an existing body.
rr.Class = RiverClass.LakeFed;
rr.Why = $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
}
else if (route.RimClimbM > opt.RimCapM)
{
// ⭐ FIX 1 — the cheapest way to the sea still climbs a wall. Refuse it. The course
// ends at its own terminal, a real terminal basin. NOTHING IS FILLED.
rr.Class = RiverClass.WalledOff;
rr.RefusedByCap = true;
rr.Lowland = null;
rr.Why = $"dry pan → WALLED OFF: cheapest route to the sea climbs {route.RimClimbM:F1} m > cap {opt.RimCapM:F0} m (cost {route.Cost:N0}) — ends at its own terminal";
}
else
{
rr.Class = RiverClass.RoutedGiant;
rr.Why = $"dry pan → routed to the sea; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, max step {route.MaxStepUphillM:F2} m, cost {route.Cost:N0}";
}
}
else
{
rr.Class = RiverClass.LakeEnder;
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
// minimum — a blind descent dead-ends there immediately. Route to the nearest
// SIGNIFICANT body with the same lowground Dijkstra, so the course joins the lake.
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
var ext = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
if (!ext.Reached)
{
// Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
// small ponds still connects rather than dead-ending.
var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
}
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
rr.FaithfulClass = RiverClass.LakeEnder;
rr.Why = rr.LakeReached
? $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
: "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal";
}
rr.Course = Assemble(c.Course, rr.Lowland);
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " +
$"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" +
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" +
$"{(rr.RefusedByCap ? " REFUSED BY CAP" : "")}" +
$"{(rr.Class == RiverClass.LakeFed ? " stopped at a lake, not the coast" : "")}");
}
if (opt.Confluence) Confluence(outp, log);
else foreach (var rr in outp) rr.OwnPath = rr.Course;
return outp;
}
public static string ClassLabel(RiverClass c) => c switch
{
RiverClass.OceanTrunk => "TRUNK",
RiverClass.RoutedGiant => "ROUTED",
RiverClass.LakeFed => "LAKE-FED",
RiverClass.WalledOff => "WALLED-OFF",
_ => "LAKE-ENDER",
};
// ═══ ⭐⭐ FIX 2 — THE CONFLUENCE POST-PASS (rivers/03b) ═══════════════════════════════════
//
// ⚠⚠ A DIVERGENCE, NOT A PORT. The reference lays every route independently and never dedups or
// joins them, which rivers/03 measured: on EVERY seed two routed rivers arrived at the identical
// ocean cell without ever having met. Two channels reaching the same mouth as parallel
// duplicates is not geography; two channels that meet and continue as one is.
//
// The rule, deliberately strict: courses are laid BIGGEST-FIRST by drainage, and a later course
// joins an earlier one only on TRUE CELL INTERSECTION — the later course's rasterised cell path
// actually reaching a cell an earlier one occupies. **Never proximity.** Two rivers running 3 px
// apart down the same valley stay two rivers; that is a question for the carve's channel width,
// not for routing to guess at.
/// <summary>
/// Join intersecting courses into a dendritic tree. Biggest-first, so the largest drainage is
/// the trunk and smaller ones become its tributaries — the later river is truncated at the
/// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's
/// downstream and terminus from there.
/// </summary>
private static void Confluence(List<RoutedRiver> rivers, Action<string> log)
{
var order = new List<RoutedRiver>(rivers);
order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
// cell -> (the river occupying it, and how far along that river's cell path it sits)
var owner = new Dictionary<(int x, int y), (RoutedRiver river, int idx)>();
int joins = 0;
foreach (var r in order)
{
r.CellPath = Rasterise(r.Course);
var stemOnly = new List<(float x, float y)>(r.Candidate.Course);
stemOnly.Reverse();
r.StemCells = Rasterise(stemOnly).Count;
if (r.CellPath.Count == 0) { r.OwnPath = r.Course; continue; }
// The first cell of THIS course that someone bigger already occupies.
int hit = -1;
(RoutedRiver river, int idx) into = default;
for (int i = 0; i < r.CellPath.Count; i++)
if (owner.TryGetValue(r.CellPath[i], out into)) { hit = i; break; }
if (hit < 0)
{
// Keeps its own route and its own mouth.
r.OwnPath = r.Course;
for (int i = 0; i < r.CellPath.Count; i++)
if (!owner.ContainsKey(r.CellPath[i])) owner[r.CellPath[i]] = (r, i);
continue;
}
// ⭐ It joins. Truncate here and adopt the parent's downstream from the junction on.
var parent = into.river;
r.Joined = true;
r.ConfluenceParentRank = parent.Candidate.Rank;
r.JunctionCell = r.CellPath[hit];
joins++;
// What it DRAWS is its own reach only, up to the junction — the union of every river's
// own reach is the tree. Drawing the adopted downstream too would just overdraw the parent.
r.OwnPath = new List<(float x, float y)>();
for (int i = 0; i <= hit; i++) r.OwnPath.Add((r.CellPath[i].x, r.CellPath[i].y));
// The full course of record: its own reach, then the parent's from the junction to the sea.
var full = new List<(int x, int y)>();
for (int i = 0; i <= hit; i++) full.Add(r.CellPath[i]);
for (int i = into.idx + 1; i < parent.CellPath.Count; i++) full.Add(parent.CellPath[i]);
r.CellPath = full;
r.Course = new List<(float x, float y)>();
foreach (var cpt in full) r.Course.Add((cpt.x, cpt.y));
// Only its OWN reach becomes occupiable, so a third river can join this tributary.
for (int i = 0; i <= hit; i++)
if (!owner.ContainsKey(full[i])) owner[full[i]] = (r, i);
log($" ⭐ CONFLUENCE: #{r.Candidate.Rank} ({r.Candidate.DrainagePx:N0} px) joins #{parent.Candidate.Rank} " +
$"({parent.Candidate.DrainagePx:N0} px) at ({r.JunctionCell.x},{r.JunctionCell.y}) — " +
$"{hit} px of its own reach, then adopts #{parent.Candidate.Rank}'s downstream and terminus");
}
if (joins == 0) log(" (no confluences — every course keeps its own mouth)");
}
/// <summary>
/// ⭐ THE ROOT of a confluence chain — the river whose terminus this one actually ends at. A
/// tributary's mouth is its trunk's mouth, so this is what mouth counting and terminus class
/// must both be read through.
/// </summary>
public static RoutedRiver Root(RoutedRiver r, List<RoutedRiver> all)
{
var cur = r;
// The chain is finite and strictly increasing in drainage (biggest-first laying), so it
// cannot cycle; the guard is belt-and-braces against a future change to the ordering.
for (int guard = 0; guard < all.Count + 1 && cur.Joined; guard++)
{
RoutedRiver parent = null;
foreach (var o in all) if (o.Candidate.Rank == cur.ConfluenceParentRank) { parent = o; break; }
if (parent == null) break;
cur = parent;
}
return cur;
}
/// <summary>
/// Rasterise a polyline to a deduped 1-px cell path. ⚠ The confluence test is a TRUE CELL
/// intersection, so the courses must be compared as the cells they occupy, not as the sparse
/// vertices the analysis decimated them to (stems are decimated ×4, routes are Chaikin-smoothed).
/// </summary>
private static List<(int x, int y)> Rasterise(List<(float x, float y)> pts)
{
var outp = new List<(int x, int y)>();
if (pts == null || pts.Count == 0) return outp;
void Push(int x, int y)
{
if (outp.Count > 0 && outp[^1].x == x && outp[^1].y == y) return;
outp.Add((x, y));
}
for (int i = 0; i + 1 < pts.Count; i++)
{
var a = pts[i]; var b = pts[i + 1];
float dx = b.x - a.x, dy = b.y - a.y;
int steps = Math.Max(1, (int)MathF.Ceiling(MathF.Max(MathF.Abs(dx), MathF.Abs(dy))));
for (int s = 0; s < steps; s++)
Push((int)MathF.Round(a.x + dx * s / steps), (int)MathF.Round(a.y + dy * s / steps));
}
Push((int)MathF.Round(pts[^1].x), (int)MathF.Round(pts[^1].y));
return outp;
}
/// <summary>
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
/// reach (terminal → target).
///
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
///
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
/// produces courses, and a densified polyline draws and measures identically.
/// </summary>
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
{
var pts = new List<(float x, float y)>(uplandStem);
pts.Reverse(); // downstream-first → head → terminal
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
return pts;
}
}
}