islaApocalypse-v2/Core/Scripts/BasinGraph.cs
beezm df286ffb3c rivers/04: lake-basin layer — the basin graph (spill + lake-identity + downstream edge), data only
Per terminal basin of DrainageAnalysis (reused, untouched): the spill cell/height on the
render flow surface (boundary minimum of FullFilled, cross-checked bit-for-bit against
BitDecrement(min inside)), lake-identity on classify (in-basin non-ocean classify water
>= ISLA_LAKE_MIN_PX, 20000), and the downstream edge (the reference's FullFilled descent
started at the spill, cross-checked against Plan.Dir). Seabed pits — terminal basins entirely
under the classify sea — tagged and excluded from statistics. Per-lake ownership table.
BasinGraphTool: chain → analysis → layer → CSVs → plate, height digests asserted around it.
DrainageRenderer: five primitives private→internal. No height mutated, no water filled.

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

478 lines
23 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;
namespace IslaApocalypse.Core
{
/// <summary>Where a terminal basin's spill drains next.</summary>
public enum DownstreamKind : byte
{
/// <summary>The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).</summary>
None = 0,
/// <summary>The spill drains into <c>RegionLabeling.OceanMask</c> — the sea, on the CLASSIFY field.</summary>
Ocean = 1,
/// <summary>The spill drains into another terminal basin's cells (<see cref="BasinNode.DownstreamId"/>).</summary>
Basin = 2,
}
/// <summary>
/// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of <see cref="DrainageAnalysis"/>,
/// enriched with the three things the analysis left latent: its SPILL, its LAKE-IDENTITY, and its
/// DOWNSTREAM EDGE. Pure data. Nothing here is a height write or a water fill.
///
/// ═══ ⚠⚠ D-046 — WHICH SURFACE EACH FIELD IS READ ON ═══
///
/// RENDER / FLOW surface (<c>Plan.FullFilled</c>, the priority-flood of the eroded render height)
/// <see cref="SpillCell"/>, <see cref="SpillHeightRaw"/>, <see cref="FloorHeightRaw"/>,
/// <see cref="SpillClimbM"/>, <see cref="DownstreamKind"/> walk — everything about WHERE WATER GOES.
/// This is the surface <c>RiverRouting.RouteTo</c> routes on, so a spill height and a rim climb are
/// the same kind of number the router already measures.
///
/// CLASSIFY / WATER surface (<c>isClassifyWater</c> = classify &lt; sea; <c>OceanMask</c>)
/// <see cref="LakeCells"/>, <see cref="IsLake"/>, <see cref="OceanCells"/>, and the OCEAN terminus
/// of the downstream walk — everything about WHAT IS VISIBLY WATER. This is the surface the
/// router's terminus tests already use.
///
/// ⛔ No field compares a classify height to a render height. The two surfaces meet only as
/// MEMBERSHIP (is this basin cell classify-water? is this walk cell ocean?), which is exactly the
/// split the routing already lives by (route on render, `OceanMask` on classify). No new seam.
/// </summary>
public sealed class BasinNode
{
/// <summary>The terminal-basin id, as <c>Plan.BasinId</c> carries it (sparse: pits that filled through gave up their ids).</summary>
public int Id;
/// <summary>Cells with <c>BasinId == Id</c>.</summary>
public long AreaPx;
/// <summary><c>Plan.BasinInflow[Id]</c> — cells whose flow terminates here (the promotion metric).</summary>
public long InflowPx;
/// <summary>The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.</summary>
public int FloorCell;
public float FloorHeightRaw;
/// <summary>
/// The basin's ENTRY cell: its minimum on <c>FullFilled</c>. The priority-flood raises the first cell
/// it steps into from the spill to exactly one ulp above the spill, so this is spill + 1 ulp.
/// </summary>
public int EntryCell;
public float EntryFullFilledRaw;
/// <summary>
/// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on <c>FullFilled</c>
/// (== the render height there — asserted, see <see cref="SpillOnTerrain"/>). This is the rim cell
/// water would overtop. Ties (same height) resolve to the lowest cell index; <see cref="SpillTies"/>
/// says how many boundary cells sit at exactly this height.
/// </summary>
public int SpillCell;
public float SpillHeightRaw;
public int SpillTies;
/// <summary>⭐ Cross-check (a) vs (b): <c>BitDecrement(EntryFullFilledRaw) == SpillHeightRaw</c>. The uniform-fill-level reading and the rim-walk reading must agree exactly.</summary>
public bool SpillCrossCheckOk;
/// <summary>⭐ The spill cell is real terrain: <c>FullFilled[spill] == render[spill]</c> (it was never raised by the flood).</summary>
public bool SpillOnTerrain;
/// <summary>Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).</summary>
public float SpillAboveSeaM;
/// <summary>Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ <c>DrainageAnalysis</c>'s <c>basinDepthM</c>).</summary>
public float DepthToSpillM;
/// <summary>
/// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: <c>ElevM(spill) ElevM(floor)</c> with elevation clamped at
/// sea exactly as <c>RiverRouting.RouteTo</c> clamps it — so a below-datum lagoon bed climbs from sea
/// level, not from its bed. Same number the router's <c>RimClimbM</c> is. ⚠ The clamp is an ELEVATION
/// rule on the render surface, not a water test — nothing here reads "render &lt; sea" as water.
/// </summary>
public float SpillClimbM;
/// <summary>Cells with <c>BasinId == Id</c> that are classify-water and NOT ocean. Read on CLASSIFY.</summary>
public long LakeCells;
/// <summary>Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.</summary>
public long LakeCellsSignificant;
/// <summary>⚠ Cells with <c>BasinId == Id</c> that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.</summary>
public long OceanCells;
/// <summary>⭐ <c>LakeCells &gt;= floor</c> — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.</summary>
public bool IsLake;
/// <summary>
/// ⚠⚠ EVERY cell of this basin is OCEAN on classify — a render depression on the SEABED. The priority-flood runs on
/// the whole render surface, so a deep-enough, large-enough pit under the sea qualifies as a "terminal basin" exactly
/// like a land one; hydrologically it is inert (its cells are D_NONE, its inflow is 0). Kept in the layer, EXCLUDED
/// from every lake/dry, spill and cap statistic, and counted loudly — this is the D-046 seam, not a lake.
/// </summary>
public bool IsSeabed;
/// <summary>Some but not all cells are ocean on classify — a basin straddling the shoreline seam. Treated as land (it has land cells and inflow) and counted.</summary>
public bool IsCoastal;
/// <summary>A basin with at least one land cell — the ones the graph is about.</summary>
public bool IsLand => !IsSeabed;
/// <summary><c>LakeCells &gt; 0</c> — exactly <c>DrainageAnalysis</c>'s <c>basinHasLake</c> (any size), the routing sort. Kept so the two labels can be compared.</summary>
public bool HasAnyLake;
/// <summary>⭐⭐ THE EDGE — where the spill drains next.</summary>
public DownstreamKind Downstream;
/// <summary>The downstream basin id when <see cref="Downstream"/> is <see cref="DownstreamKind.Basin"/>; 0 otherwise.</summary>
public int DownstreamId;
/// <summary>The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.</summary>
public int DownstreamEntryCell;
/// <summary>The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on <c>FullFilled</c>.</summary>
public List<int> SpillPath = new();
/// <summary>⭐ Cross-check: following <c>Plan.Dir</c> (the analysis's own D8 field) from the first cell past the spill reaches the same node.</summary>
public bool DirWalkAgrees = true;
public DownstreamKind DirWalkKind;
public int DirWalkId;
}
/// <summary>
/// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
///
/// ═══ WHAT IT IS ═══
///
/// <see cref="DrainageAnalysis"/> already found the sinks (<c>BasinId</c>) and already computed the
/// overflow surface (<c>FullFilled</c>). This layer reads those outputs and records, per terminal basin,
/// its spill, whether a significant heightmap lake sits in it, and where its spill drains to. The
/// result is a DAG: an edge always leads to a strictly lower spill, so no chain can cycle.
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Reads heights, writes none. Creates no water. <c>DrainageAnalysis</c> is consumed, not edited.**
/// The caller asserts both height digests unchanged around <see cref="Build"/>.
///
/// ═══ ⭐ WHY THE SPILL IS EXACT (the Part-0 argument, kept where the code is) ═══
///
/// The routing fill is a Barnes priority-flood with a one-ulp pit epsilon. A depression is entered
/// from the lowest rim cell S popped off the heap (height L, never raised); the first cells inside
/// are raised to <c>BitIncrement(L)</c> and every deeper cell to one ulp above ITS parent. So:
/// • a terminal basin's cells (<c>FullFilled &gt; original</c>, 8-connected) are ONE flood chain from
/// ONE spill, and their minimum on <c>FullFilled</c> is exactly L + 1 ulp;
/// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so <c>FullFilled == original</c>
/// there, and its height is ≥ L (a lower one would have been a lower way in);
/// • the boundary minimum IS S, at exactly L.
/// Both readings are computed and compared per basin (<see cref="BasinNode.SpillCrossCheckOk"/>), and
/// the "spill sits on real terrain" fact is asserted too (<see cref="BasinNode.SpillOnTerrain"/>).
///
/// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
///
/// <c>Plan.Dir</c> is D8 on <c>Filled</c> — the surface with terminal basins REVERTED to real heights.
/// The spill cell is the saddle; on <c>Filled</c> its steepest neighbour may be back INTO its own basin
/// (the reverted floor is lower than the rim), which would name the basin its own downstream. On
/// <c>FullFilled</c> the basin stands at L + ulps above its spill, so the descent from S cannot re-enter
/// it — that is exactly why the reference walked its provisional route on the full fill. <c>Dir</c> is
/// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
/// </summary>
public sealed class BasinGraph
{
// Neighbour order FIXED, identical to DrainageAnalysis — the deterministic tiebreak.
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 };
public int MapSize;
public float SeaLevel;
/// <summary>The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (<c>ISLA_LAKE_MIN_PX</c>).</summary>
public int LakeMinPx;
// ---- provenance, recorded on the layer ----
public const string SpillDatum =
"spill = minimum of Plan.FullFilled over the basin's 8-neighbour boundary (== eroded RENDER height there); " +
"cross-checked against BitDecrement(min FullFilled inside the basin); ties → lowest cell index";
public const string LakeDatum =
"lake = cells with BasinId == id AND classify < sea AND NOT OceanMask (CLASSIFY surface), total >= LakeMinPx";
public const string DownstreamMethod =
"edge = steepest descent on Plan.FullFilled from the spill cell (the reference's provisional-route walk), " +
"until OceanMask (classify) or another BasinId; cross-checked by following Plan.Dir from the first cell past the spill";
public List<BasinNode> Nodes = new();
private Dictionary<int, BasinNode> _byId = new();
public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
// ---- invariant tallies ----
public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
/// <summary>Tallies over LAND basins only (seabed basins excluded — see <see cref="BasinNode.IsSeabed"/>).</summary>
public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
/// <summary>⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.</summary>
public int Seabed, Coastal;
/// <summary>The land basins, in id order — what every statistic and the plate's graph are over.</summary>
public List<BasinNode> LandNodes = new();
/// <summary>
/// ⭐ ONE SIGNIFICANT CLASSIFY-WATER BODY, and which basin (if any) owns it. The reconciliation the whole
/// layer exists for, measured per lake rather than assumed: heightmap lakes and terminal basins coincide
/// only by terrain coincidence, so this says, per significant body, how much of it sits inside a terminal
/// basin and which one — or that it floats free of the hydrology entirely.
/// </summary>
public sealed class LakeBody
{
public int Index; // 1-based, scan order
public long SizePx;
public long CellsInBasins; // cells with BasinId != 0
public int DominantBasinId; // the basin holding most of its cells (0 = none)
public long DominantCells;
public int BasinsTouched; // distinct basins it overlaps
public bool Owned => DominantBasinId != 0 && DominantCells * 2 >= SizePx; // ≥ half inside one basin
public bool Free => CellsInBasins == 0;
}
/// <summary>Every significant body (8-connected, ≥ floor), scan order.</summary>
public List<LakeBody> LakeBodies = new();
public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
/// <summary>Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.</summary>
public long ClassifyWaterCellsOutsideBasins, ClassifyWaterCellsTotal;
public static BasinGraph Build(DrainageAnalysis.Plan plan, float[,] render, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, int lakeMinPx)
{
int total = n * n;
var g = new BasinGraph { MapSize = n, SeaLevel = sea, LakeMinPx = lakeMinPx };
int[] basinId = plan.BasinId;
float[] ff = plan.FullFilled;
int maxId = 0;
for (int i = 0; i < total; i++) if (basinId[i] > maxId) maxId = basinId[i];
// ---- pass 1: per-basin scalars, scan order ----
var area = new long[maxId + 1];
var floorCell = new int[maxId + 1]; var floorH = new float[maxId + 1];
var entryCell = new int[maxId + 1]; var entryFF = new float[maxId + 1];
var lake = new long[maxId + 1]; var lakeSig = new long[maxId + 1]; var ocean = new long[maxId + 1];
for (int id = 0; id <= maxId; id++) { floorCell[id] = -1; floorH[id] = float.MaxValue; entryCell[id] = -1; entryFF[id] = float.MaxValue; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
area[id]++;
float h = render[i / n, i % n];
if (h < floorH[id]) { floorH[id] = h; floorCell[id] = i; }
if (ff[i] < entryFF[id]) { entryFF[id] = ff[i]; entryCell[id] = i; }
if (isOcean[i]) ocean[id]++;
else if (isClassifyWater[i])
{
lake[id]++;
if (isSignificantWater != null && isSignificantWater[i]) lakeSig[id]++;
}
}
// ---- pass 2: boundary minimum on FullFilled (the rim walk) ----
var bMin = new float[maxId + 1]; var bCell = new int[maxId + 1];
for (int id = 0; id <= maxId; id++) { bMin[id] = float.MaxValue; bCell[id] = -1; }
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % 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 (basinId[ni] == id) continue;
float v = ff[ni];
if (v < bMin[id] || (v == bMin[id] && ni < bCell[id])) { bMin[id] = v; bCell[id] = ni; }
}
}
// ---- pass 3: how many DISTINCT boundary cells tie at the spill height ----
var ties = new HashSet<int>[maxId + 1];
for (int i = 0; i < total; i++)
{
int id = basinId[i];
if (id == 0) continue;
int cx = i / n, cy = i % 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 (basinId[ni] == id || ff[ni] != bMin[id]) continue;
(ties[id] ??= new HashSet<int>()).Add(ni);
}
}
float ElevM(float h) => MathF.Max(0f, WorldScale.MetresFromRaw(h - sea));
// ---- per basin: the node ----
for (int id = 1; id <= maxId; id++)
{
if (area[id] == 0) continue;
var b = new BasinNode
{
Id = id, AreaPx = area[id],
InflowPx = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0,
FloorCell = floorCell[id], FloorHeightRaw = floorH[id],
EntryCell = entryCell[id], EntryFullFilledRaw = entryFF[id],
SpillCell = bCell[id], SpillHeightRaw = bMin[id],
SpillTies = ties[id]?.Count ?? 0,
LakeCells = lake[id], LakeCellsSignificant = lakeSig[id], OceanCells = ocean[id],
};
b.SpillCrossCheckOk = bCell[id] >= 0 && MathF.BitDecrement(entryFF[id]) == bMin[id];
b.SpillOnTerrain = bCell[id] >= 0 && render[bCell[id] / n, bCell[id] % n] == bMin[id];
b.SpillAboveSeaM = WorldScale.MetresFromRaw(b.SpillHeightRaw - sea);
b.DepthToSpillM = WorldScale.MetresFromRaw(b.SpillHeightRaw - b.FloorHeightRaw);
b.SpillClimbM = ElevM(b.SpillHeightRaw) - ElevM(b.FloorHeightRaw);
b.IsLake = b.LakeCells >= lakeMinPx;
b.HasAnyLake = b.LakeCells > 0;
b.IsSeabed = b.OceanCells == b.AreaPx;
b.IsCoastal = b.OceanCells > 0 && !b.IsSeabed;
if (!b.SpillCrossCheckOk) g.SpillCrossCheckFailures++;
if (!b.SpillOnTerrain) g.SpillNotOnTerrain++;
if (b.IsSeabed) g.Seabed++;
if (b.IsCoastal) g.Coastal++;
// ⭐⭐ THE DOWNSTREAM WALK — the reference's provisional-route descent, started at the spill.
if (bCell[id] >= 0)
{
int c = bCell[id];
b.Downstream = DownstreamKind.None;
for (int guard = 0; guard < 4 * n; guard++)
{
b.SpillPath.Add(c);
if (isOcean[c]) { b.Downstream = DownstreamKind.Ocean; break; }
int bid = basinId[c];
if (bid != 0 && bid != id) { b.Downstream = DownstreamKind.Basin; b.DownstreamId = bid; break; }
int cx = c / n, cy = c % n;
float best = float.MaxValue; int bestN = -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;
int ni = nx * n + ny;
if (ff[ni] < best) { best = ff[ni]; bestN = ni; }
}
if (bestN < 0 || ff[bestN] >= ff[c]) break; // stuck — a closed sink (or an exact flat)
c = bestN;
}
b.DownstreamEntryCell = b.SpillPath[^1];
// ⭐ Cross-check on the analysis's own D8 field, from the first cell PAST the spill.
if (b.SpillPath.Count >= 2)
{
int c2 = b.SpillPath[1];
var (dk, did) = WalkDir(plan, n, isOcean, c2);
b.DirWalkKind = dk; b.DirWalkId = did;
b.DirWalkAgrees = dk == b.Downstream && did == b.DownstreamId;
}
else { b.DirWalkKind = b.Downstream; b.DirWalkId = b.DownstreamId; b.DirWalkAgrees = true; }
if (!b.DirWalkAgrees) g.DirWalkDisagreements++;
}
if (b.IsLand)
{
switch (b.Downstream)
{
case DownstreamKind.Ocean: g.ToOcean++; break;
case DownstreamKind.Basin: g.ToBasin++; break;
default: g.Closed++; break;
}
if (b.IsLake) g.LakeBasins++; else g.DryBasins++;
g.LandNodes.Add(b);
}
g.Nodes.Add(b);
g._byId[id] = b;
}
// ---- the reconciliation, per significant body: which basin owns it? ----
for (int i = 0; i < total; i++)
if (isClassifyWater[i] && !isOcean[i]) { g.ClassifyWaterCellsTotal++; if (basinId[i] == 0) g.ClassifyWaterCellsOutsideBasins++; }
if (isSignificantWater != null)
{
var seen = new bool[total];
var stack = new Stack<int>();
var perBasin = new Dictionary<int, long>();
for (int s = 0; s < total; s++)
{
if (seen[s] || !isSignificantWater[s]) continue;
var body = new LakeBody { Index = g.LakeBodies.Count + 1 };
perBasin.Clear();
seen[s] = true; stack.Push(s);
while (stack.Count > 0)
{
int c = stack.Pop();
body.SizePx++;
int bid = basinId[c];
if (bid != 0) { body.CellsInBasins++; perBasin.TryGetValue(bid, out long cur); perBasin[bid] = cur + 1; }
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 (seen[ni] || !isSignificantWater[ni]) continue;
seen[ni] = true; stack.Push(ni);
}
}
body.BasinsTouched = perBasin.Count;
foreach (var kv in perBasin)
if (kv.Value > body.DominantCells || (kv.Value == body.DominantCells && kv.Key < body.DominantBasinId))
{ body.DominantCells = kv.Value; body.DominantBasinId = kv.Key; }
if (body.Free) g.LakeBodiesFree++; else if (body.Owned) g.LakeBodiesOwned++; else g.LakeBodiesSplit++;
g.LakeBodies.Add(body);
}
}
return g;
}
/// <summary>Follow <c>Plan.Dir</c> from a cell to where its flow ends: the sea, a terminal basin, or nowhere.</summary>
private static (DownstreamKind kind, int id) WalkDir(DrainageAnalysis.Plan plan, int n, bool[] isOcean, int start)
{
int c = start;
for (int guard = 0; guard < 8 * n; guard++)
{
if (isOcean[c]) return (DownstreamKind.Ocean, 0);
sbyte d = plan.Dir[c];
if (d == DrainageAnalysis.D_SEA) return (DownstreamKind.Ocean, 0);
if (d == DrainageAnalysis.D_NONE) return plan.BasinId[c] != 0 ? (DownstreamKind.Basin, plan.BasinId[c]) : (DownstreamKind.None, 0);
int cx = c / n, cy = c % n;
c = (cx + DX[d]) * n + (cy + DY[d]);
}
return (DownstreamKind.None, 0);
}
/// <summary>
/// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
/// <see cref="BasinNode.SpillClimbM"/> must be ≤ <paramref name="capM"/>. A preview of what the
/// flow-through model will trade at a given cap; it decides nothing.
/// </summary>
public bool[] ConnectedAtCap(float capM, out int connected)
{
var state = new Dictionary<int, byte>(); // 1 = yes, 2 = no, 3 = visiting
bool Reach(int id)
{
if (state.TryGetValue(id, out byte s)) return s == 1;
var b = Of(id);
if (b == null) { state[id] = 2; return false; }
state[id] = 3;
bool ok = false;
if (b.SpillClimbM <= capM)
{
if (b.Downstream == DownstreamKind.Ocean) ok = true;
else if (b.Downstream == DownstreamKind.Basin)
{
// The graph is a DAG (an edge always lands on a strictly lower spill); the
// visiting guard is belt-and-braces, never expected to fire.
bool visiting = state.TryGetValue(b.DownstreamId, out byte ds) && ds == 3;
ok = !visiting && Reach(b.DownstreamId);
}
}
state[id] = ok ? (byte)1 : (byte)2;
return ok;
}
var outp = new bool[Nodes.Count];
connected = 0;
for (int i = 0; i < Nodes.Count; i++)
{
outp[i] = Reach(Nodes[i].Id);
if (outp[i]) connected++;
}
return outp;
}
/// <summary>Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.</summary>
public int HopsToOcean(int id)
{
int hops = 0; var seen = new HashSet<int>();
var b = Of(id);
while (b != null && seen.Add(b.Id))
{
if (b.Downstream == DownstreamKind.Ocean) return hops + 1;
if (b.Downstream != DownstreamKind.Basin) return -1;
b = Of(b.DownstreamId); hops++;
}
return -1;
}
}
}