diff --git a/Tools/Scripts/DrainageAnalysis.cs b/Tools/Scripts/DrainageAnalysis.cs index e540a44..c9f6544 100644 --- a/Tools/Scripts/DrainageAnalysis.cs +++ b/Tools/Scripts/DrainageAnalysis.cs @@ -53,6 +53,7 @@ public static class DrainageAnalysis public int EndorheicMaxCount = 3; public int TrunkCount = 3; // ~3 sea-reaching trunks (developer) + public int GiantCount = 3; // 21b: top endorheic giants promoted public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta public int StemMinAccPx = 1000; // stem tracing stops below this @@ -83,6 +84,32 @@ public static class DrainageAnalysis public List Tributaries = new(); } + /// + /// A promoted endorheic giant (task 21b): one of the island's biggest drainage + /// systems, which pools inland because erosion could not cross the flats. + /// Kind "routed" carries a PROVISIONAL route across the flats to the ocean — + /// the path part 2 would carve, drawn for the gate, not water. Kind + /// "lake-ender" keeps its lake/lagoon terminal (real geography, developer's + /// call). Terminal is where the MAIN STEM actually pools (its sub-minimum), + /// which on a flat basin floor is more truthful than the basin's deepest cell. + /// + public class Giant : Stream + { + public (float x, float y) Terminal; + public (float x, float y) Spill; // where the basin overtops + public float BasinDepthM; + public long BasinAreaPx; + public string Kind = "routed"; // "routed" | "lake-ender" + public bool SouthernCandidate; + public bool TerminalInClassifyWater; + public List<(float x, float y)> ProvisionalRoute; // null for lake-enders + public bool RouteReachedOcean; + public (float x, float y) MountainExit; + public float MountainExitElevM; + public bool ExitFound; + public List Tributaries = new(); + } + public class EndorheicTerminal { public (float x, float y) Terminal; // basin minimum @@ -95,6 +122,7 @@ public static class DrainageAnalysis { public List Trunks = new(); public List Endorheics = new(); + public List Giants = new(); // 21b: the promoted giants public int TerminalBasinCount; // basins that qualified as sinks public long PitsFilledCount; // depressions filled through public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells; @@ -110,7 +138,14 @@ public static class DrainageAnalysis /// such) or fill and spill onward to the true sea. Without this mask the first /// draft called two of its three "sea-reaching" trunks done at enclosed /// lagoons, which is exactly the overclaim the gate must not inherit. - public static Plan Run(float[,] height, int mapSize, bool[] isOcean, Params p) + /// Row-major mask of ANY classify water (WBID != 0): + /// a giant whose main stem pools inside classify water is a natural lake-ender; + /// one pooling on dry ground is a route-to-sea candidate. + /// Southernmost-town position (or -1 for none): the giant + /// whose terminal lies closest is flagged the SOUTHERN CANDIDATE and always + /// routed provisionally, per the 21b design — shown, not forced. + public static Plan Run(float[,] height, int mapSize, bool[] isOcean, + bool[] isClassifyWater, float southX, float southY, Params p) { int n = mapSize; int total = n * n; @@ -122,6 +157,8 @@ public static class DrainageAnalysis for (int y = 0; y < n; y++) original[x * n + y] = height[x, y]; + float[] plan_fullFilled = null; // set inside step 2, used by 21b routing + // --- 1. Priority-flood with one-ulp epsilon (routing surface only) --- float[] filled = (float[])original.Clone(); { @@ -197,6 +234,11 @@ public static class DrainageAnalysis basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell); } + // 21b: the FULL fill (before terminal reversion) is the provisional- + // routing surface — on it, every basin overtops at its spill and drains + // to the border, which is exactly "where the water would continue". + plan_fullFilled = (float[])filled.Clone(); + bool[] terminal = new bool[nextId]; for (int id = 1; id < nextId; id++) { @@ -270,8 +312,8 @@ public static class DrainageAnalysis // badly when the basin floor is flat (a lagoon bed scatters inflow across // many sub-minima — measured: a 500k-px lagoon system reported under 50k). long[] basinInflow = new long[basinMinCell.Count]; + int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id { - int[] dest = new int[total]; // 0 unknown, -1 sea, -2 stuck, >0 basin id var path = new List(4096); for (int i = 0; i < total; i++) { @@ -455,6 +497,161 @@ public static class DrainageAnalysis } } + // --- 5d. The promoted GIANTS (21b): mixed set, provisional routes --- + // Top GiantCount terminal basins by TOTAL inflow. Their upland stems are the + // island's real big rivers; whether each continues to the sea is the gate's + // decision, previewed here. + { + var giantsRanked = new List<(int id, long inflow)>(); + for (int id = 1; id < basinMinCell.Count; id++) + { + int mc = basinMinCell[id]; + if (mc < 0 || basinId[mc] != id) continue; + if (basinInflow[id] >= p.EndorheicMinInflowPx) giantsRanked.Add((id, basinInflow[id])); + } + giantsRanked.Sort((a, b) => b.inflow.CompareTo(a.inflow)); + + // Does a terminal basin HOLD classify water? The lake-ender test must look + // at the whole pool, not the stem's single pooling cell — a stem can pool on + // dry ground a few hundred px short of its lagoon and still be a lagoon river. + bool[] basinHasLake = new bool[basinMinCell.Count]; + for (int i = 0; i < total; i++) + if (basinId[i] != 0 && isClassifyWater[i] && !isOcean[i]) + basinHasLake[basinId[i]] = true; + + // The main stem's ENTRY into the basin: the highest-accumulation cell + // whose flow terminates in this basin. On a flat basin floor the deepest + // cell sees only local trickles (the task-21 lesson), so the stem is + // anchored on the strongest feeder instead. + var bestEntry = new Dictionary(); + for (int i = 0; i < total; i++) + { + if (dest[i] <= 0) continue; + if (!bestEntry.TryGetValue(dest[i], out int cur) || acc[i] > acc[cur]) + bestEntry[dest[i]] = i; + } + + // The giant whose pooling point sits closest to the southernmost town is + // the SOUTHERN CANDIDATE — always routed provisionally (shown, not forced). + int southernPick = -1; + if (southX >= 0f) + { + float bestD = float.MaxValue; + foreach (var (id, _) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count))) + { + int mc = basinMinCell[id]; + float ddx = mc / n - southX, ddy = mc % n - southY; + float d2 = ddx * ddx + ddy * ddy; + if (d2 < bestD) { bestD = d2; southernPick = id; } + } + } + + foreach (var (id, inflow) in giantsRanked.GetRange(0, Math.Min(p.GiantCount, giantsRanked.Count))) + { + var g = new Giant { DrainageAreaPx = inflow, BasinDepthM = basinDepthM[id], BasinAreaPx = basinAreaPx[id] }; + if (!bestEntry.TryGetValue(id, out int entry)) entry = basinMinCell[id]; + + // Downstream from the strongest feeder to where it actually pools… + int t2 = entry; + var down = new List { t2 }; + while (dir[t2] >= 0) { t2 = Target(t2); down.Add(t2); } + g.Terminal = (t2 / n, t2 % n); + // …then the full main stem, traced upstream from that pooling point. + var stem = TraceStem(t2, p.StemMinAccPx); + g.Course = Decimate(stem); + g.Head = (stem[^1] / n, stem[^1] % n); + g.TerminalInClassifyWater = isClassifyWater[t2]; + + for (int i = 0; i + p.ExitWindowPx < stem.Count; i++) + { + float rise = (original[stem[i + p.ExitWindowPx]] - original[stem[i]]) * M_PER_UNIT; + if (rise / p.ExitWindowPx >= p.ExitGradeMin) + { + g.ExitFound = true; + g.MountainExit = (stem[i] / n, stem[i] % n); + g.MountainExitElevM = original[stem[i]] * M_PER_UNIT; + break; + } + } + + // Lean tributaries on the giant's stem, same junction rule as trunks. + var stemSet = new HashSet(stem); + var cands = new List<(int cell, long acc)>(); + foreach (int sc in stem) + { + int cx = sc / n, cy = sc % 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 (stemSet.Contains(ni)) continue; + if (dir[ni] >= 0 && Target(ni) == sc && acc[ni] >= p.TributaryMinAccPx) + cands.Add((ni, acc[ni])); + } + } + cands.Sort((a, b) => b.acc.CompareTo(a.acc)); + var takenT = new List(); + foreach (var (cell, _) in cands) + { + if (takenT.Count >= p.TributaryMaxPerTrunk) break; + int cx2 = cell / n, cy2 = cell % n; + bool dup = false; + foreach (int tc in takenT) + { + float ddx = cx2 - tc / n, ddy = cy2 - tc % n; + if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; } + } + if (!dup) takenT.Add(cell); + } + foreach (int cell in takenT) + { + var trib = new Stream { DrainageAreaPx = acc[cell] }; + var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(acc[cell] / 20))); + trib.Course = Decimate(ts); + trib.Head = (ts[^1] / n, ts[^1] % n); + g.Tributaries.Add(trib); + } + + // Kind: the terminal BASIN holds a classify lake → natural lake-ender; + // dry pan → route to sea; the southern candidate is always routed. + g.SouthernCandidate = id == southernPick; + g.TerminalInClassifyWater = g.TerminalInClassifyWater || basinHasLake[id]; + g.Kind = (basinHasLake[id] && !g.SouthernCandidate) ? "lake-ender" : "routed"; + + // PROVISIONAL route (routed giants): walk steepest descent on the FULL + // fill from the pooling point — the basin overtops at its spill and + // the walk continues along the terrain's own drainage to the ocean. + // DRAWN, not carved; part 2 carves along a route like this one. + if (g.Kind == "routed") + { + var route = new List(); + int c = t2; + bool spillRecorded = false; + for (int guard = 0; guard < 4 * n; guard++) + { + route.Add(c); + if (isOcean[c]) { g.RouteReachedOcean = true; break; } + if (!spillRecorded && basinId[c] != id) + { g.Spill = (c / n, c % n); spillRecorded = true; } + 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 (plan_fullFilled[ni] < best) { best = plan_fullFilled[ni]; bestN = ni; } + } + if (bestN < 0 || plan_fullFilled[bestN] >= plan_fullFilled[c]) break; // stuck (report via flag) + c = bestN; + } + g.ProvisionalRoute = Decimate(route); + } + plan.Giants.Add(g); + } + } + return plan; } } diff --git a/Tools/Scripts/RiverPlanTool.cs b/Tools/Scripts/RiverPlanTool.cs index 0655556..53cf35b 100644 --- a/Tools/Scripts/RiverPlanTool.cs +++ b/Tools/Scripts/RiverPlanTool.cs @@ -47,6 +47,8 @@ public partial class RiverPlanTool : Node p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx); p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount); p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount); + p.TrunkCount = EnvI("RIVERPLAN_OCEAN_N", p.TrunkCount); // 21b alias + p.GiantCount = EnvI("RIVERPLAN_GIANT_N", p.GiantCount); p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx); p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx); p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx); @@ -81,12 +83,22 @@ public partial class RiverPlanTool : Node // destinations that make a trunk "sea-reaching". int nn = bp.MapSize; bool[] isOcean = new bool[nn * nn]; + bool[] isClassifyWater = new bool[nn * nn]; for (int x = 0; x < nn; x++) for (int y = 0; y < nn; y++) - isOcean[x * nn + y] = bp.WaterBodyIds[x, y] == 1; + { + ushort wb = bp.WaterBodyIds[x, y]; + isOcean[x * nn + y] = wb == 1; + isClassifyWater[x * nn + y] = wb != 0; + } + + // Southernmost town — the 21b SOUTHERN CANDIDATE anchor (shown, not forced). + float southX = -1f, southY = -1f; + foreach (var t in bp.Towns) + if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; } var p = ReadParams(); - var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, p); + var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, isClassifyWater, southX, southY, p); ulong t2 = Time.GetTicksMsec(); GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s."); @@ -123,6 +135,22 @@ public partial class RiverPlanTool : Node (wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + "."); } + // ---- 21b: the promoted giants ---- + int gi = 0; + foreach (var g in plan.Giants) + { + gi++; + GD.Print($"[RiverPlan] GIANT {gi} [{g.Kind.ToUpper()}{(g.SouthernCandidate ? " — SOUTHERN CANDIDATE" : "")}]: " + + $"drainage {g.DrainageAreaPx} px, pools at ({g.Terminal.x:F0},{g.Terminal.y:F0}) " + + $"({(g.TerminalInClassifyWater ? "in classify water" : "dry pan")}, basin {g.BasinDepthM:F1} m / {g.BasinAreaPx} px), " + + (g.ExitFound ? $"mountain-exit ({g.MountainExit.x:F0},{g.MountainExit.y:F0}) at {g.MountainExitElevM:F0} m, " : "") + + $"{g.Tributaries.Count} tributaries" + + (g.ProvisionalRoute != null + ? $"; PROVISIONAL route {g.ProvisionalRoute.Count * 4} px via spill ({g.Spill.x:F0},{g.Spill.y:F0}) " + + (g.RouteReachedOcean ? "-> reaches the OCEAN" : "-> DID NOT reach the ocean (walk stuck — report)") + : "; ends at its lake") + "."); + } + // ---- the southern-town report (filed fact, not a constraint) ---- if (bp.Towns.Count > 0) { @@ -209,6 +237,39 @@ public partial class RiverPlanTool : Node if (i < plan.Trunks.Count - 1) sb.Append(','); sb.Append('\n'); } + sb.Append("],\n\"giants\": [\n"); + for (int i = 0; i < plan.Giants.Count; i++) + { + var g = plan.Giants[i]; + sb.Append(" {\"kind\": \"").Append(g.Kind).Append("\", "); + sb.Append($"\"southernCandidate\": {(g.SouthernCandidate ? "true" : "false")}, "); + sb.Append($"\"drainageAreaPx\": {g.DrainageAreaPx}, "); + sb.Append("\"terminal\": "); Pt(sb, g.Terminal); + sb.Append($", \"terminalInClassifyWater\": {(g.TerminalInClassifyWater ? "true" : "false")}, "); + sb.Append($"\"basinDepthM\": {g.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {g.BasinAreaPx}, "); + sb.Append($"\"exitFound\": {(g.ExitFound ? "true" : "false")}, \"mountainExit\": "); Pt(sb, g.MountainExit); + sb.Append($", \"mountainExitElevM\": {g.MountainExitElevM.ToString("F1", ci)},\n \"course\": "); + Course(g.Course); + if (g.ProvisionalRoute != null) + { + sb.Append(",\n \"spill\": "); Pt(sb, g.Spill); + sb.Append($", \"routeReachedOcean\": {(g.RouteReachedOcean ? "true" : "false")}"); + sb.Append(",\n \"provisionalRoute_NOT_WATER\": "); + Course(g.ProvisionalRoute); + } + sb.Append(",\n \"tributaries\": ["); + for (int j = 0; j < g.Tributaries.Count; j++) + { + var tr = g.Tributaries[j]; + if (j > 0) sb.Append(','); + sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": "); + Course(tr.Course); + sb.Append('}'); + } + sb.Append("]\n }"); + if (i < plan.Giants.Count - 1) sb.Append(','); + sb.Append('\n'); + } sb.Append("],\n\"endorheics\": ["); for (int i = 0; i < plan.Endorheics.Count; i++) {