using System; using System.Collections.Generic; using Godot; using IslaApocalypse.Core; namespace IslaApocalypse.Tools { /// /// ⭐⭐ THE CANDIDATE SET — one implementation, shared by every task that ranks rivers. /// /// Extracted from `RiverPromotionTool` at rivers/03, unchanged in behaviour, because routing needs /// exactly the same promoted set the count gate was judged on. **Two copies of this enumeration /// would be two answers to "which rivers does the island have", and the epic rests on there being /// one.** `RiverPromotionTool` now delegates here; its plates are byte-identical across the change. /// /// ═══ WHAT IT DOES, AND WHAT IT DELIBERATELY DOES NOT ═══ /// /// It derives the COMPLETE candidate set from the arrays `DrainageAnalysis.Plan` exposes — /// `Dir` / `Acc` / `BasinId` / `BasinInflow` / `FullFilled` — rather than from `Plan.Trunks` / /// `Plan.Giants`, which are already truncated by the analysis's lean reporting caps. Ranking over /// the truncated lists would measure the caps rather than the terrain. /// /// ⚠ **`DrainageAnalysis` is reused, never rebuilt.** Every derived quantity here is a /// reconstruction of a value the analysis computed internally, from state it exposes. Nothing the /// analysis owns is reimplemented — least of all stem tracing, which is BOUND from the analysis's /// own `Trunk` / `Giant` (see ). /// public static class RiverCandidates { /// The enumeration's full result: the ranking, plus what the separation rule cost. public sealed class Enumeration { /// Separated and above the floor, descending by DrainagePx, `Rank` assigned. public List Ranked; public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells; public int TerminalBasins, SeaOutletsAll; /// Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles. public int SuppressedCount; public long SuppressedPx; /// ⭐ The two that matter: only outlets clearing the floor could ever have been promoted. public int SuppressedAboveFloor; public long SuppressedAboveFloorPx; /// Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition. public int SuppressedCrossLandmass; public long SuppressedCrossLandmassPx; public List SuppressedAboveFloorAccs = new(); public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } } } /// /// Enumerate every candidate major drainage, cap-free. /// /// SEA every cell with Dir == D_SEA, carrying Acc there, then the /// analysis's own greedy MinOutletSeparationPx rule so three mouths of one /// delta are not three rivers. /// ENDORHEIC every terminal basin in BasinId, carrying BasinInflow[id], with /// terminal cell / area / depth re-derived from the exposed surfaces. /// /// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below. /// public static Enumeration Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n, long floorPx, int separationPx, RegionLabels regions) { int total = n * n; var r = new Enumeration { LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells, EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells, TerminalBasins = plan.TerminalBasinCount, }; // ---- SEA: every outlet, then the separation rule ---- var outlets = new List<(int cell, long acc)>(); long seaSum = 0; for (int i = 0; i < total; i++) if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; } outlets.Sort((a, b) => b.acc.CompareTo(a.acc)); r.SeaOutletsAll = outlets.Count; var sea = new List(); var kept = new List(); foreach (var (cell, acc) in outlets) { int cx = cell / n, cy = cell % n; bool far = true; int suppressor = -1; foreach (int pcell in kept) { float ddx = cx - pcell / n, ddy = cy - pcell % n; if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; } } var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far }; if (far) kept.Add(cell); else { r.SuppressedCount++; r.SuppressedPx += acc; if (acc >= floorPx) { r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc); // ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain // Euclidean distance test — it has no idea what land a coastline belongs to. On // this deliberately fragmented archipelago (→ D-063) an ISLAND's only river can // be suppressed by a mainland mouth 400 px away ACROSS WATER. Measured, not // argued; the rule itself is NOT changed (it belongs to the analysis). if (regions != null && suppressor >= 0) { int a = regions.Id[cell], b = regions.Id[suppressor]; if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; } } } } if (acc >= floorPx) sea.Add(c); } // ---- ENDORHEIC: every terminal basin, metrics re-derived ---- // After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and // Filled == the original height there — so FullFilled − height IS the fill depth, and the // basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly // what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx. int maxId = 0; for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i]; var area = new long[maxId + 1]; var minCell = new int[maxId + 1]; var minH = new float[maxId + 1]; var depth = new float[maxId + 1]; for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; } for (int i = 0; i < total; i++) { int id = plan.BasinId[i]; if (id == 0) continue; area[id]++; float h = height[i / n, i % n]; if (h < minH[id]) { minH[id] = h; minCell[id] = i; } float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h); if (d > depth[id]) depth[id] = d; } var endo = new List(); long endoSum = 0; for (int id = 1; id <= maxId; id++) { if (minCell[id] < 0) continue; long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0; endoSum += inflow; if (inflow < floorPx) continue; endo.Add(new RiverCandidate { IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n, TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id], }); } // ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══ // // Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land // cell has exactly one destination — so the two populations partition the land exactly. // If this identity ever fails, the two numbers are not the same unit and ranking them in // one list is meaningless. It is asserted per seed rather than argued in a comment. long partition = seaSum + endoSum + plan.UnroutedCells; if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells) throw new InvalidOperationException( "[RiverCandidates] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" + $" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" + $" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" + $" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" + "Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " + "population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)"); GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition"); // ---- the unified ranking: one list, both termini, descending by contributing cells ---- var ranked = new List(); foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c); ranked.AddRange(endo); ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx)); for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1; r.Ranked = ranked; return r; } /// /// Bind each candidate in to the Trunk / Giant the /// analysis already traced, so plates draw REAL upland stems rather than anything reimplemented /// here. Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by /// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells at /// the minimum height and the analysis's DFS tie-break need not match a row-major scan. /// /// ⚠ Also transfers the analysis's own and /// for endorheic candidates — rivers/03 /// needs the reference's routed/lake-ender verdict to compare against its own. /// public static void BindCourses(DrainageAnalysis.Plan plan, int n, List need, string what) { var byOutlet = new Dictionary(); foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t; var byBasin = new Dictionary(); foreach (var g in plan.Giants) { int cell = (int)g.Terminal.x * n + (int)g.Terminal.y; int id = plan.BasinId[cell]; if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g; } int missing = 0; foreach (var c in need) { if (c.Course != null) continue; if (c.IsSea) { if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; } } else { // ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate // is keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and // marking the wrong one draws every endorheic stem detached from its own endpoint. if (byBasin.TryGetValue(c.BasinId, out var g)) { c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y; c.AnalysisKind = g.Kind; c.TerminalInClassifyWater = g.TerminalInClassifyWater; } } if (c.Course == null) missing++; } if (missing > 0) throw new InvalidOperationException( $"[RiverCandidates] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " + "reporting caps are what produce the courses, so they must cover every candidate being drawn — " + "raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers."); } } }