islaApocalypse-v2/Tools/Scripts/RiverRouting.cs
beezm ccdd6a1996 rivers/05: flow-through routing — river → lake → over the spill → … → sea; the hydrology map and the flow-direction field
Replaces terminate-at-first with chaining through the basin graph (rivers/04): one D8 field on
FullFilled per seed; each promoted river follows it from its terminal, checked at every basin
entered against the floor→spill climb (SpillClimbM) vs ISLA_FLOW_CAP_M (30) — overflow or wall.
Lake basins are entered on the real terrain (Plan.Dir, rivers/03c fix B fallback), crossed as
water to the entered body's lowest-FullFilled outlet, left over the spill. Keep on OceanMask /
IsLake, drop on dry or puddle-only, read through the rivers/03b confluence root (reused verbatim).
The field at the cap (walled basins re-pointed onto the real terrain), its accumulation and every
cell's destination; hero-lake candidates ranked as data. HydrologyRenderer: the showpiece map on
the atlas relief and the flow-direction data map. Heights digested and asserted; nothing filled,
nothing carved. RiverRouting.Confluence and DrainageRenderer.LabelPlacer private→internal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EppUMXNhSeuA5Mu51UnTyP
2026-08-25 22:50:25 -04:00

754 lines
37 KiB
C#
Raw Permalink 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>
/// ⭐⭐ rivers/03c FIX A — lake-termination becomes a PREFERENCE instead of an unconditional
/// capture. Set > 0 to enable; it then supersedes the plain nearest-of-union rule above.
///
/// ⚠⚠ WHY rivers/03b OVERSHOT. "Nearest of {ocean lake}" lets a lake that is merely a
/// *little* closer capture a river that had a clear shot at the coast — and it moved **12
/// rivers** to lake-fed, roughly halving the island's sea mouths (5/5/6/5 → 4/2/3/3). The
/// rule here is deliberately sea-biased instead:
/// <code>
/// lake-fed iff cost_lake &lt; LakePreferRatio × cost_ocean
/// </code>
/// so a lake must be MATERIALLY cheaper to reach, not just nearer. **Lower ratio → more sea
/// rivers.** Both costs are recorded per router whether or not the lake wins, so the knob can
/// be read off the table without a re-run.
/// </summary>
public float LakePreferRatio;
/// <summary>
/// ⭐⭐ rivers/03c FIX B — a NATURAL lake-ender terminates at the water inside its OWN
/// terminal basin, at any size.
///
/// ⚠ The 20,000 px significance threshold is what exiled `999999937 #3` from its own home:
/// its basin's lake was sub-threshold, so it marched ~5,800 px along the shoreline hunting a
/// distant "significant" body. A basin's own water is where its flow goes regardless of how
/// big it is. **The threshold still applies to ROUTERS choosing a DISTANT lake** — a dry
/// basin still cannot connect itself to a three-cell puddle.
/// </summary>
public bool OwnBasinLakeEnder;
/// <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>⭐ rivers/03c FIX A's lever, recorded for EVERY router — lake-fed or not — so the
/// developer can read off which ratio value flips which river without a re-run.
/// <see cref="CostRatio"/> is cost_lake / cost_ocean; a river is lake-fed iff it is below
/// the configured ratio. NaN where the leg was not reachable.</summary>
public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
/// <summary>Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.</summary>
public bool OwnBasinTargeted;
/// <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, int[] basinId = null)
{
opt ??= Options.Faithful;
if (opt.OwnBasinLakeEnder && basinId == null)
throw new InvalidOperationException(
"[RiverRouting] OwnBasinLakeEnder needs Plan.BasinId to know which water is a basin's OWN. " +
"Pass it; refusing to silently fall back to the distant-significant-body rule that produced the coast-hugger.");
// ⭐ 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)
{
rr.FaithfulClass = RiverClass.RoutedGiant;
Route route;
bool stoppedAtLake;
if (opt.LakePreferRatio > 0f)
{
// ⭐⭐ rivers/03c FIX A — the two legs are costed SEPARATELY and compared, instead of
// racing in one search. That is the whole difference: a shared search returns
// whichever is nearer, this one returns the sea unless the lake is materially cheaper.
var lakeLeg = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, style, sea);
rr.CostOcean = probe.Reached ? probe.Cost : float.NaN;
rr.CostLake = lakeLeg.Reached ? lakeLeg.Cost : float.NaN;
rr.CostRatio = probe.Reached && lakeLeg.Reached && probe.Cost > 0f
? lakeLeg.Cost / probe.Cost : float.NaN;
// ⚠ No reachable lake → the sea, always. No reachable ocean → the lake if there is one.
stoppedAtLake = lakeLeg.Reached && probe.Reached
&& lakeLeg.Cost < opt.LakePreferRatio * probe.Cost;
if (lakeLeg.Reached && !probe.Reached) stoppedAtLake = true;
route = stoppedAtLake ? lakeLeg : probe;
}
else
{
// rivers/03b — the nearest of the union mask, whichever that turns out to be.
route = opt.LakeTargetForRouters
? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
: probe;
stoppedAtLake = route.Reached
&& isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
&& !isOcean[(int)route.Target.x * n + (int)route.Target.y];
}
rr.Lowland = route;
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
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)
{
// It ends at a significant lake — because that lake was nearer (03b) or materially
// cheaper (03c). NO WATER CREATED: the course simply ends at an existing body.
rr.Class = RiverClass.LakeFed;
rr.Why = opt.LakePreferRatio > 0f
? $"dry pan → LAKE-FED: reaching a significant lake costs {rr.CostLake:N0} vs {rr.CostOcean:N0} to the sea (ratio {rr.CostRatio:F3} < {opt.LakePreferRatio:F2}) — materially cheaper, so it ends at the lake"
: $"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")}, cost {route.Cost:N0}" +
(opt.LakePreferRatio > 0f && !float.IsNaN(rr.CostRatio) ? $"; the nearest lake was not materially cheaper (ratio {rr.CostRatio:F3} ≥ {opt.LakePreferRatio:F2})" : "");
}
}
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 with the same lowground
// Dijkstra so the course actually joins the water.
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
Route ext;
if (opt.OwnBasinLakeEnder)
{
// ⭐⭐ rivers/03c FIX B — its OWN basin's water, at any size. This is where its flow
// goes; it has no business hunting a distant body. Killing the coast-hugger outright.
var ownWater = new bool[n * n];
int owned = 0;
for (int i = 0; i < ownWater.Length; i++)
if (basinId[i] == c.BasinId && isClassifyWater[i] && !isOcean[i]) { ownWater[i] = true; owned++; }
if (owned > 0)
{
ext = RouteTo(height, n, ownWater, c.TermX, c.TermY, StyleLowground, sea);
rr.OwnBasinTargeted = ext.Reached;
}
else
{
// ⚠ Should not happen — basinHasLake is what put it in this branch — but a
// basin whose water is all ocean-masked would land here. Report, do not crash.
ext = new Route();
}
}
else
{
var far = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
ext = far;
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
? (rr.OwnBasinTargeted
? $"terminal basin holds classify water → natural lake-ender; terminates at its OWN basin's water {ext.LenPx:F0} px away (fix B — no distant-body hunt, so no coast-hugging)"
: $"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 ? $" LAKE-FED (ratio {rr.CostRatio:F3})" : "")}" +
$"{(!float.IsNaN(rr.CostRatio) && rr.Class == RiverClass.RoutedGiant ? $" SEA (lake ratio {rr.CostRatio:F3})" : "")}" +
$"{(rr.OwnBasinTargeted ? $" own-basin water, {rr.Lowland.LenPx:F0} px" : "")}");
}
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>
internal 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;
}
}
}