From b5ceca04190d4dd56da47bfca0461f6ec8430ab1 Mon Sep 17 00:00:00 2001 From: beezm Date: Mon, 24 Aug 2026 22:00:22 -0400 Subject: [PATCH] =?UTF-8?q?rivers/03b:=20routing=20refinement=20=E2=80=94?= =?UTF-8?q?=20three=20deliberate=20divergences=20from=20the=20faithful=20p?= =?UTF-8?q?ort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOT a port. Each fix corrects a faithful reference behaviour that produced a physically-wrong result, on the developer's explicit call. basinHasLake is KEPT as the sort. Courses only: no height mutated, no water filled or created — asserted per seed by a raw-bit digest of both height fields. - FIX 1 rim cap (ISLA_RIM_CAP_M, default 30 m): a route to the sea that must climb higher than this above its terminal is refused; the river ends at its own terminal. Reference routes at any cost (rivers/03 found a 66.7 m one). - FIX 3 lake targets: a router stops at the nearer of {ocean, significant lake}, so it cannot skirt a lake to reach a distant coast. Reference targets ocean only. - FIX 2 confluence: courses laid biggest-first join on TRUE cell intersection (never proximity); the smaller becomes a tributary and adopts the bigger one's downstream and terminus. Reference lays routes independently — rivers/03 found two rivers at the identical ocean cell on every seed. All three are off by default (RiverRouting.Options.Faithful), so rivers/03 still reproduces bit-for-bit from the same tool. Taste gate: nothing locked, nothing graduated. --- Tools/Scripts/DrainageRenderer.cs | 174 ++++++++++++++++++ Tools/Scripts/RiverRouting.cs | 282 ++++++++++++++++++++++++++++-- Tools/Scripts/RiverRoutingTool.cs | 270 +++++++++++++++++++++++++--- 3 files changed, 688 insertions(+), 38 deletions(-) diff --git a/Tools/Scripts/DrainageRenderer.cs b/Tools/Scripts/DrainageRenderer.cs index 926ad5c..a1631a4 100644 --- a/Tools/Scripts/DrainageRenderer.cs +++ b/Tools/Scripts/DrainageRenderer.cs @@ -520,6 +520,180 @@ namespace IslaApocalypse.Tools return img; } + // ═══ ⭐⭐ THE REFINED MIX (rivers/03b) — five classes, and the dendritic tree ═══════════════ + // + // Same base, same colours where they carry over, and the SAME fixed width scale as rivers/02b + // and rivers/03, so this plate can be laid beside `03_lowland_routing//routed_mix.png` and + // read as a before/after rather than as two different pictures. + // + // `RoutedMix` above is left exactly as rivers/03 produced it — that batch stays reproducible. + + /// ⭐ rivers/03b: a router that stopped at a significant lake instead of skirting it. + private static readonly Color LakeFedStem = new(0.520f, 0.380f, 0.780f); + private static readonly Color LakeFedReach = new(0.720f, 0.560f, 1.000f); + /// ⚠ rivers/03b: refused by the rim cap — it would have been an uphill river. + private static readonly Color Walled = new(0.950f, 0.330f, 0.330f); + /// Where two courses actually meet. + private static readonly Color Junction = new(1.000f, 1.000f, 1.000f); + + private static (Color stem, Color reach) ClassColours(RiverRouting.RiverClass c) => c switch + { + RiverRouting.RiverClass.OceanTrunk => (Trunk, Trunk), + RiverRouting.RiverClass.RoutedGiant => (RoutedStem, RoutedReach), + RiverRouting.RiverClass.LakeFed => (LakeFedStem, LakeFedReach), + RiverRouting.RiverClass.WalledOff => (Walled, Walled), + _ => (Giant, Giant), + }; + + /// + /// ⭐⭐ THE RESHAPED MIX — natural trunks, routed-through, lake-fed, natural lake-enders and + /// walled-off lake-enders, drawn as a dendritic TREE rather than as independent courses. + /// + /// Each river draws only its OWN reach — truncated at its confluence junction if it joined one — + /// so tributaries merge into a single downstream line instead of running as parallel duplicates. + /// A white dot marks every junction. Within a river, the natural upland stem is drawn in the + /// muted tone and the lowland reach routing added in the bright one, exactly as rivers/03. + /// + public static Image RefinedMix(List rivers, Image img, int n, + string title, string subtitle, string capLine) + { + if (rivers.Count == 0) return img; + int mark = n >= 4096 ? 18 : 10; + + var byArea = new List(rivers); + byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx)); + + // Smallest first, so the biggest rivers finish on top. + for (int i = byArea.Count - 1; i >= 0; i--) + { + var r = byArea[i]; + int w = StemWidthFixed(r.Candidate.DrainagePx); + var (stemCol, reachCol) = ClassColours(r.Class); + var cells = r.CellPath; + if (cells == null || cells.Count == 0) continue; + + // Its OWN reach: everything up to the junction, or the whole course if it kept its mouth. + int own = r.Joined ? OwnLength(r) : cells.Count; + int stemEnd = Math.Min(own, Math.Max(1, r.StemCells)); + + Polyline(img, Slice(cells, 0, stemEnd), n, stemCol, w); + if (own > stemEnd) Polyline(img, Slice(cells, stemEnd - 1, own), n, reachCol, w); + } + + // Terminus markers — read through the CONFLUENCE ROOT, because a tributary's mouth is its + // trunk's mouth and marking its own truncated end would invent a terminus it does not have. + foreach (var r in byArea) + { + var c = r.Candidate; + if (r.Joined) + { + Disc(img, r.JunctionCell.x, r.JunctionCell.y, Math.Max(4, mark / 2), n, Junction); + continue; + } + var (stemCol, reachCol) = ClassColours(r.Class); + switch (r.Class) + { + case RiverRouting.RiverClass.OceanTrunk: + Square(img, c.TermX, c.TermY, mark, n, Trunk); + break; + case RiverRouting.RiverClass.RoutedGiant: + if (r.Lowland != null && r.Lowland.Reached) + { + var t = r.Lowland.Target; + Square(img, (int)t.x, (int)t.y, mark, n, reachCol); + Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3); + MarkRim(img, r.Lowland, n, mark); + } + Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4); + break; + case RiverRouting.RiverClass.LakeFed: + if (r.Lowland != null && r.Lowland.Reached) + { + var t = r.Lowland.Target; + Disc(img, (int)t.x, (int)t.y, mark, n, reachCol); + Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3); + } + Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4); + break; + case RiverRouting.RiverClass.WalledOff: + // ⚠ It ends at its own terminal. A cross-less ring plus the rim it could not clear. + Disc(img, c.TermX, c.TermY, mark, n, Walled); + Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); + break; + default: + Disc(img, c.TermX, c.TermY, mark, n, Giant); + Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); + if (r.Lowland != null && r.Lowland.Reached) + Ring(img, (int)r.Lowland.Target.x, (int)r.Lowland.Target.y, mark, n, Giant, 4); + break; + } + } + + // ---- labels ---- + int ls = n >= 4096 ? 4 : 3; + var placer = new LabelPlacer(n, ls, headerLines: 9); + int dropped = 0; + foreach (var r in byArea) + { + var c = r.Candidate; + var (stemCol, reachCol) = ClassColours(r.Class); + string tag = r.Class switch + { + RiverRouting.RiverClass.OceanTrunk => "TRUNK", + RiverRouting.RiverClass.RoutedGiant => $"SEA RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M", + RiverRouting.RiverClass.LakeFed => "LAKE-FED", + RiverRouting.RiverClass.WalledOff => $"WALLED {r.CappedRimM:F0}M", + _ => "LAKE", + }; + if (r.Joined) tag += $" INTO R{r.ConfluenceParentRank}"; + int lx = r.Joined ? r.JunctionCell.x : c.TermX; + int ly = r.Joined ? r.JunctionCell.y : c.TermY; + if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank} {tag}", lx, ly, mark, + r.Joined ? Junction : reachCol)) dropped++; + } + + int trunks = 0, routed = 0, lakeFed = 0, natural = 0, walled = 0, joined = 0; + foreach (var r in byArea) + { + switch (r.Class) + { + case RiverRouting.RiverClass.OceanTrunk: trunks++; break; + case RiverRouting.RiverClass.RoutedGiant: routed++; break; + case RiverRouting.RiverClass.LakeFed: lakeFed++; break; + case RiverRouting.RiverClass.WalledOff: walled++; break; + default: natural++; break; + } + if (r.Joined) joined++; + } + int s2 = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s2) + 6; + TinyFont.Draw(img, title, 12, 12, s2, Ink); + TinyFont.Draw(img, subtitle, 12, 12 + lh, s2, Ink); + TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) GREEN: ROUTED THROUGH TO THE SEA ({routed}) - DARK = NATURAL STEM, BRIGHT = THE REACH ROUTING ADDED", 12, 12 + lh * 2, s2, Trunk); + TinyFont.Draw(img, $"VIOLET: LAKE-FED ({lakeFed}) - A DRY BASIN THAT MET A SIGNIFICANT LAKE BEFORE THE SEA AND STOPS THERE (FIX 3)", 12, 12 + lh * 3, s2, LakeFedReach); + TinyFont.Draw(img, $"RED: WALLED OFF ({walled}) - {capLine} (FIX 1)", 12, 12 + lh * 4, s2, Walled); + TinyFont.Draw(img, $"ORANGE: NATURAL LAKE-ENDER ({natural}) - ITS BASIN ALREADY HOLDS A LAKE, SO ITS RIVER FEEDS IT", 12, 12 + lh * 5, s2, Giant); + TinyFont.Draw(img, $"WHITE DOT: CONFLUENCE ({joined} JOINED) - A TRIBUTARY MERGING INTO A BIGGER RIVER, NOT A PARALLEL DUPLICATE (FIX 2)", 12, 12 + lh * 6, s2, Junction); + TinyFont.Draw(img, $"YELLOW RING = THE RIM A ROUTED RIVER CLIMBED OVER. WIDTH: {StemWidthLaw()} - AS RIVERS/02B AND 03", 12, 12 + lh * 7, s2, RimMark); + TinyFont.Draw(img, "COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NOTHING CARVED. PROVISIONALROUTE NOT DRAWN." + + (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 8, s2, Ink); + return img; + } + + /// How many leading cells of a joined river's path are its own, up to the junction. + private static int OwnLength(RiverRouting.RoutedRiver r) + { + for (int i = 0; i < r.CellPath.Count; i++) + if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1; + return r.CellPath.Count; + } + + private static List<(float x, float y)> Slice(List<(int x, int y)> cells, int from, int to) + { + var outp = new List<(float x, float y)>(); + for (int i = Math.Max(0, from); i < Math.Min(to, cells.Count); i++) outp.Add((cells[i].x, cells[i].y)); + return outp; + } + /// Ring the route's high point — the rim the channel crosses. private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark) { diff --git a/Tools/Scripts/RiverRouting.cs b/Tools/Scripts/RiverRouting.cs index b1ac24b..3b0f963 100644 --- a/Tools/Scripts/RiverRouting.cs +++ b/Tools/Scripts/RiverRouting.cs @@ -262,6 +262,48 @@ namespace IslaApocalypse.Tools RoutedGiant, /// Stays inland: terminates at a significant lake, or at its own terminal. LakeEnder, + + // ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══ + + /// ⭐ rivers/03b (fix 3): a dry-basin router that reached a SIGNIFICANT LAKE before it + /// reached the sea, and terminates there. In the reference a router targets ocean only, so it + /// would skirt the lake and carry on — which is what this corrects. **No water is created.** + LakeFed, + + /// ⚠ rivers/03b (fix 1): a dry-basin router whose cheapest route to the sea had to + /// climb a rim HIGHER THAN THE CAP. The reference routes at any cost, which produced an + /// uphill river over a 66.7 m wall. Refused: the course ends at its own terminal — a real + /// terminal basin. **Nothing is filled; it just ends there.** + WalledOff, + } + + /// + /// ⚠⚠ THE THREE DELIBERATE DIVERGENCES FROM THE REFERENCE (rivers/03b), off by default. + /// + /// Defaults reproduce rivers/03's faithful port EXACTLY — no cap, ocean-only targets, no + /// confluence — so that batch stays re-runnable bit-for-bit. The refinement task turns them on. + /// **None of these is a port. Each is a motivated correction of a faithful behaviour that + /// produced a physically-wrong result**, on the developer's explicit call. + /// + public sealed class Options + { + /// ⭐ FIX 1 — the rim cap, metres. A route to the sea that must climb higher than + /// this above its terminal is refused and the river becomes a walled-off lake-ender. + /// Infinity = the reference's behaviour (route at any cost). + public float RimCapM = float.PositiveInfinity; + + /// ⭐ FIX 3 — include significant lakes in a ROUTER's target mask, so a river stops + /// at the nearer of {ocean, significant lake} instead of skirting a lake to reach the sea. + /// False = the reference's behaviour (routers target ocean only). + public bool LakeTargetForRouters; + + /// ⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell + /// intersection instead of running as parallel duplicates to the same mouth. + /// False = the reference's behaviour (no dedup, no join). + public bool Confluence; + + /// rivers/03's faithful settings — every divergence off. + public static Options Faithful => new(); } /// One promoted river, classified, routed and assembled. @@ -281,6 +323,29 @@ namespace IslaApocalypse.Tools public List<(float x, float y)> Course; public string Why = ""; + // ═══ rivers/03b ═══ + + /// ⚠ The rim climb that was tested against the cap, and whether it was refused. + public float CappedRimM; + public bool RefusedByCap; + /// The class this river WOULD have had under the reference's rules — so every + /// reclassification the divergences caused is legible rather than silent. + public RiverClass FaithfulClass; + + /// The full course rasterised to cells — what the confluence test intersects on. + public List<(int x, int y)> CellPath; + /// ⭐ What this river draws: its OWN reach, truncated at its junction if it joined. + /// The union of every river's own reach is the dendritic tree. + public List<(float x, float y)> OwnPath; + /// The rank of the river this one flows into, or 0 if it keeps its own terminus. + public int ConfluenceParentRank; + public bool Joined; + public (int x, int y) JunctionCell; + /// How many leading cells of are the NATURAL upland stem. + /// Everything after is the lowland reach routing added — the plate colours the two apart. + public int StemCells; + + /// Does this river's own course end at the sea, before any confluence? public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant; } @@ -313,8 +378,22 @@ namespace IslaApocalypse.Tools /// public static List RouteAll(List promoted, float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style, - Action log) + Action log, Options opt = null) { + opt ??= Options.Faithful; + + // ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which + // is the reference. With it on, a significant lake is an equally valid place for a river to + // stop, so the Dijkstra halts at whichever it reaches first and a river can no longer skirt + // a lake on its way to a distant coast. + bool[] routerTargets = isOcean; + if (opt.LakeTargetForRouters) + { + routerTargets = new bool[n * n]; + for (int i = 0; i < routerTargets.Length; i++) + routerTargets[i] = isOcean[i] || isSignificantWater[i]; + } + var outp = new List(); foreach (var c in promoted) { @@ -333,18 +412,54 @@ namespace IslaApocalypse.Tools continue; } - // ⭐ The ocean probe, for every giant — the affordability evidence. + // ⭐ The ocean probe, for every giant — the affordability evidence (rivers/03). var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea); rr.OceanProbe = probe; + // ⚠ `basinHasLake` is KEPT as the sort (rivers/03's finding): a basin that already holds a + // visible lake is a natural lake-ender and its river feeds its own lake — it is not routed + // anywhere. Only DRY basins are candidate routers. None of the three divergences touches this. bool refLakeEnder = c.AnalysisKind == "lake-ender"; if (!refLakeEnder) { - rr.Class = RiverClass.RoutedGiant; - rr.Lowland = probe; - rr.Why = probe.Reached - ? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}" - : "dry pan → routed, but NO path to the ocean was found (unexpected — report)"; + // Route to the nearest of the target mask — {ocean} faithfully, {ocean ∪ lakes} refined. + var route = opt.LakeTargetForRouters + ? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea) + : probe; + rr.FaithfulClass = RiverClass.RoutedGiant; + rr.Lowland = route; + rr.CappedRimM = route.Reached ? route.RimClimbM : 0f; + + bool stoppedAtLake = route.Reached + && isSignificantWater[(int)route.Target.x * n + (int)route.Target.y] + && !isOcean[(int)route.Target.x * n + (int)route.Target.y]; + + if (!route.Reached) + { + rr.Class = RiverClass.RoutedGiant; + rr.Why = "dry pan → routed, but NO path to a target was found (unexpected — report)"; + } + else if (stoppedAtLake) + { + // ⭐ FIX 3 — it met a significant lake first. It terminates there. NO WATER CREATED: + // the course simply ends at an existing body. + rr.Class = RiverClass.LakeFed; + rr.Why = $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)"; + } + else if (route.RimClimbM > opt.RimCapM) + { + // ⭐ FIX 1 — the cheapest way to the sea still climbs a wall. Refuse it. The course + // ends at its own terminal, a real terminal basin. NOTHING IS FILLED. + rr.Class = RiverClass.WalledOff; + rr.RefusedByCap = true; + rr.Lowland = null; + rr.Why = $"dry pan → WALLED OFF: cheapest route to the sea climbs {route.RimClimbM:F1} m > cap {opt.RimCapM:F0} m (cost {route.Cost:N0}) — ends at its own terminal"; + } + else + { + rr.Class = RiverClass.RoutedGiant; + rr.Why = $"dry pan → routed to the sea; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, max step {route.MaxStepUphillM:F2} m, cost {route.Cost:N0}"; + } } else { @@ -362,17 +477,160 @@ namespace IslaApocalypse.Tools if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; } } if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; } + rr.FaithfulClass = RiverClass.LakeEnder; rr.Why = rr.LakeReached - ? $"terminal basin holds classify water → lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away" - : "terminal basin holds classify water → lake-ender; no water body reachable, course ends at its terminal"; + ? $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away" + : "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal"; } rr.Course = Assemble(c.Course, rr.Lowland); outp.Add(rr); - log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " + - $"probe{(probe.Reached ? $" reached cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2} expanded {probe.Expanded:N0}" : " NO PATH")}" + - $"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}"); + log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " + + $"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" + + $"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" + + $"{(rr.RefusedByCap ? " ⚠ REFUSED BY CAP" : "")}" + + $"{(rr.Class == RiverClass.LakeFed ? " ⭐ stopped at a lake, not the coast" : "")}"); } + + if (opt.Confluence) Confluence(outp, log); + else foreach (var rr in outp) rr.OwnPath = rr.Course; + return outp; + } + + public static string ClassLabel(RiverClass c) => c switch + { + RiverClass.OceanTrunk => "TRUNK", + RiverClass.RoutedGiant => "ROUTED", + RiverClass.LakeFed => "LAKE-FED", + RiverClass.WalledOff => "WALLED-OFF", + _ => "LAKE-ENDER", + }; + + // ═══ ⭐⭐ FIX 2 — THE CONFLUENCE POST-PASS (rivers/03b) ═══════════════════════════════════ + // + // ⚠⚠ A DIVERGENCE, NOT A PORT. The reference lays every route independently and never dedups or + // joins them, which rivers/03 measured: on EVERY seed two routed rivers arrived at the identical + // ocean cell without ever having met. Two channels reaching the same mouth as parallel + // duplicates is not geography; two channels that meet and continue as one is. + // + // The rule, deliberately strict: courses are laid BIGGEST-FIRST by drainage, and a later course + // joins an earlier one only on TRUE CELL INTERSECTION — the later course's rasterised cell path + // actually reaching a cell an earlier one occupies. **Never proximity.** Two rivers running 3 px + // apart down the same valley stay two rivers; that is a question for the carve's channel width, + // not for routing to guess at. + + /// + /// Join intersecting courses into a dendritic tree. Biggest-first, so the largest drainage is + /// the trunk and smaller ones become its tributaries — the later river is truncated at the + /// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's + /// downstream and terminus from there. + /// + private static void Confluence(List rivers, Action log) + { + var order = new List(rivers); + order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx)); + + // cell -> (the river occupying it, and how far along that river's cell path it sits) + var owner = new Dictionary<(int x, int y), (RoutedRiver river, int idx)>(); + int joins = 0; + + foreach (var r in order) + { + r.CellPath = Rasterise(r.Course); + var stemOnly = new List<(float x, float y)>(r.Candidate.Course); + stemOnly.Reverse(); + r.StemCells = Rasterise(stemOnly).Count; + if (r.CellPath.Count == 0) { r.OwnPath = r.Course; continue; } + + // The first cell of THIS course that someone bigger already occupies. + int hit = -1; + (RoutedRiver river, int idx) into = default; + for (int i = 0; i < r.CellPath.Count; i++) + if (owner.TryGetValue(r.CellPath[i], out into)) { hit = i; break; } + + if (hit < 0) + { + // Keeps its own route and its own mouth. + r.OwnPath = r.Course; + for (int i = 0; i < r.CellPath.Count; i++) + if (!owner.ContainsKey(r.CellPath[i])) owner[r.CellPath[i]] = (r, i); + continue; + } + + // ⭐ It joins. Truncate here and adopt the parent's downstream from the junction on. + var parent = into.river; + r.Joined = true; + r.ConfluenceParentRank = parent.Candidate.Rank; + r.JunctionCell = r.CellPath[hit]; + joins++; + + // What it DRAWS is its own reach only, up to the junction — the union of every river's + // own reach is the tree. Drawing the adopted downstream too would just overdraw the parent. + r.OwnPath = new List<(float x, float y)>(); + for (int i = 0; i <= hit; i++) r.OwnPath.Add((r.CellPath[i].x, r.CellPath[i].y)); + + // The full course of record: its own reach, then the parent's from the junction to the sea. + var full = new List<(int x, int y)>(); + for (int i = 0; i <= hit; i++) full.Add(r.CellPath[i]); + for (int i = into.idx + 1; i < parent.CellPath.Count; i++) full.Add(parent.CellPath[i]); + r.CellPath = full; + r.Course = new List<(float x, float y)>(); + foreach (var cpt in full) r.Course.Add((cpt.x, cpt.y)); + + // Only its OWN reach becomes occupiable, so a third river can join this tributary. + for (int i = 0; i <= hit; i++) + if (!owner.ContainsKey(full[i])) owner[full[i]] = (r, i); + + log($" ⭐ CONFLUENCE: #{r.Candidate.Rank} ({r.Candidate.DrainagePx:N0} px) joins #{parent.Candidate.Rank} " + + $"({parent.Candidate.DrainagePx:N0} px) at ({r.JunctionCell.x},{r.JunctionCell.y}) — " + + $"{hit} px of its own reach, then adopts #{parent.Candidate.Rank}'s downstream and terminus"); + } + if (joins == 0) log(" (no confluences — every course keeps its own mouth)"); + } + + /// + /// ⭐ THE ROOT of a confluence chain — the river whose terminus this one actually ends at. A + /// tributary's mouth is its trunk's mouth, so this is what mouth counting and terminus class + /// must both be read through. + /// + public static RoutedRiver Root(RoutedRiver r, List all) + { + var cur = r; + // The chain is finite and strictly increasing in drainage (biggest-first laying), so it + // cannot cycle; the guard is belt-and-braces against a future change to the ordering. + for (int guard = 0; guard < all.Count + 1 && cur.Joined; guard++) + { + RoutedRiver parent = null; + foreach (var o in all) if (o.Candidate.Rank == cur.ConfluenceParentRank) { parent = o; break; } + if (parent == null) break; + cur = parent; + } + return cur; + } + + /// + /// Rasterise a polyline to a deduped 1-px cell path. ⚠ The confluence test is a TRUE CELL + /// intersection, so the courses must be compared as the cells they occupy, not as the sparse + /// vertices the analysis decimated them to (stems are decimated ×4, routes are Chaikin-smoothed). + /// + private static List<(int x, int y)> Rasterise(List<(float x, float y)> pts) + { + var outp = new List<(int x, int y)>(); + if (pts == null || pts.Count == 0) return outp; + void Push(int x, int y) + { + if (outp.Count > 0 && outp[^1].x == x && outp[^1].y == y) return; + outp.Add((x, y)); + } + for (int i = 0; i + 1 < pts.Count; i++) + { + var a = pts[i]; var b = pts[i + 1]; + float dx = b.x - a.x, dy = b.y - a.y; + int steps = Math.Max(1, (int)MathF.Ceiling(MathF.Max(MathF.Abs(dx), MathF.Abs(dy)))); + for (int s = 0; s < steps; s++) + Push((int)MathF.Round(a.x + dx * s / steps), (int)MathF.Round(a.y + dy * s / steps)); + } + Push((int)MathF.Round(pts[^1].x), (int)MathF.Round(pts[^1].y)); return outp; } diff --git a/Tools/Scripts/RiverRoutingTool.cs b/Tools/Scripts/RiverRoutingTool.cs index 3d9638b..41ed2ba 100644 --- a/Tools/Scripts/RiverRoutingTool.cs +++ b/Tools/Scripts/RiverRoutingTool.cs @@ -69,7 +69,15 @@ namespace IslaApocalypse.Tools public int Seed; public List Rivers; public int Trunks, Routed, Lakes; + /// rivers/03b classes. + public int LakeFed, Walled, Joined; + /// Rivers whose OWN course ends at the sea (tributaries excluded — they have no mouth). public int SeaReaching; + /// ⭐ Rivers whose water reaches the sea, counting tributaries through their trunk. + public int SeaConnected; + public List CapMoved = new(); + public List LakeMoved = new(); + public List Joins = new(); /// ⭐ DISTINCT ocean mouth cells — routes are computed per giant with nothing /// coordinating them, so two can land on the same cell. This is the honest river count. public int DistinctMouths; @@ -90,8 +98,18 @@ namespace IslaApocalypse.Tools ToolingPaths.Configure(OS.GetUserDataDir()); ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers")); + // ⭐ rivers/03b — the three approved DIVERGENCES. Default OFF, so this tool still reproduces + // rivers/03's faithful port bit-for-bit. + bool refined = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant() == "refined"; + float rimCapM = float.TryParse(EnvStr("ISLA_RIM_CAP_M", "30"), out float rc) ? rc : 30f; + var opt = refined + ? new RiverRouting.Options { RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true } + : RiverRouting.Options.Faithful; + int task = EnvInt("ISLA_TASK", 3); - string descr = EnvStr("ISLA_BATCH", "lowland_routing"); + // ⭐ rivers/03b is a LETTERED SUB-TASK of 03 — same authoring task, three changed rules. + string taskSfx = EnvStr("ISLA_TASK_SUFFIX", refined ? "b" : ""); + string descr = EnvStr("ISLA_BATCH", refined ? "routing_refinement" : "lowland_routing"); int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); @@ -114,7 +132,7 @@ namespace IslaApocalypse.Tools TerrainShapeV1.Assert("RiverRouting"); TerrainShapeV1.AssertErosionDefaultOn("RiverRouting"); - string batchRoot = ToolingPaths.BatchRoot(task, descr); + string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); @@ -135,7 +153,9 @@ namespace IslaApocalypse.Tools var dpDefaults = new DrainageAnalysis.Params(); GD.Print("=================================================================="); - GD.Print(" LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY"); + GD.Print(refined + ? " ROUTING REFINEMENT (rivers/03b) — three DELIBERATE DIVERGENCES from the faithful port, COURSES ONLY" + : " LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY"); GD.Print("=================================================================="); GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}"); GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default"); @@ -146,6 +166,17 @@ namespace IslaApocalypse.Tools GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so"); GD.Print($" \"a path exists → routed\" would classify everything as routed. The ocean route is"); GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess."); + if (refined) + { + GD.Print("⚠⚠ THREE DELIBERATE DIVERGENCES FROM THE REFERENCE — approved, and NOT a port:"); + GD.Print($" FIX 1 RIM CAP {rimCapM:F0} m — a route to the sea that must climb higher than this above its"); + GD.Print( " terminal is REFUSED; the river becomes a walled-off inland lake-ender. (Reference: routes at ANY cost.)"); + GD.Print( " FIX 3 LAKE TARGETS — a router stops at the nearer of {ocean, significant lake}, so it cannot"); + GD.Print( " skirt a lake to reach a distant coast. (Reference: routers target ocean only.)"); + GD.Print( " FIX 2 CONFLUENCE — courses laid biggest-first join on TRUE CELL INTERSECTION and the smaller"); + GD.Print( " becomes a tributary. (Reference: no dedup, no join — parallel duplicates to one mouth.)"); + GD.Print( " ⚠ KEPT: basinHasLake stays the sort — a basin that already holds a lake is a natural lake-ender."); + } GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)"); GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed."); GD.Print($"batch : {batchRoot}"); @@ -208,7 +239,7 @@ namespace IslaApocalypse.Tools GD.Print($" routing (style {styleS}) — probing the ocean for every giant:"); ulong tr0 = Time.GetTicksMsec(); var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater, - significant, sea, style, m => GD.Print(m)); + significant, sea, style, m => GD.Print(m), opt); double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0; // ═══ ⛔ …and assert they are byte-identical after ═══ @@ -235,28 +266,48 @@ namespace IslaApocalypse.Tools }; foreach (var rr in rivers) { - if (rr.Class == RiverRouting.RiverClass.OceanTrunk) r.Trunks++; - else if (rr.Class == RiverRouting.RiverClass.RoutedGiant) r.Routed++; - else r.Lakes++; + switch (rr.Class) + { + case RiverRouting.RiverClass.OceanTrunk: r.Trunks++; break; + case RiverRouting.RiverClass.RoutedGiant: r.Routed++; break; + case RiverRouting.RiverClass.LakeFed: r.LakeFed++; break; + case RiverRouting.RiverClass.WalledOff: r.Walled++; break; + default: r.Lakes++; break; + } if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded; + if (rr.Joined) { r.Joined++; r.Joins.Add($"#{rr.Candidate.Rank}→#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})"); } + // ⭐ Make every reclassification the divergences caused legible, not silent. + if (rr.RefusedByCap) r.CapMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px, rim {rr.CappedRimM:F1} m)"); + if (rr.Class == RiverRouting.RiverClass.LakeFed) r.LakeMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px)"); + } + // ⚠ A tributary has NO mouth of its own — it reaches the sea through its trunk. So the two + // numbers are different and both are reported: how many rivers END at the sea, and how many + // rivers' water GETS there. + foreach (var rr in rivers) + { + if (!rr.Joined && rr.ReachesSea) r.SeaReaching++; + if (RiverRouting.Root(rr, rivers).ReachesSea) r.SeaConnected++; } - r.SeaReaching = r.Trunks + r.Routed; MeasureMouths(r, rivers); r.Spread = Spread(rivers, mapSize); r.Ms = Time.GetTicksMsec() - t0; - GD.Print($" ⭐ MIX: {r.Trunks} natural trunks + {r.Routed} routed-through + {r.Lakes} lake-enders = {rivers.Count}"); - GD.Print($" → SEA-REACHING RIVERS: {r.SeaReaching}, at {r.DistinctMouths} DISTINCT mouths" + - (r.SharedMouths.Count > 0 ? $" ⚠ shared mouth: {string.Join(", ", r.SharedMouths)}" : "") + + GD.Print($" ⭐ MIX: {r.Trunks} trunks + {r.Routed} routed-to-sea + {r.LakeFed} lake-fed + {r.Lakes} natural lake-enders + {r.Walled} walled-off = {rivers.Count}" + + (r.Joined > 0 ? $" ({r.Joined} joined as tributaries)" : "")); + GD.Print($" → {r.DistinctMouths} DISTINCT SEA MOUTHS from {r.SeaReaching} river(s) ending at the sea; {r.SeaConnected} rivers' water reaches the sea" + + (r.SharedMouths.Count > 0 ? $" ⚠⚠ STILL SHARED: {string.Join(", ", r.SharedMouths)}" : " ✅ no two rivers share a mouth") + $" spread: {r.Spread}"); + if (r.CapMoved.Count > 0) GD.Print($" ⚠ rim cap moved routed→walled-off: {string.Join(", ", r.CapMoved)}"); + if (r.LakeMoved.Count > 0) GD.Print($" ⭐ lake-target moved routed→lake-fed: {string.Join(", ", r.LakeMoved)}"); GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes"); WriteRiverCsv(batchRoot, r); - RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw); + RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw, refined, rimCapM); results.Add(r); } - WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw); + if (refined) WriteRefinedIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, dpDefaults, skipRaw); + else WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw); GD.Print("\n=================================================================="); GD.Print($" DONE — {batchRoot}"); GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set."); @@ -301,7 +352,8 @@ namespace IslaApocalypse.Tools var at = new Dictionary<(int x, int y), List>(); foreach (var rr in rivers) { - if (!rr.ReachesSea) continue; + // ⚠ A tributary adopted its trunk's terminus — it is not a separate mouth. + if (rr.Joined || !rr.ReachesSea) continue; (int x, int y) key; if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY); else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y); @@ -325,7 +377,7 @@ namespace IslaApocalypse.Tools int total = 0; foreach (var r in rivers) { - if (!r.ReachesSea) continue; + if (r.Joined || !r.ReachesSea) continue; float mx, my; if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; } else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; } @@ -355,16 +407,19 @@ namespace IslaApocalypse.Tools { RiverRouting.RiverClass.OceanTrunk => "trunk", RiverRouting.RiverClass.RoutedGiant => "routed", + RiverRouting.RiverClass.LakeFed => "lake-fed", + RiverRouting.RiverClass.WalledOff => "walled-off", _ => "lake-ender", }; private static void WriteRiverCsv(string batchRoot, SeedResult r) { var sb = new StringBuilder(); - sb.AppendLine("rank,class,analysis_kind,terminus_type,drainage_px,term_x,term_y,course_pts," + + sb.AppendLine("rank,class,basin_has_lake,analysis_kind,faithful_class,terminus_type,drainage_px,term_x,term_y,course_pts," + "route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," + - "route_cost,rim_climb_m,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," + - "rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,stem_width_px,why"); + "route_cost,rim_climb_m,cap_verdict,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," + + "rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,joined,confluence_parent_rank," + + "junction_x,junction_y,stem_width_px,why"); foreach (var rr in r.Rivers) { var c = rr.Candidate; @@ -372,31 +427,43 @@ namespace IslaApocalypse.Tools // ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the // probe row below) — those are what an affordability threshold would be set against. var pr = rr.OceanProbe; - sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : c.AnalysisKind)},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," + + sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : (c.AnalysisKind == "lake-ender" ? "yes" : "no"))},{(c.IsSea ? "" : c.AnalysisKind)}," + + $"{(c.IsSea ? "" : ClassName(rr.FaithfulClass))},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," + $"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," + $"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," + - $"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")},{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," + + $"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")}," + + $"{(rr.RefusedByCap ? "REFUSED" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under cap" : "")}," + + $"{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," + $"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," + $"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," + $"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," + $"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," + + $"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")}," + + $"{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," + $"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\""); } WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString()); } private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2, - int n, float sea, long floorPx, int promoteN, bool skipRaw) + int n, float sea, long floorPx, int promoteN, bool skipRaw, bool refined, float rimCapM) { string dir = Path.Combine(batchRoot, $"{r.Seed}"); DirAccess.MakeDirRecursiveAbsolute(dir); Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax); - DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n, - $"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}", - $"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}", - floorPx) - .SavePng(Path.Combine(dir, "routed_mix.png")); + if (refined) + DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n, + $"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)", + $"{r.DistinctMouths} DISTINCT SEA MOUTHS - {r.Trunks} TRUNK + {r.Routed} ROUTED + {r.LakeFed} LAKE-FED + {r.Lakes} NATURAL LAKE-ENDER + {r.Walled} WALLED-OFF, {r.Joined} JOINED. SPREAD: {r.Spread.ToUpperInvariant()}", + $"CHEAPEST ROUTE TO THE SEA CLIMBS MORE THAN THE {rimCapM:F0} M RIM CAP, SO IT ENDS AT ITS OWN TERMINAL") + .SavePng(Path.Combine(dir, "refined_mix.png")); + else + DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n, + $"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}", + $"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}", + floorPx) + .SavePng(Path.Combine(dir, "routed_mix.png")); // Grayscale beside the pretty render — the field must be inspectable without the palette. var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png")); @@ -583,6 +650,157 @@ namespace IslaApocalypse.Tools WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString()); } + /// + /// ⭐ THE REFINEMENT INDEX (rivers/03b) — the before/after, and the one judgment it feeds. + /// + private static void WriteRefinedIndex(string batchRoot, int mapSize, int[] seeds, + List rows, int promoteN, int lakeMinPx, float rimCapM, + DrainageAnalysis.Params def, bool skipRaw) + { + var sb = new StringBuilder(); + int primary = seeds.Length > 0 ? seeds[0] : 0; + + sb.AppendLine($"# Batch 03b — routing refinement: the reshaped MIX on the pure top {promoteN}"); + sb.AppendLine(); + sb.AppendLine("**⛔ TASTE GATE. Nothing is locked, and nothing graduates yet** — the cap is a knob, and the"); + sb.AppendLine("developer has said not to graduate mid-refinement."); + sb.AppendLine(); + sb.AppendLine("**⛔ COURSES ONLY. No height mutated, no water filled or created, nothing carved** — asserted per"); + sb.AppendLine("seed by a raw-bit digest of both height fields before and after routing. Lake-termination *ends a"); + sb.AppendLine("course at* an existing lake; it does not fill or create one."); + sb.AppendLine(); + sb.AppendLine("## 👉 The pick — this is a BEFORE/AFTER"); + sb.AppendLine(); + sb.AppendLine($"Open **`{primary}/refined_mix.png`** beside rivers/03's"); + sb.AppendLine($"**`../03_lowland_routing/{primary}/routed_mix.png`** (the before). Same base, same colours where"); + sb.AppendLine("they carry over, same fixed width scale — the two are directly comparable."); + sb.AppendLine(); + sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED"); + sb.AppendLine("> **Does the island now read as natural dendritic drainage — no uphill rivers, no parallel"); + sb.AppendLine("> duplicate mouths, no skirting past a lake to reach the sea — and is the resulting sea-river count"); + sb.AppendLine("> healthy, or is the island now too lake-locked?**"); + sb.AppendLine(">"); + sb.AppendLine($"> If too walled-off, the levers are the cap (`ISLA_RIM_CAP_M` up from {rimCapM:F0}) or — as its own"); + sb.AppendLine("> future task — rim incision. If about right, routing character is settled and the whole routing"); + sb.AppendLine("> unit graduates together."); + sb.AppendLine(); + sb.AppendLine("## ⚠⚠ Three DELIBERATE DIVERGENCES from the reference — not a port"); + sb.AppendLine(); + sb.AppendLine("Each corrects a faithful behaviour that produced a physically-wrong result, on the developer's call."); + sb.AppendLine(); + sb.AppendLine("| # | The reference does | rivers/03 showed | rivers/03b does instead |"); + sb.AppendLine("|---|---|---|---|"); + sb.AppendLine($"| **1** | routes a dry basin to the sea **at any cost** | an uphill river over a **66.7 m** rim (`31415926 #2`) | **rim cap {rimCapM:F0} m** — over it, the river is refused and ends at its own terminal |"); + sb.AppendLine("| **2** | lays every route independently, **no dedup, no join** | **two rivers at the identical ocean cell on every seed**, never having met | **confluence** — biggest-first, join on true cell intersection, the smaller becomes a tributary |"); + sb.AppendLine("| **3** | a router targets **ocean only** | a router **skirts a lake** to reach the distant sea | **lake targets** — a router stops at the nearer of {ocean, significant lake} |"); + sb.AppendLine(); + sb.AppendLine("**⚠ KEPT unchanged: `basinHasLake` is still the sort.** A basin that already holds a visible lake is"); + sb.AppendLine("a natural lake-ender and its river feeds its own lake — that is rivers/03's finding and it stands."); + sb.AppendLine(); + sb.AppendLine("## ⭐ The reshaped MIX, per seed"); + sb.AppendLine(); + sb.AppendLine("| Seed | trunk | routed→sea | lake-fed | natural lake-ender | ⚠ walled-off | joined | ⭐⭐ DISTINCT SEA MOUTHS | spread |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); + foreach (var r in rows) + sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.LakeFed} | {r.Lakes} | {r.Walled} | {r.Joined} | " + + $"**{r.DistinctMouths}** | {r.Spread} |"); + sb.AppendLine(); + sb.AppendLine("⚠ *Rivers whose water reaches the sea, counting tributaries through their trunk:* " + + string.Join(", ", Array.ConvertAll(rows.ToArray(), r => $"`{r.Seed}` {r.SeaConnected}")) + "."); + sb.AppendLine("A tributary has no mouth of its own, so it is not a separate sea mouth — but its water still gets there."); + sb.AppendLine(); + bool anyShared = false; + foreach (var r in rows) if (r.SharedMouths.Count > 0) anyShared = true; + if (anyShared) + { + sb.AppendLine("> ### ⚠⚠ SOME MOUTHS ARE STILL SHARED — fix 2 did not fully close it"); + foreach (var r in rows) + if (r.SharedMouths.Count > 0) sb.AppendLine($"> - `{r.Seed}`: {string.Join("; ", r.SharedMouths)}"); + sb.AppendLine("> Two courses can arrive at the same cell without their rasterised paths ever sharing one"); + sb.AppendLine("> earlier — they approach from different sides. Reported, not papered over."); + } + else + { + sb.AppendLine("> ### ✅ NO TWO RIVERS SHARE A MOUTH on any seed — fix 2 closed rivers/03's duplicate-mouth finding."); + sb.AppendLine("> Every distinct mouth is now a distinct river, and rivers that meet do so as a confluence."); + } + sb.AppendLine(); + sb.AppendLine("## What the divergences actually moved"); + sb.AppendLine(); + sb.AppendLine("| Seed | ⚠ rim cap moved routed → walled-off | ⭐ lake target moved routed → lake-fed | confluences formed |"); + sb.AppendLine("|---|---|---|---|"); + foreach (var r in rows) + sb.AppendLine($"| `{r.Seed}` | {(r.CapMoved.Count > 0 ? string.Join("; ", r.CapMoved) : "— none")} | " + + $"{(r.LakeMoved.Count > 0 ? string.Join("; ", r.LakeMoved) : "— none")} | " + + $"{(r.Joins.Count > 0 ? string.Join("; ", r.Joins) : "— none")} |"); + sb.AppendLine(); + sb.AppendLine("> ### ⚠ The min-rim signal — what to watch for"); + sb.AppendLine("> The cap is applied to the **least-cost route's** rim climb, not to a theoretical minimum-rim path."); + sb.AppendLine("> LOWGROUND penalises uphill heavily so the chosen route is almost always the low-rim one — **but if"); + sb.AppendLine("> a basin is walled off that visibly should have had a low way out, that is the signal we need a"); + sb.AppendLine("> bottleneck (min-rim) search.** Check each walled-off river on the plate against its surroundings."); + sb.AppendLine("> Not built here."); + sb.AppendLine(); + sb.AppendLine("## The per-river diagnostic"); + sb.AppendLine(); + foreach (var r in rows) + { + sb.AppendLine($"### `{r.Seed}`"); + sb.AppendLine(); + sb.AppendLine("| rank | class | basin has lake | drainage px | terminus | rim climb m | cap | joins | route len px |"); + sb.AppendLine("|---|---|---|---|---|---|---|---|---|"); + foreach (var rr in r.Rivers) + { + var c = rr.Candidate; var lo = rr.Lowland; var pr = rr.OceanProbe; + string term = rr.Joined + ? $"→ tributary of #{rr.ConfluenceParentRank}" + : rr.Class switch + { + RiverRouting.RiverClass.OceanTrunk => $"sea ({c.TermX},{c.TermY})", + RiverRouting.RiverClass.RoutedGiant => lo != null && lo.Reached ? $"sea ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ no route", + RiverRouting.RiverClass.LakeFed => lo != null && lo.Reached ? $"**lake** ({(int)lo.Target.x},{(int)lo.Target.y})" : "⚠ no route", + RiverRouting.RiverClass.WalledOff => $"**its own terminal** ({c.TermX},{c.TermY})", + _ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y})" : $"its own terminal ({c.TermX},{c.TermY})", + }; + sb.AppendLine($"| #{c.Rank} | {ClassName(rr.Class)} | {(c.IsSea ? "—" : (c.AnalysisKind == "lake-ender" ? "**yes**" : "no"))} | {c.DrainagePx:N0} | {term} | " + + $"{(pr != null && pr.Reached ? pr.RimClimbM.ToString("F1") : "—")} | " + + $"{(rr.RefusedByCap ? "**REFUSED**" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under" : "—")} | " + + $"{(rr.Joined ? $"#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})" : "—")} | " + + $"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "—")} |"); + } + sb.AppendLine(); + } + sb.AppendLine("## What was run"); + sb.AppendLine(); + sb.AppendLine($"Chain + analysis + routing at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered."); + sb.AppendLine($"Promoted set: the **pure top {promoteN}** — no quota; rivers/02b's K stays parked and unlocked."); + sb.AppendLine($"Rim cap **{rimCapM:F0} m** (`ISLA_RIM_CAP_M`). Significant water: 8-connected classify-water components ≥ {lakeMinPx:N0} px,"); + sb.AppendLine("ocean excluded (the interim for v2's missing water-bodies table). Lowland-only smoothing unchanged —"); + sb.AppendLine("the lowland reach is smoothed, the erosion-carved upland stem never is."); + sb.AppendLine(); + sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused, not rebuilt); `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}"); + sb.AppendLine($"(they define the routing surface); `MinOutletSeparationPx` {def.MinOutletSeparationPx}; `StemMinAccPx` {def.StemMinAccPx}. Termini are classified by"); + sb.AppendLine("`RegionLabeling.OceanMask` and the significant-lake mask only — no bare `h < sea`. `Giant.ProvisionalRoute` never drawn."); + sb.AppendLine(); + sb.AppendLine("**⚠ No spatial term anywhere.** The `1063685222` southern-coast gap is *not* addressed here — it stays a"); + sb.AppendLine("placement-era question, not a routing one."); + sb.AppendLine(); + sb.AppendLine("## Files"); + sb.AppendLine(); + sb.AppendLine("| File | What it is |"); + sb.AppendLine("|---|---|"); + sb.AppendLine("| `/refined_mix.png` | the five classes as a dendritic tree; white dot = confluence, yellow ring = rim crossed |"); + sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |"); + sb.AppendLine("| `rivers_.csv` | per river: class, `basin_has_lake`, faithful class, rim + cap verdict, confluence parent, route geometry, why |"); + if (skipRaw) + sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. `ISLA_SKIP_RAW=0` regenerates it. |"); + sb.AppendLine(); + sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}."); + sb.AppendLine(); + sb.AppendLine("→ `XX_Human/output/rivers/03b_routing_refinement.report.md`"); + WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString()); + } + // ---- the curve (the house pattern; pool pinned family-off per rivers/01) ------------------- private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)