using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea.
///
/// ═══ THE MODEL ═══
///
/// The reference routes a river to the FIRST terminus it can afford and stops. This replaces that with
/// CHAINING THROUGH THE BASIN GRAPH (rivers/04): a promoted river runs down its erosion-carved stem to
/// its terminal basin and from there follows the terrain's own overflow structure — into the lake or
/// across the dry pan, over the basin's spill, into the next basin, over its spill — until it reaches
/// the coast, walls at a real lake, or walls at a dry sink.
///
/// THE FIELD one island-wide D8 direction per land cell on Plan.FullFilled (the overflow
/// surface, rivers/04 §0.2: on it every basin's minimum is its spill, so descent leaves
/// each basin over its spill into the next). Cap-independent; computed once per seed.
/// THE WALK a river FOLLOWS the field from its terminal. Each basin it enters is checked once:
/// floor→spill climb (BasinNode.SpillClimbM, the fill-to-overtop metric, applied
/// uniformly to lake and dry basins) ≤ cap → overflow, continue; > cap → walled, stop.
/// DISPOSITION reaches OceanMask → KEEP (flow-through to the sea); walls at an IsLake
/// basin → KEEP (lake-terminal, feeds visible water); walls at a dry or puddle-only basin
/// → DROP the river entirely (a river dead-ending in dry nowhere is worse than no river,
/// and nothing is filled). Read through the CONFLUENCE ROOT: a river that joins a kept
/// river is kept as its tributary, whatever its own chain would have done.
/// CONFLUENCE rivers/03b's, reused verbatim: biggest-first, true cell intersection, never proximity.
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Courses, a direction field, and data. No height written, no bed carved, no water created or
/// filled.** The caller digests both height fields around this and refuses on any change.
///
/// ═══ ⚠ D-046 — which surface each step reads ═══
///
/// RENDER the field (FullFilled), the climbs (SpillClimbM), the real-terrain descent into
/// a lake (Plan.Dir on Filled), the lowground fallback (RouteTo on p2.Height).
/// CLASSIFY every terminus test: OceanMask for the sea, IsLake (≥ floor) for a lake, and
/// "is this cell the basin's own water" for where a river enters a lake. No bare h < sea.
///
/// ═══ ⭐ HOW A LAKE BASIN IS CROSSED (the one place the field is not simply followed) ═══
///
/// The field inside a basin is the flood's ulp-staircase — it points from anywhere in the basin straight
/// at the spill, IGNORING the lake, because the flood never asked where the low water is. Water entering
/// a lake basin does not skirt the lake to the spill; it runs down to the lake, fills it, and leaves at
/// the spill. So inside an IsLake basin the course is: the REAL-TERRAIN descent from the entry
/// point into the basin's own classify water (Plan.Dir; rivers/03c fix B's lowground route as the
/// fallback when the descent pools short of the water), then the LAKE SPAN (water — recorded, not drawn),
/// then the OUTLET: from the lake's lowest cell on FullFilled (its point nearest the spill in flood
/// terms) along the field over the spill. A dry basin is crossed on the field as a visible line.
///
public static class FlowThroughRouting
{
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 };
public const sbyte D_NONE = -1;
public enum Terminus : byte
{
/// The chain reached OceanMask.
Ocean,
/// Walled at an IsLake basin — a significant lake.
Lake,
/// Walled at a dry (or puddle-only) basin.
DrySink,
/// The walk stuck with no lower neighbour outside any basin — an exact flat. Not expected.
Closed,
}
/// One basin the chain entered.
public sealed class Hop
{
public int BasinId;
public bool IsLake;
/// Floor→spill climb, metres, clamped at sea as RouteTo clamps — the cap metric.
public float ClimbM;
public bool Walled;
public int EntryCell;
/// The first cell outside the basin on the way out (-1 if walled).
public int SpillCell = -1;
/// ⭐ The cell the river actually ENTERED the lake at (lake basins only; -1 otherwise).
public int LakeEntryCell = -1;
/// Cross-check: the field's exit from this basin lands where BasinGraph's edge says.
public bool EdgeAgreesWithGraph = true;
/// The lake-entry descent had to fall back to the lowground route (the terminal pooled short of the water).
public bool UsedLowgroundFallback;
}
/// One promoted river, walked, disposed, assembled.
public sealed class FlowRiver
{
public RiverCandidate Candidate;
public List Chain = new();
/// This river's OWN terminus, before confluence.
public Terminus Terminus;
public int TerminusBasinId;
/// The ocean cell entered, the lake cell entered, or where a dry/closed chain ended.
public int MouthCell = -1;
/// Head → terminus: the upland stem then the lowland chain. Lake spans are straight jumps across water.
public List<(float x, float y)> Course;
/// Lake spans: (entry water cell, outlet water cell) — the parts of the course that are water, not channel.
public List<(int from, int to)> WaterSpans = new();
public float StemLenPx, LowlandLenPx;
public float TotalLenPx => StemLenPx + LowlandLenPx;
public float MaxHopClimbM, TotalClimbM;
public int LakesPassed;
/// The rivers/03b confluence wrapper — Joined, ConfluenceParentRank, CellPath, OwnPath, StemCells.
public RiverRouting.RoutedRiver Routed;
/// ⭐ The disposition of record — read through the confluence root.
public Terminus RootTerminus;
public bool Dropped;
public bool Trunk => Candidate.IsSea;
public bool ReachesSea => !Dropped && RootTerminus == Terminus.Ocean;
public string Why = "";
}
/// The cap-independent field: D8 on FullFilled, 0..7 or (ocean, or no lower neighbour).
public sealed class Field
{
public sbyte[] Dir;
public int N;
public int Target(int i)
{
sbyte d = Dir[i];
if (d < 0) return -1;
int cx = i / N, cy = i % N;
return (cx + DX[d]) * N + (cy + DY[d]);
}
}
/// Per-seed precomputation shared by every cap: each lake basin's water cells and outlet cell; every basin's fill volume.
public sealed class Prep
{
public Dictionary> LakeCells = new();
///
/// ⭐ Per WATER BODY (an 8-connected component of a lake basin's own classify water): its cell with the lowest
/// FullFilled — the body's point nearest the spill in flood terms, where its overflow leaves. Per body, not
/// per basin: one basin can own several separate lakes (rivers/04 found `1063685222 #699` owning two), and a
/// river that enters one must leave from THAT one, not jump across land to another.
///
public Dictionary BodyOut = new();
/// Water cell → its body id (lake basins' own water only).
public Dictionary BodyOf = new();
/// Per basin id: Σ (FullFilled − render) × metres, over its cells — the volume to fill it to its spill, in metre·cells.
public Dictionary FillVolumeMPx = new();
public bool[] Scratch; // one reusable target mask for the lowground fallback
}
public sealed class HeroLake
{
public int BasinId; public long LakeCells; public double FillVolumeMPx;
public float RiverLenPx; public int RiverRank; public int RiversThrough;
public double Score;
}
public sealed class Result
{
public float CapM;
public List Rivers = new();
public HashSet WalledIds = new();
public int Trunks, FlowThrough, LakeTerminal, DroppedDry, DroppedClosed, Joined, RescuedByConfluence, DroppedByConfluence;
public int EdgeAgree, EdgeDisagree, LowgroundFallbacks;
/// The field AT THIS CAP: the ∞ field with every walled basin's cells replaced by D8 on the real terrain, so flow into a walled basin ends there.
public sbyte[] CappedDir;
/// Flow accumulation on the capped field (Kahn), cells; 0 on ocean — the field's own drainage tree, for the data map.
public int[] CappedAcc;
public long LandCells, CellsToSea, CellsToWalledLake, CellsToWalledDry, CellsStuck;
public List HeroLakes = new();
}
// ═══ THE FIELD ══════════════════════════════════════════════════════════════════════════
/// D8 on FullFilled for every non-ocean cell, the analysis's exact neighbour order and drop/DIST rule. Ocean cells are .
public static Field BuildField(DrainageAnalysis.Plan plan, int n, bool[] isOcean)
{
int total = n * n;
var dir = new sbyte[total];
float[] ff = plan.FullFilled;
for (int i = 0; i < total; i++)
{
if (isOcean[i]) { dir[i] = D_NONE; continue; }
int cx = i / n, cy = i % n;
float best = 0f; int bestK = -1;
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;
float drop = (ff[i] - ff[nx * n + ny]) / DIST[k];
if (drop > best) { best = drop; bestK = k; }
}
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
}
return new Field { Dir = dir, N = n };
}
public static Prep Prepare(DrainageAnalysis.Plan plan, BasinGraph graph, float[,] height, int n, bool[] isOcean, bool[] isClassifyWater)
{
int total = n * n;
var p = new Prep { Scratch = new bool[total] };
var isLake = new HashSet();
foreach (var b in graph.Nodes) if (b.IsLake) isLake.Add(b.Id);
var outFF = new Dictionary();
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0) continue;
double d = WorldScale.MetresFromRaw(plan.FullFilled[i] - height[i / n, i % n]);
p.FillVolumeMPx.TryGetValue(id, out double v); p.FillVolumeMPx[id] = v + d;
if (!isLake.Contains(id) || !isClassifyWater[i] || isOcean[i]) continue;
if (!p.LakeCells.TryGetValue(id, out var cells)) { cells = new List(); p.LakeCells[id] = cells; }
cells.Add(i);
}
// Label each lake basin's water into bodies (8-connected, fixed order) and find each body's outlet cell.
int nextBody = 1;
var stack = new Stack();
foreach (var kv in p.LakeCells)
{
var set = new HashSet(kv.Value);
foreach (int seed in kv.Value)
{
if (p.BodyOf.ContainsKey(seed)) continue;
int body = nextBody++;
p.BodyOf[seed] = body; stack.Push(seed);
int outCell = seed; float outFFv = plan.FullFilled[seed];
while (stack.Count > 0)
{
int c = stack.Pop();
if (plan.FullFilled[c] < outFFv || (plan.FullFilled[c] == outFFv && c < outCell)) { outFFv = plan.FullFilled[c]; outCell = c; }
int cx = c / n, cy = c % n;
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 (!set.Contains(ni) || p.BodyOf.ContainsKey(ni)) continue;
p.BodyOf[ni] = body; stack.Push(ni);
}
}
p.BodyOut[body] = outCell;
}
}
return p;
}
/// The shortest 8-connected path THROUGH a body's water from one of its cells to another (BFS, fixed order). Water, not channel — recorded, never drawn.
private static List WaterPath(Prep prep, int from, int to, int n)
{
int body = prep.BodyOf[from];
var parent = new Dictionary { [from] = -1 };
var q = new Queue(); q.Enqueue(from);
while (q.Count > 0)
{
int c = q.Dequeue();
if (c == to) break;
int cx = c / n, cy = c % n;
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 (parent.ContainsKey(ni) || !prep.BodyOf.TryGetValue(ni, out int b) || b != body) continue;
parent[ni] = c; q.Enqueue(ni);
}
}
var path = new List();
if (!parent.ContainsKey(to)) { path.Add(from); path.Add(to); return path; }
for (int c = to; c >= 0; c = parent[c]) path.Add(c);
path.Reverse();
return path;
}
// ═══ THE WALKS ══════════════════════════════════════════════════════════════════════════
public static Result Run(List promoted, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Action log, bool confluence = true)
{
var r = new Result { CapM = capM };
foreach (var b in graph.LandNodes) if (b.SpillClimbM > capM) r.WalledIds.Add(b.Id);
foreach (var c in promoted)
{
var fr = Walk(c, plan, graph, field, prep, height, n, isOcean, isClassifyWater, sea, capM, r);
r.Rivers.Add(fr);
}
// ---- confluence (rivers/03b, reused) over EVERY river, kept or not — a river that meets a kept river's
// channel before its own dead-end is that river's tributary, and its water reaches the sea through it.
var wrappers = new List();
foreach (var fr in r.Rivers)
{
fr.Routed = new RiverRouting.RoutedRiver
{
Candidate = fr.Candidate, Course = fr.Course,
Class = fr.Trunk ? RiverRouting.RiverClass.OceanTrunk
: fr.Terminus == Terminus.Ocean ? RiverRouting.RiverClass.RoutedGiant
: fr.Terminus == Terminus.Lake ? RiverRouting.RiverClass.LakeEnder
: RiverRouting.RiverClass.WalledOff,
};
wrappers.Add(fr.Routed);
}
if (confluence) RiverRouting.Confluence(wrappers, log);
else foreach (var w in wrappers) w.OwnPath = w.Course;
var byRank = new Dictionary();
foreach (var fr in r.Rivers) byRank[fr.Candidate.Rank] = fr;
foreach (var fr in r.Rivers)
{
var root = RiverRouting.Root(fr.Routed, wrappers);
var rootFr = byRank[root.Candidate.Rank];
fr.RootTerminus = rootFr.Terminus;
fr.Dropped = fr.RootTerminus == Terminus.DrySink || fr.RootTerminus == Terminus.Closed;
bool ownKept = fr.Terminus == Terminus.Ocean || fr.Terminus == Terminus.Lake;
if (fr.Routed.Joined)
{
r.Joined++;
if (!ownKept && !fr.Dropped) r.RescuedByConfluence++;
if (ownKept && fr.Dropped) r.DroppedByConfluence++;
}
if (fr.Dropped) { if (fr.RootTerminus == Terminus.Closed) r.DroppedClosed++; else r.DroppedDry++; }
else if (fr.Trunk) r.Trunks++;
else if (fr.RootTerminus == Terminus.Ocean) r.FlowThrough++;
else r.LakeTerminal++;
foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) r.LowgroundFallbacks++; }
}
foreach (var fr in r.Rivers)
for (int i = 0; i < fr.Chain.Count; i++)
if (fr.Chain[i].SpillCell >= 0) { if (fr.Chain[i].EdgeAgreesWithGraph) r.EdgeAgree++; else r.EdgeDisagree++; }
return r;
}
private static FlowRiver Walk(RiverCandidate c, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Result res)
{
var fr = new FlowRiver { Candidate = c };
// The upland stem, head → terminal (the analysis's course is downstream-first, decimated ×4).
fr.Course = new List<(float x, float y)>(c.Course);
fr.Course.Reverse();
fr.StemLenPx = PolyLen(fr.Course);
if (c.IsSea)
{
fr.Terminus = Terminus.Ocean;
fr.MouthCell = c.Cell;
fr.Why = "natural ocean trunk — erosion already reaches the coast";
return fr;
}
int cur = c.TermX * n + c.TermY;
int basin = plan.BasinId[cur] != 0 ? plan.BasinId[cur] : c.BasinId;
var visited = new HashSet();
var why = new System.Text.StringBuilder();
for (int guard = 0; guard < 256; guard++)
{
var node = graph.Of(basin);
if (node == null || !visited.Add(basin))
{
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = basin; fr.MouthCell = cur;
why.Append(node == null ? $" → basin #{basin} not in the graph (closed)" : $" → basin #{basin} revisited (closed)");
break;
}
var hop = new Hop { BasinId = basin, IsLake = node.IsLake, ClimbM = node.SpillClimbM, Walled = node.SpillClimbM > capM, EntryCell = cur };
fr.Chain.Add(hop);
if (hop.ClimbM > fr.MaxHopClimbM) fr.MaxHopClimbM = hop.ClimbM;
if (hop.Walled)
{
fr.TerminusBasinId = basin;
if (node.IsLake)
{
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
hop.UsedLowgroundFallback = fb;
if (reach.Count > 1) AppendReach(fr, reach, n);
hop.LakeEntryCell = reach[^1];
fr.Terminus = Terminus.Lake; fr.MouthCell = reach[^1];
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at the lake — LAKE-TERMINAL, kept");
}
else
{
fr.Terminus = Terminus.DrySink; fr.MouthCell = cur;
why.Append($" → #{basin} DRY{(node.HasAnyLake ? " (puddle only)" : "")}, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at a dry sink — DROPPED");
}
break;
}
fr.TotalClimbM += hop.ClimbM;
int from = cur;
if (node.IsLake)
{
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
hop.UsedLowgroundFallback = fb;
if (reach.Count > 1) AppendReach(fr, reach, n);
int w = reach[^1];
hop.LakeEntryCell = w;
int lakeOut = prep.BodyOf.TryGetValue(w, out int body) && prep.BodyOut.TryGetValue(body, out int bo) ? bo : w;
if (lakeOut != w)
{
// The lake span — through the water of the body the river entered, to that body's outlet.
fr.WaterSpans.Add((w, lakeOut));
var span = WaterPath(prep, w, lakeOut, n);
for (int i = 1; i < span.Count; i++) fr.Course.Add((span[i] / n, span[i] % n));
}
fr.LakesPassed++;
from = lakeOut;
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m ≤ cap: through the lake and over its spill");
}
else why.Append($" → #{basin} dry, rim {hop.ClimbM:F1} m ≤ cap: across the low ground and over its spill");
var path = Follow(from, basin, field, plan, isOcean, n, out int status, out int spill);
hop.SpillCell = spill;
if (path.Count > 1) AppendReach(fr, path, n);
int end = path[^1];
if (status == 1)
{
fr.Terminus = Terminus.Ocean; fr.MouthCell = end;
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Ocean;
why.Append(" → the SEA — flow-through, kept");
break;
}
if (status == 0)
{
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = 0; fr.MouthCell = end;
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.None;
why.Append(" → stuck on an exact flat outside any basin — CLOSED, dropped");
break;
}
int next = plan.BasinId[end];
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Basin && node.DownstreamId == next;
cur = end; basin = next;
}
fr.Why = $"terminal basin #{fr.Chain[0].BasinId}" + why;
return fr;
}
///
/// Follow the ∞ field from a cell inside until it reaches the ocean (status 1),
/// enters another terminal basin (status 2), or sticks (status 0). is the first
/// cell outside the basin on the way.
///
private static List Follow(int start, int basin, Field field, DrainageAnalysis.Plan plan, bool[] isOcean, int n,
out int status, out int spill)
{
var path = new List { start };
int c = start; spill = -1; status = 0;
for (int guard = 0; guard < 8 * n; guard++)
{
int b = plan.BasinId[c];
if (b != basin && spill < 0) spill = c;
if (isOcean[c]) { status = 1; return path; }
if (b != basin && b != 0) { status = 2; return path; }
int t = field.Target(c);
if (t < 0) { status = 0; return path; }
c = t; path.Add(c);
}
return path;
}
///
/// The real-terrain descent from a point inside a lake basin to the basin's own classify water:
/// Plan.Dir (D8 on Filled) until a water cell; if it pools short (the terminal is a D8
/// sink by definition), rivers/03c fix B's lowground route to the basin's own water — same rule, same cost model.
///
private static List DescendToWater(int start, int basin, DrainageAnalysis.Plan plan, Prep prep, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, float sea, out bool usedFallback)
{
usedFallback = false;
var path = new List { start };
int c = start;
bool IsOwnWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] == basin;
for (int guard = 0; guard < 8 * n; guard++)
{
if (IsOwnWater(c)) return path;
sbyte d = plan.Dir[c];
if (d < 0) break;
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (plan.BasinId[t] != basin) break;
c = t; path.Add(c);
}
// Pooled short of the water — route the rest as rivers/03c does for a lake-ender.
if (!prep.LakeCells.TryGetValue(basin, out var cells) || cells.Count == 0) return path;
usedFallback = true;
foreach (int i in cells) prep.Scratch[i] = true;
var route = RiverRouting.RouteTo(height, n, prep.Scratch, start / n, start % n, RiverRouting.StyleLowground, sea);
foreach (int i in cells) prep.Scratch[i] = false;
if (!route.Reached) return path;
var outp = new List(route.Path.Count);
foreach (var p in route.Path) outp.Add((int)p.x * n + (int)p.y);
return outp;
}
/// Append a 1-px cell reach to the course, RDP+Chaikin-smoothed as rivers/03 smooths every lowland reach (endpoints pinned).
private static void AppendReach(FlowRiver fr, List cells, int n)
{
var pts = new List<(float x, float y)>(cells.Count);
foreach (int i in cells) pts.Add((i / n, i % n));
fr.LowlandLenPx += PolyLen(pts);
var sm = RiverRouting.SmoothCourse(pts);
int start = fr.Course.Count > 0 && fr.Course[^1].x == sm[0].x && fr.Course[^1].y == sm[0].y ? 1 : 0;
for (int i = start; i < sm.Count; i++) fr.Course.Add(sm[i]);
}
private static float PolyLen(List<(float x, float y)> pts)
{
float L = 0f;
for (int i = 1; i < pts.Count; i++)
{
float dx = pts[i].x - pts[i - 1].x, dy = pts[i].y - pts[i - 1].y;
L += MathF.Sqrt(dx * dx + dy * dy);
}
return L;
}
// ═══ THE FIELD AT THE CAP — the artifact ════════════════════════════════════════════════
///
/// The ∞ field with every WALLED basin's cells replaced by D8 on the real terrain (Filled), so
/// flow that reaches a walled basin descends to its floor and ends there — "a cell whose downstream
/// chain walls off drains to that wall, not past it". Then every land cell's destination, memoised.
///
public static void BuildCappedField(Result r, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, int n, bool[] isOcean)
{
int total = n * n;
var dir = (sbyte[])field.Dir.Clone();
float[] filled = plan.Filled;
for (int i = 0; i < total; i++)
{
int id = plan.BasinId[i];
if (id == 0 || !r.WalledIds.Contains(id) || isOcean[i]) continue;
int cx = i / n, cy = i % n;
float best = 0f; int bestK = -1;
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;
float drop = (filled[i] - filled[nx * n + ny]) / DIST[k];
if (drop > best) { best = drop; bestK = k; }
}
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
}
r.CappedDir = dir;
// Accumulation on the capped field — Kahn propagation, as the analysis does on its own field.
{
var acc = new int[total];
var indeg = new byte[total];
int Tgt(int i) { sbyte d = dir[i]; if (d < 0) return -1; int cx = i / n, cy = i % n; return (cx + DX[d]) * n + (cy + DY[d]); }
for (int i = 0; i < total; i++) if (!isOcean[i] && dir[i] >= 0) { int t = Tgt(i); if (!isOcean[t]) indeg[t]++; }
var q = new Queue();
for (int i = 0; i < total; i++) { if (isOcean[i]) continue; acc[i] = 1; if (indeg[i] == 0) q.Enqueue(i); }
while (q.Count > 0)
{
int c = q.Dequeue();
int t = Tgt(c);
if (t < 0 || isOcean[t]) continue;
acc[t] += acc[c];
if (--indeg[t] == 0) q.Enqueue(t);
}
r.CappedAcc = acc;
}
// Destinations: -1 sea, >0 basin id (a sink), -2 stuck.
var lakeIds = new HashSet();
foreach (var b in graph.Nodes) if (b.IsLake) lakeIds.Add(b.Id);
var dest = new int[total];
var path = new List(4096);
for (int i = 0; i < total; i++)
{
if (isOcean[i] || dest[i] != 0) continue;
int c = i; path.Clear(); int result;
while (true)
{
if (dest[c] != 0) { result = dest[c]; break; }
path.Add(c);
sbyte d = dir[c];
if (d < 0) { result = plan.BasinId[c] != 0 ? plan.BasinId[c] : -2; break; }
int cx = c / n, cy = c % n;
int t = (cx + DX[d]) * n + (cy + DY[d]);
if (isOcean[t]) { result = -1; break; }
c = t;
}
foreach (int pc in path) dest[pc] = result;
}
for (int i = 0; i < total; i++)
{
if (isOcean[i]) continue;
r.LandCells++;
int d = dest[i];
if (d == -1) r.CellsToSea++;
else if (d > 0) { if (lakeIds.Contains(d)) r.CellsToWalledLake++; else r.CellsToWalledDry++; }
else r.CellsStuck++;
}
}
// ═══ THE HERO-LAKE CANDIDATE — data, not a fill ═════════════════════════════════════════
///
/// Among lakes that a sea-reaching, un-joined river flows THROUGH, rank by the geometric mean of
/// normalised fill volume and normalised attached river length. Recorded intent (procedural, executed
/// post-water-render); nothing is filled here.
///
public static void RankHeroLakes(Result r, BasinGraph graph, Prep prep)
{
var cand = new Dictionary();
foreach (var fr in r.Rivers)
{
if (fr.Dropped || fr.Trunk || fr.Routed.Joined || fr.Terminus != Terminus.Ocean) continue;
foreach (var h in fr.Chain)
{
if (!h.IsLake || h.Walled) continue;
if (!cand.TryGetValue(h.BasinId, out var hl))
{
var node = graph.Of(h.BasinId);
hl = new HeroLake { BasinId = h.BasinId, LakeCells = node.LakeCells, FillVolumeMPx = prep.FillVolumeMPx.TryGetValue(h.BasinId, out double v) ? v : 0 };
cand[h.BasinId] = hl;
}
hl.RiversThrough++;
if (fr.TotalLenPx > hl.RiverLenPx) { hl.RiverLenPx = fr.TotalLenPx; hl.RiverRank = fr.Candidate.Rank; }
}
}
double maxV = 1, maxL = 1;
foreach (var hl in cand.Values) { if (hl.FillVolumeMPx > maxV) maxV = hl.FillVolumeMPx; if (hl.RiverLenPx > maxL) maxL = hl.RiverLenPx; }
foreach (var hl in cand.Values) hl.Score = Math.Sqrt((hl.FillVolumeMPx / maxV) * (hl.RiverLenPx / maxL));
r.HeroLakes = new List(cand.Values);
r.HeroLakes.Sort((a, b) => b.Score != a.Score ? b.Score.CompareTo(a.Score) : a.BasinId.CompareTo(b.BasinId));
}
public static string TerminusName(Terminus t) => t switch
{
Terminus.Ocean => "sea", Terminus.Lake => "lake", Terminus.DrySink => "dry-sink", _ => "closed",
};
public static string ClassName(FlowRiver fr) =>
fr.Dropped ? "dropped" : fr.Trunk ? "trunk" : fr.RootTerminus == Terminus.Ocean ? "flow-through" : "lake-terminal";
}
}