diff --git a/Tools/Scenes/RiverPlanTool.tscn b/Tools/Scenes/RiverPlanTool.tscn new file mode 100644 index 0000000..0e9f20c --- /dev/null +++ b/Tools/Scenes/RiverPlanTool.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://rvplantool21"] + +[ext_resource type="Script" path="res://Tools/Scripts/RiverPlanTool.cs" id="1_rpt"] + +[node name="RiverPlanTool" type="Node"] +script = ExtResource("1_rpt") diff --git a/Tools/Scripts/DrainageAnalysis.cs b/Tools/Scripts/DrainageAnalysis.cs new file mode 100644 index 0000000..e540a44 --- /dev/null +++ b/Tools/Scripts/DrainageAnalysis.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Generic; + +/// +/// Drainage-network promotion — C0b part 1 (terrain-water task 21). PURE ANALYSIS: +/// reads the ERODED render heightmap and produces a river PLAN — it changes zero +/// terrain and adds zero water. Standalone numeric (D-035 family; no Godot types). +/// +/// Pipeline, built on the task-03 priority-flood family: +/// 1. Priority-flood the eroded surface from the map border (Barnes heap+pit +/// variant, 8-connected, same as RunPriorityFloodDiagnostics) — but with a +/// one-ulp epsilon on pit fills, so every filled cell keeps a STRICTLY +/// descending path to its spill. This resolves the ~15,000 erosion pits +/// (task-20 finding) for ROUTING ONLY; the terrain itself is never modified. +/// 2. Depressions that are deep AND large enough (the endorheic dials) are NOT +/// filled through: their cells revert to original heights, so flow entering +/// them terminates at the basin minimum. Real closed drainage survives; +/// micro-pits route through. +/// 3. D8 flow directions on that routing surface. D8 was reverted as a CARVING +/// technique (task 10 — grid-aligned scratches in the terrain); using it to +/// COMPUTE where water flows is standard hydrology and leaves no mark. +/// 4. Flow accumulation by topological (Kahn) propagation — no sort needed. +/// 5. Promotion: outlets to the sea ranked by drainage area, top-N (separated) +/// become trunks; main stems traced upstream by max-accumulation; the +/// mountain-exit point found from the along-stem grade; LEAN tributaries and +/// LEAN endorheic terminals marked. +/// +/// The plan's lowland courses are provisional: erosion delivered the UPLAND +/// network only (task 18 §3), so below each mountain-exit the traced course is +/// "where the routing surface drains", not a designed river. Part 2 (task 22) +/// routes the lowland reach properly from the mountain-exit points — which is why +/// those points are this analysis's key output. +/// +public static class DrainageAnalysis +{ + public const float M_PER_UNIT = 251f; + + // Neighbour order is FIXED (it is 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 }; + private static readonly float[] DIST = { + 1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f }; + + public class Params + { + // Endorheic qualification: a depression this deep AND this large is a real + // closed basin and terminates flow; anything smaller is a pit, filled through. + public float EndorheicMinDepthM = 2.0f; + public int EndorheicMinAreaPx = 10000; + // Endorheic REPORTING is lean: only terminals with at least this much + // upstream drainage, at most MaxCount of them. + public int EndorheicMinInflowPx = 50000; + public int EndorheicMaxCount = 3; + + public int TrunkCount = 3; // ~3 sea-reaching trunks (developer) + public int MinOutletSeparationPx = 400; // don't pick 3 mouths of one delta + + public int StemMinAccPx = 1000; // stem tracing stops below this + public int TributaryMinAccPx = 30000; // LEAN: a branch must drain this much + public int TributaryMaxPerTrunk = 4; // ...and only the top few are marked + + // Mountain-exit: furthest-downstream stem point where the upstream window + // still sustains this grade (m per px) over ExitWindowPx. + public float ExitGradeMin = 0.05f; + public int ExitWindowPx = 100; + + public float SeaLevel = 0.15f; // flat sea scalar (raw units) + } + + public class Stream + { + public List<(float x, float y)> Course = new(); // downstream-first + public long DrainageAreaPx; + public (float x, float y) Head; // upstream end + } + + public class Trunk : Stream + { + public (float x, float y) Outlet; // last land cell before sea + 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 + public long DrainageAreaPx; + public float BasinDepthM; + public long BasinAreaPx; + } + + public class Plan + { + public List Trunks = new(); + public List Endorheics = new(); + public int TerminalBasinCount; // basins that qualified as sinks + public long PitsFilledCount; // depressions filled through + public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells; + public List<(float x, float y, long acc)> AllOutletsTop = new(); // top 12, pre-separation + public Params P; + } + + /// Row-major mask of THE OCEAN body (WBID == 1) — the + /// only water that counts as "the sea" for sea-reaching trunks. Below-sea + /// cells that are NOT ocean (enclosed lagoons, below-datum lake beds) are + /// ordinary terrain to the router: as depressions they either qualify as + /// terminal basins (a river legitimately ENDING in a lagoon/lake — reported as + /// 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) + { + int n = mapSize; + int total = n * n; + var plan = new Plan { P = p }; + + // 1-D row-major copies (idx = x * n + y), same convention as the task-03 pass. + float[] original = new float[total]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + original[x * n + y] = height[x, y]; + + // --- 1. Priority-flood with one-ulp epsilon (routing surface only) --- + float[] filled = (float[])original.Clone(); + { + bool[] visited = new bool[total]; + var heap = new PriorityQueue(); + var pit = new Queue(); + void Seed(int idx) + { + if (visited[idx]) return; + visited[idx] = true; + heap.Enqueue(idx, (filled[idx], idx)); // idx tiebreak => deterministic + } + for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); } + for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); } + + while (heap.Count > 0 || pit.Count > 0) + { + int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue(); + float fc = filled[c]; + 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 (visited[ni]) continue; + visited[ni] = true; + if (filled[ni] <= fc) + { + // One ulp above the parent: strictly descending back out, so + // D8 never meets an exact flat inside a filled pit. + filled[ni] = MathF.BitIncrement(fc); + pit.Enqueue(ni); + } + else heap.Enqueue(ni, (filled[ni], ni)); + } + } + } + + // --- 2. Depression components; big+deep ones become terminal sinks --- + // Components of (filled > original), 8-connected — the pools. Qualifying + // pools revert to ORIGINAL height so flow terminates at their minimum. + int[] basinId = new int[total]; // 0 = not in a pool + var basinDepthM = new List { 0f }; + var basinAreaPx = new List { 0L }; + var basinMinCell = new List { -1 }; + { + var stack = new Stack(); + int nextId = 1; + for (int i = 0; i < total; i++) + { + if (basinId[i] != 0 || filled[i] <= original[i]) continue; + int id = nextId++; + long area = 0; float depth = 0f; int minCell = i; float minH = original[i]; + stack.Push(i); basinId[i] = id; + while (stack.Count > 0) + { + int c = stack.Pop(); + area++; + float d = (filled[c] - original[c]) * M_PER_UNIT; + if (d > depth) depth = d; + if (original[c] < minH) { minH = original[c]; minCell = c; } + 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 (basinId[ni] == 0 && filled[ni] > original[ni]) + { basinId[ni] = id; stack.Push(ni); } + } + } + basinDepthM.Add(depth); basinAreaPx.Add(area); basinMinCell.Add(minCell); + } + + bool[] terminal = new bool[nextId]; + for (int id = 1; id < nextId; id++) + { + if (basinDepthM[id] >= p.EndorheicMinDepthM && basinAreaPx[id] >= p.EndorheicMinAreaPx) + { terminal[id] = true; plan.TerminalBasinCount++; } + else plan.PitsFilledCount++; + } + // Revert terminal pools to the real surface; re-tag basinId to keep only + // terminal pools (routing needs to know "am I in a terminal basin"). + for (int i = 0; i < total; i++) + { + if (basinId[i] == 0) continue; + if (terminal[basinId[i]]) filled[i] = original[i]; + else basinId[i] = 0; + } + } + + // --- 3. D8 flow directions on the routing surface --- + // dir[i] = 0..7 neighbour, SEA (into a below-sea cell), or NONE (sink). + const sbyte D_NONE = -1, D_SEA = -2; + sbyte[] dir = new sbyte[total]; + bool IsSea(int idx) => isOcean[idx]; + for (int i = 0; i < total; i++) + { + if (IsSea(i)) { dir[i] = D_NONE; continue; } + int cx = i / n, cy = i % n; + float best = 0f; int bestK = -1; bool bestIsSea = false; + 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; + float drop = (filled[i] - filled[ni]) / DIST[k]; + if (drop > best) { best = drop; bestK = k; bestIsSea = IsSea(ni); } + } + dir[i] = bestK < 0 ? D_NONE : (bestIsSea ? D_SEA : (sbyte)bestK); + } + + // --- 4. Flow accumulation (Kahn topological propagation) --- + int Target(int i) + { + if (dir[i] < 0) return -1; + int cx = i / n, cy = i % n; + return (cx + DX[dir[i]]) * n + (cy + DY[dir[i]]); + } + int[] acc = new int[total]; + { + byte[] indeg = new byte[total]; + for (int i = 0; i < total; i++) + if (dir[i] >= 0) indeg[Target(i)]++; + var q = new Queue(); + for (int i = 0; i < total; i++) + { + if (IsSea(i)) continue; + acc[i] = 1; + if (indeg[i] == 0) q.Enqueue(i); + } + while (q.Count > 0) + { + int c = q.Dequeue(); + if (dir[c] < 0) continue; + int t = Target(c); + acc[t] += acc[c]; + if (--indeg[t] == 0 && !IsSea(t)) q.Enqueue(t); + } + } + + // Bookkeeping: where does each cell's flow END — the sea, WHICH terminal + // basin, or stuck? Memoised downstream walk. The per-basin totals matter: + // crediting a terminal basin only with acc at its deepest cell undercounts + // 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 + var path = new List(4096); + for (int i = 0; i < total; i++) + { + if (IsSea(i) || dest[i] != 0) continue; + int c = i; path.Clear(); + int result; + while (true) + { + if (dest[c] != 0) { result = dest[c]; break; } + path.Add(c); + if (dir[c] == D_SEA) { result = -1; break; } + if (dir[c] == D_NONE) { result = basinId[c] != 0 ? basinId[c] : -2; break; } + c = Target(c); + } + foreach (int pc in path) dest[pc] = result; + } + for (int i = 0; i < total; i++) + { + if (IsSea(i)) continue; + plan.LandCells++; + if (dest[i] == -1) plan.SeaReachingCells++; + else if (dest[i] > 0) { plan.EndorheicCells++; basinInflow[dest[i]]++; } + else plan.UnroutedCells++; + } + } + + // --- 5a. Outlets: land cells whose flow enters the sea, ranked by acc --- + var outlets = new List<(int cell, long acc)>(); + for (int i = 0; i < total; i++) + if (dir[i] == D_SEA) outlets.Add((i, acc[i])); + outlets.Sort((a, b) => b.acc.CompareTo(a.acc)); + + foreach (var (cell, a) in outlets.GetRange(0, Math.Min(12, outlets.Count))) + plan.AllOutletsTop.Add((cell / n, cell % n, a)); + + // Greedy top-N with separation, so three mouths of one delta can't take + // all three trunk slots. + var picked = new List(); + foreach (var (cell, _) in outlets) + { + if (picked.Count >= p.TrunkCount) break; + int cx = cell / n, cy = cell % n; + bool far = true; + foreach (int pcell in picked) + { + float ddx = cx - pcell / n, ddy = cy - pcell % n; + if (ddx * ddx + ddy * ddy < (float)p.MinOutletSeparationPx * p.MinOutletSeparationPx) + { far = false; break; } + } + if (far) picked.Add(cell); + } + + // upstream max-acc walk shared by trunks and tributaries + List TraceStem(int fromCell, int minAcc) + { + var stem = new List { fromCell }; + int c = fromCell; + while (true) + { + int cx = c / n, cy = c % n; + int bestN = -1; long bestA = minAcc - 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 (dir[ni] >= 0 && Target(ni) == c && acc[ni] > bestA) + { bestA = acc[ni]; bestN = ni; } + } + if (bestN < 0) break; + stem.Add(bestN); + c = bestN; + } + return stem; + } + + List<(float x, float y)> Decimate(List cells, int step = 4) + { + var pts = new List<(float, float)>(); + for (int i = 0; i < cells.Count; i += step) + pts.Add((cells[i] / n, cells[i] % n)); + if ((cells.Count - 1) % step != 0) + pts.Add((cells[^1] / n, cells[^1] % n)); + return pts; + } + + // --- 5b. Trunks: stems, mountain exits, LEAN tributaries --- + foreach (int outletCell in picked) + { + var t = new Trunk + { + Outlet = (outletCell / n, outletCell % n), + DrainageAreaPx = acc[outletCell] + }; + var stem = TraceStem(outletCell, p.StemMinAccPx); + t.Course = Decimate(stem); + t.Head = (stem[^1] / n, stem[^1] % n); + + // Mountain-exit: walk the stem downstream-first; the exit is the + // furthest-DOWNSTREAM point whose upstream window still sustains the + // grade — i.e. where the mountains hand the river to the flats. + // Elevation truth is the ORIGINAL eroded surface, not the fill. + int w = p.ExitWindowPx; + for (int i = 0; i + w < stem.Count; i++) + { + float rise = (original[stem[i + w]] - original[stem[i]]) * M_PER_UNIT; + if (rise / w >= p.ExitGradeMin) + { + t.ExitFound = true; + t.MountainExit = (stem[i] / n, stem[i] % n); + t.MountainExitElevM = original[stem[i]] * M_PER_UNIT; + break; + } + } + + // LEAN tributaries: junction branches off the stem with enough drainage, + // top few by accumulation. + 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)); + // Dedup: two inflow neighbours at adjacent stem cells are one confluence, + // not two tributaries — keep only junctions ≥ 30 px apart. + var taken = new List(); + foreach (var (cell, a) in cands) + { + if (taken.Count >= p.TributaryMaxPerTrunk) break; + int cx2 = cell / n, cy2 = cell % n; + bool dup = false; + foreach (int tc in taken) + { + float ddx = cx2 - tc / n, ddy = cy2 - tc % n; + if (ddx * ddx + ddy * ddy < 30f * 30f) { dup = true; break; } + } + if (!dup) taken.Add(cell); + } + foreach (int cell in taken) + { + long a = acc[cell]; + var trib = new Stream { DrainageAreaPx = a }; + var ts = TraceStem(cell, Math.Max(p.StemMinAccPx, (int)(a / 20))); + trib.Course = Decimate(ts); + trib.Head = (ts[^1] / n, ts[^1] % n); + t.Tributaries.Add(trib); + } + plan.Trunks.Add(t); + } + + // --- 5c. LEAN endorheic terminals: terminal basins ranked by TOTAL inflow --- + { + var terms = 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; // not a terminal basin + if (basinInflow[id] >= p.EndorheicMinInflowPx) terms.Add((id, basinInflow[id])); + } + terms.Sort((a, b) => b.inflow.CompareTo(a.inflow)); + foreach (var (id, inflow) in terms.GetRange(0, Math.Min(p.EndorheicMaxCount, terms.Count))) + { + int mc = basinMinCell[id]; + plan.Endorheics.Add(new EndorheicTerminal + { + Terminal = (mc / n, mc % n), + DrainageAreaPx = inflow, + BasinDepthM = basinDepthM[id], + BasinAreaPx = basinAreaPx[id] + }); + } + } + + return plan; + } +} diff --git a/Tools/Scripts/RiverPlanTool.cs b/Tools/Scripts/RiverPlanTool.cs new file mode 100644 index 0000000..0655556 --- /dev/null +++ b/Tools/Scripts/RiverPlanTool.cs @@ -0,0 +1,225 @@ +using Godot; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using IslaApocalypse.Core; + +/// +/// The river-plan tool (C0b part 1, terrain-water task 21). Headless, harness-style: +/// +/// 1. load an EROSION-ON blueprint through the real parser, +/// 2. run DrainageAnalysis over its (eroded) heightmap — pure analysis, +/// 3. print the full plan report to the console, +/// 4. write the plan as a JSON SIDECAR next to the source file. +/// +/// It never writes the blueprint. The sidecar is deliberately NOT a blueprint +/// section: sections are for realized world data, and this is a PLAN the developer +/// gates before part 2 carves anything — a plan that read as actual water would be +/// exactly the masquerade task 21 forbids. Part 2 owns the durable representation. +/// +/// Run: Godot --headless --path res://Tools/Scenes/RiverPlanTool.tscn +/// Env: RIVERPLAN_SRC (source .dat; default user://MapData_Seed_1280587109.dat), +/// RIVERPLAN_OUT (sidecar path; default /RiverPlan_Seed_.json), +/// RIVERPLAN_* dial overrides (see ReadParams). +/// Exit 0 = plan written, 1 = failure. +/// +public partial class RiverPlanTool : Node +{ + public override void _Ready() + { + bool ok = false; + try { ok = RunPlan(); } + catch (System.Exception e) { GD.PrintErr($"[RiverPlan] EXCEPTION: {e}"); } + GD.Print(ok ? "[RiverPlan] RESULT: PLAN WRITTEN" : "[RiverPlan] RESULT: FAIL"); + GetTree().Quit(ok ? 0 : 1); + } + + private static float EnvF(string k, float d) => + float.TryParse(OS.GetEnvironment(k), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : d; + private static int EnvI(string k, int d) => + int.TryParse(OS.GetEnvironment(k), out var v) ? v : d; + + private static DrainageAnalysis.Params ReadParams() + { + var p = new DrainageAnalysis.Params(); + p.EndorheicMinDepthM = EnvF("RIVERPLAN_ENDO_MIN_DEPTH_M", p.EndorheicMinDepthM); + p.EndorheicMinAreaPx = EnvI("RIVERPLAN_ENDO_MIN_AREA_PX", p.EndorheicMinAreaPx); + 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.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); + p.TributaryMaxPerTrunk = EnvI("RIVERPLAN_TRIB_MAX_PER_TRUNK", p.TributaryMaxPerTrunk); + p.ExitGradeMin = EnvF("RIVERPLAN_EXIT_GRADE_MIN", p.ExitGradeMin); + p.ExitWindowPx = EnvI("RIVERPLAN_EXIT_WINDOW_PX", p.ExitWindowPx); + p.SeaLevel = EnvF("RIVERPLAN_SEA_LEVEL", p.SeaLevel); + return p; + } + + private bool RunPlan() + { + string src = OS.GetEnvironment("RIVERPLAN_SRC"); + if (string.IsNullOrEmpty(src)) + src = ProjectSettings.GlobalizePath("user://MapData_Seed_1280587109.dat"); + GD.Print($"[RiverPlan] source blueprint: {src}"); + + ulong t0 = Time.GetTicksMsec(); + WorldBlueprint bp = MapDataParser.LoadMapDataFromPath(src); + if (bp == null) { GD.PrintErr("[RiverPlan] blueprint load failed."); return false; } + if (bp.Erosion == null) + GD.PrintErr("[RiverPlan] ⚠ source carries no EROS section — analysing an UNERODED " + + "surface; the plan will still compute but is not the C0b input the task means."); + ulong t1 = Time.GetTicksMsec(); + GD.Print($"[RiverPlan] loaded in {(t1 - t0) / 1000.0:F1}s " + + $"(seed {bp.Params?.WorldSeed}, {bp.MapSize}², erosion {(bp.Erosion != null ? $"v{bp.Erosion.Version}" : "ABSENT")})."); + + if (bp.WaterBodyIds == null) + { GD.PrintErr("[RiverPlan] source carries no WBID — cannot identify THE OCEAN; refusing."); return false; } + // THE OCEAN body (WBID == 1) is the only water that counts as "the sea": + // enclosed lagoons are depressions a river may legitimately END in, not + // destinations that make a trunk "sea-reaching". + int nn = bp.MapSize; + bool[] isOcean = 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; + + var p = ReadParams(); + var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, p); + ulong t2 = Time.GetTicksMsec(); + GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s."); + + // ---- console report ---- + GD.Print($"[RiverPlan] routing: {plan.LandCells} land cells; " + + $"{plan.SeaReachingCells} drain to sea ({100.0 * plan.SeaReachingCells / plan.LandCells:F1}%), " + + $"{plan.EndorheicCells} endorheic ({100.0 * plan.EndorheicCells / plan.LandCells:F1}%), " + + $"{plan.UnroutedCells} unrouted (should be ~0)."); + GD.Print($"[RiverPlan] depressions: {plan.PitsFilledCount} pits filled through for routing, " + + $"{plan.TerminalBasinCount} qualified as terminal basins " + + $"(depth ≥ {p.EndorheicMinDepthM} m and area ≥ {p.EndorheicMinAreaPx} px)."); + GD.Print("[RiverPlan] top outlets by drainage area (pre-separation):"); + foreach (var (x, y, a) in plan.AllOutletsTop) + GD.Print($"[RiverPlan] ({x},{y}) {a} px"); + int ti = 0; + foreach (var t in plan.Trunks) + { + ti++; + GD.Print($"[RiverPlan] TRUNK {ti}: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}), " + + $"drainage {t.DrainageAreaPx} px, stem {t.Course.Count * 4} px, " + + (t.ExitFound + ? $"mountain-exit ({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m" + : "mountain-exit NOT FOUND (stem never sustains the exit grade)") + + $", {t.Tributaries.Count} tributaries."); + foreach (var tr in t.Tributaries) + GD.Print($"[RiverPlan] trib: joins near head ({tr.Course[0].x:F0},{tr.Course[0].y:F0}), " + + $"drainage {tr.DrainageAreaPx} px"); + } + foreach (var e in plan.Endorheics) + { + ushort wb = bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]; + GD.Print($"[RiverPlan] ENDORHEIC terminal ({e.Terminal.x:F0},{e.Terminal.y:F0}): " + + $"drainage {e.DrainageAreaPx} px into a basin {e.BasinDepthM:F1} m deep, {e.BasinAreaPx} px" + + (wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + "."); + } + + // ---- the southern-town report (filed fact, not a constraint) ---- + if (bp.Towns.Count > 0) + { + TownLocation south = bp.Towns[0]; + foreach (var t in bp.Towns) + if (t.Position.Y > south.Position.Y) south = t; + GD.Print($"[RiverPlan] southernmost town: tier {south.Tier} at " + + $"({south.Position.X:F0},{south.Position.Y:F0})."); + ti = 0; + foreach (var t in plan.Trunks) + { + ti++; + float best = float.MaxValue; + foreach (var (x, y) in t.Course) + { + float dx = x - south.Position.X, dy = y - south.Position.Y; + float d2 = dx * dx + dy * dy; + if (d2 < best) best = d2; + } + GD.Print($"[RiverPlan] SOUTH REPORT trunk {ti}: outlet y={t.Outlet.y:F0} " + + $"({(t.Outlet.y > bp.MapSize * 0.55f ? "southern" : t.Outlet.y < bp.MapSize * 0.45f ? "northern" : "central")} coast); " + + $"course passes {Mathf.Sqrt(best):F0} px from the southernmost town."); + } + } + + // ---- JSON sidecar ---- + string outPath = OS.GetEnvironment("RIVERPLAN_OUT"); + if (string.IsNullOrEmpty(outPath)) + outPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(src) ?? ".", + $"RiverPlan_Seed_{bp.Params?.WorldSeed}.json"); + System.IO.File.WriteAllText(outPath, ToJson(bp, plan)); + GD.Print($"[RiverPlan] plan sidecar written: {outPath}"); + return true; + } + + // Hand-rolled, invariant-culture JSON for a fixed schema — deterministic output, + // no serializer reflection surprises. + private static string ToJson(WorldBlueprint bp, DrainageAnalysis.Plan plan) + { + var ci = CultureInfo.InvariantCulture; + var sb = new StringBuilder(1 << 20); + void Pt(StringBuilder b, (float x, float y) v) => + b.Append('[').Append(v.x.ToString("F1", ci)).Append(',').Append(v.y.ToString("F1", ci)).Append(']'); + void Course(List<(float x, float y)> c) + { + sb.Append('['); + for (int i = 0; i < c.Count; i++) { if (i > 0) sb.Append(','); Pt(sb, c[i]); } + sb.Append(']'); + } + sb.Append("{\n\"_WARNING\": \"RIVER *PLAN* — analysis output for the task-21 gate. "); + sb.Append("Nothing here is realized water or terrain. Part 2 (task 22) consumes this; "); + sb.Append("nothing at runtime may read it as water.\",\n"); + sb.Append($"\"seed\": {bp.Params?.WorldSeed ?? 0}, \"mapSize\": {bp.MapSize},\n"); + var p = plan.P; + sb.Append($"\"params\": {{\"endoMinDepthM\": {p.EndorheicMinDepthM.ToString(ci)}, "); + sb.Append($"\"endoMinAreaPx\": {p.EndorheicMinAreaPx}, \"endoMinInflowPx\": {p.EndorheicMinInflowPx}, "); + sb.Append($"\"endoMaxCount\": {p.EndorheicMaxCount}, \"trunkCount\": {p.TrunkCount}, "); + sb.Append($"\"minOutletSeparationPx\": {p.MinOutletSeparationPx}, \"stemMinAccPx\": {p.StemMinAccPx}, "); + sb.Append($"\"tribMinAccPx\": {p.TributaryMinAccPx}, \"tribMaxPerTrunk\": {p.TributaryMaxPerTrunk}, "); + sb.Append($"\"exitGradeMin\": {p.ExitGradeMin.ToString(ci)}, \"exitWindowPx\": {p.ExitWindowPx}, "); + sb.Append($"\"seaLevel\": {p.SeaLevel.ToString(ci)}}},\n"); + sb.Append($"\"routing\": {{\"landCells\": {plan.LandCells}, \"seaReaching\": {plan.SeaReachingCells}, "); + sb.Append($"\"endorheic\": {plan.EndorheicCells}, \"unrouted\": {plan.UnroutedCells}, "); + sb.Append($"\"pitsFilled\": {plan.PitsFilledCount}, \"terminalBasins\": {plan.TerminalBasinCount}}},\n"); + sb.Append("\"trunks\": [\n"); + for (int i = 0; i < plan.Trunks.Count; i++) + { + var t = plan.Trunks[i]; + sb.Append(" {\"outlet\": "); Pt(sb, t.Outlet); + sb.Append($", \"drainageAreaPx\": {t.DrainageAreaPx}, \"exitFound\": {(t.ExitFound ? "true" : "false")}, "); + sb.Append("\"mountainExit\": "); Pt(sb, t.MountainExit); + sb.Append($", \"mountainExitElevM\": {t.MountainExitElevM.ToString("F1", ci)},\n \"course\": "); + Course(t.Course); + sb.Append(",\n \"tributaries\": ["); + for (int j = 0; j < t.Tributaries.Count; j++) + { + var tr = t.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.Trunks.Count - 1) sb.Append(','); + sb.Append('\n'); + } + sb.Append("],\n\"endorheics\": ["); + for (int i = 0; i < plan.Endorheics.Count; i++) + { + var e = plan.Endorheics[i]; + if (i > 0) sb.Append(','); + sb.Append("\n {\"terminal\": "); Pt(sb, e.Terminal); + sb.Append($", \"drainageAreaPx\": {e.DrainageAreaPx}, "); + sb.Append($"\"basinDepthM\": {e.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {e.BasinAreaPx}, "); + sb.Append($"\"terminalWbid\": {bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]}}}"); + } + sb.Append("\n]\n}\n"); + return sb.ToString(); + } +}