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; } } }