using System;
using System.Collections.Generic;
namespace IslaApocalypse.Core
{
/// Where a terminal basin's spill drains next.
public enum DownstreamKind : byte
{
/// The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).
None = 0,
/// The spill drains into RegionLabeling.OceanMask — the sea, on the CLASSIFY field.
Ocean = 1,
/// The spill drains into another terminal basin's cells ().
Basin = 2,
}
///
/// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of ,
/// 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 (Plan.FullFilled, the priority-flood of the eroded render height)
/// , , ,
/// , walk — everything about WHERE WATER GOES.
/// This is the surface RiverRouting.RouteTo routes on, so a spill height and a rim climb are
/// the same kind of number the router already measures.
///
/// CLASSIFY / WATER surface (isClassifyWater = classify < sea; OceanMask)
/// , , , 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.
///
public sealed class BasinNode
{
/// The terminal-basin id, as Plan.BasinId carries it (sparse: pits that filled through gave up their ids).
public int Id;
/// Cells with BasinId == Id.
public long AreaPx;
/// Plan.BasinInflow[Id] — cells whose flow terminates here (the promotion metric).
public long InflowPx;
/// The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.
public int FloorCell;
public float FloorHeightRaw;
///
/// The basin's ENTRY cell: its minimum on FullFilled. 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.
///
public int EntryCell;
public float EntryFullFilledRaw;
///
/// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on FullFilled
/// (== the render height there — asserted, see ). This is the rim cell
/// water would overtop. Ties (same height) resolve to the lowest cell index;
/// says how many boundary cells sit at exactly this height.
///
public int SpillCell;
public float SpillHeightRaw;
public int SpillTies;
/// ⭐ Cross-check (a) vs (b): BitDecrement(EntryFullFilledRaw) == SpillHeightRaw. The uniform-fill-level reading and the rim-walk reading must agree exactly.
public bool SpillCrossCheckOk;
/// ⭐ The spill cell is real terrain: FullFilled[spill] == render[spill] (it was never raised by the flood).
public bool SpillOnTerrain;
/// Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).
public float SpillAboveSeaM;
/// Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ DrainageAnalysis's basinDepthM).
public float DepthToSpillM;
///
/// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: ElevM(spill) − ElevM(floor) with elevation clamped at
/// sea exactly as RiverRouting.RouteTo clamps it — so a below-datum lagoon bed climbs from sea
/// level, not from its bed. Same number the router's RimClimbM is. ⚠ The clamp is an ELEVATION
/// rule on the render surface, not a water test — nothing here reads "render < sea" as water.
///
public float SpillClimbM;
/// Cells with BasinId == Id that are classify-water and NOT ocean. Read on CLASSIFY.
public long LakeCells;
/// Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.
public long LakeCellsSignificant;
/// ⚠ Cells with BasinId == Id that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.
public long OceanCells;
/// ⭐ LakeCells >= floor — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.
public bool IsLake;
///
/// ⚠⚠ 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.
///
public bool IsSeabed;
/// 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.
public bool IsCoastal;
/// A basin with at least one land cell — the ones the graph is about.
public bool IsLand => !IsSeabed;
/// LakeCells > 0 — exactly DrainageAnalysis's basinHasLake (any size), the routing sort. Kept so the two labels can be compared.
public bool HasAnyLake;
/// ⭐⭐ THE EDGE — where the spill drains next.
public DownstreamKind Downstream;
/// The downstream basin id when is ; 0 otherwise.
public int DownstreamId;
/// The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.
public int DownstreamEntryCell;
/// The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on FullFilled.
public List SpillPath = new();
/// ⭐ Cross-check: following Plan.Dir (the analysis's own D8 field) from the first cell past the spill reaches the same node.
public bool DirWalkAgrees = true;
public DownstreamKind DirWalkKind;
public int DirWalkId;
}
///
/// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
///
/// ═══ WHAT IT IS ═══
///
/// already found the sinks (BasinId) and already computed the
/// overflow surface (FullFilled). 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. DrainageAnalysis is consumed, not edited.**
/// The caller asserts both height digests unchanged around .
///
/// ═══ ⭐ 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 BitIncrement(L) and every deeper cell to one ulp above ITS parent. So:
/// • a terminal basin's cells (FullFilled > original, 8-connected) are ONE flood chain from
/// ONE spill, and their minimum on FullFilled is exactly L + 1 ulp;
/// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so FullFilled == original
/// 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 (), and
/// the "spill sits on real terrain" fact is asserted too ().
///
/// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
///
/// Plan.Dir is D8 on Filled — the surface with terminal basins REVERTED to real heights.
/// The spill cell is the saddle; on Filled 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
/// FullFilled 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. Dir is
/// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
///
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;
/// The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (ISLA_LAKE_MIN_PX).
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 Nodes = new();
private Dictionary _byId = new();
public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
// ---- invariant tallies ----
public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
/// Tallies over LAND basins only (seabed basins excluded — see ).
public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
/// ⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.
public int Seabed, Coastal;
/// The land basins, in id order — what every statistic and the plate's graph are over.
public List LandNodes = new();
///
/// ⭐ 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.
///
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;
}
/// Every significant body (8-connected, ≥ floor), scan order.
public List LakeBodies = new();
public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
/// Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.
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[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()).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();
var perBasin = new Dictionary();
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;
}
/// Follow Plan.Dir from a cell to where its flow ends: the sea, a terminal basin, or nowhere.
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);
}
///
/// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
/// must be ≤ . A preview of what the
/// flow-through model will trade at a given cap; it decides nothing.
///
public bool[] ConnectedAtCap(float capM, out int connected)
{
var state = new Dictionary(); // 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;
}
/// Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.
public int HopsToOcean(int id)
{
int hops = 0; var seen = new HashSet();
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;
}
}
}