rivers/03b: routing refinement — three deliberate divergences from the faithful port

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.
This commit is contained in:
Stewart Howe 2026-08-24 22:00:22 -04:00
parent 4e4be6a83e
commit b5ceca0419
3 changed files with 688 additions and 38 deletions

View file

@ -520,6 +520,180 @@ namespace IslaApocalypse.Tools
return img; 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/<seed>/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.
/// <summary>⭐ rivers/03b: a router that stopped at a significant lake instead of skirting it.</summary>
private static readonly Color LakeFedStem = new(0.520f, 0.380f, 0.780f);
private static readonly Color LakeFedReach = new(0.720f, 0.560f, 1.000f);
/// <summary>⚠ rivers/03b: refused by the rim cap — it would have been an uphill river.</summary>
private static readonly Color Walled = new(0.950f, 0.330f, 0.330f);
/// <summary>Where two courses actually meet.</summary>
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),
};
/// <summary>
/// ⭐⭐ 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.
/// </summary>
public static Image RefinedMix(List<RiverRouting.RoutedRiver> 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<RiverRouting.RoutedRiver>(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;
}
/// <summary>How many leading cells of a joined river's path are its own, up to the junction.</summary>
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;
}
/// <summary>Ring the route's high point — the rim the channel crosses.</summary> /// <summary>Ring the route's high point — the rim the channel crosses.</summary>
private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark) private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
{ {

View file

@ -262,6 +262,48 @@ namespace IslaApocalypse.Tools
RoutedGiant, RoutedGiant,
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary> /// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
LakeEnder, LakeEnder,
// ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══
/// <summary>⭐ 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.**</summary>
LakeFed,
/// <summary>⚠ 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.**</summary>
WalledOff,
}
/// <summary>
/// ⚠⚠ 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.
/// </summary>
public sealed class Options
{
/// <summary>⭐ 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).</summary>
public float RimCapM = float.PositiveInfinity;
/// <summary>⭐ 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).</summary>
public bool LakeTargetForRouters;
/// <summary>⭐ 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).</summary>
public bool Confluence;
/// <summary>rivers/03's faithful settings — every divergence off.</summary>
public static Options Faithful => new();
} }
/// <summary>One promoted river, classified, routed and assembled.</summary> /// <summary>One promoted river, classified, routed and assembled.</summary>
@ -281,6 +323,29 @@ namespace IslaApocalypse.Tools
public List<(float x, float y)> Course; public List<(float x, float y)> Course;
public string Why = ""; public string Why = "";
// ═══ rivers/03b ═══
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
public float CappedRimM;
public bool RefusedByCap;
/// <summary>The class this river WOULD have had under the reference's rules — so every
/// reclassification the divergences caused is legible rather than silent.</summary>
public RiverClass FaithfulClass;
/// <summary>The full course rasterised to cells — what the confluence test intersects on.</summary>
public List<(int x, int y)> CellPath;
/// <summary>⭐ 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.</summary>
public List<(float x, float y)> OwnPath;
/// <summary>The rank of the river this one flows into, or 0 if it keeps its own terminus.</summary>
public int ConfluenceParentRank;
public bool Joined;
public (int x, int y) JunctionCell;
/// <summary>How many leading cells of <see cref="CellPath"/> are the NATURAL upland stem.
/// Everything after is the lowland reach routing added — the plate colours the two apart.</summary>
public int StemCells;
/// <summary>Does this river's own course end at the sea, before any confluence?</summary>
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant; public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
} }
@ -313,8 +378,22 @@ namespace IslaApocalypse.Tools
/// </summary> /// </summary>
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n, public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style, bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action<string> log) Action<string> 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<RoutedRiver>(); var outp = new List<RoutedRiver>();
foreach (var c in promoted) foreach (var c in promoted)
{ {
@ -333,18 +412,54 @@ namespace IslaApocalypse.Tools
continue; 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); var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
rr.OceanProbe = probe; 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"; bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder) if (!refLakeEnder)
{ {
rr.Class = RiverClass.RoutedGiant; // Route to the nearest of the target mask — {ocean} faithfully, {ocean lakes} refined.
rr.Lowland = probe; var route = opt.LakeTargetForRouters
rr.Why = probe.Reached ? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}" : probe;
: "dry pan → routed, but NO path to the ocean was found (unexpected — report)"; 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 else
{ {
@ -362,17 +477,160 @@ namespace IslaApocalypse.Tools
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; } if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
} }
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; } if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
rr.FaithfulClass = RiverClass.LakeEnder;
rr.Why = rr.LakeReached 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 → natural 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; no water body reachable, course ends at its terminal";
} }
rr.Course = Assemble(c.Course, rr.Lowland); rr.Course = Assemble(c.Course, rr.Lowland);
outp.Add(rr); outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " + log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " +
$"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")}" + $"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.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.
/// <summary>
/// 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.
/// </summary>
private static void Confluence(List<RoutedRiver> rivers, Action<string> log)
{
var order = new List<RoutedRiver>(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)");
}
/// <summary>
/// ⭐ 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.
/// </summary>
public static RoutedRiver Root(RoutedRiver r, List<RoutedRiver> 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;
}
/// <summary>
/// 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).
/// </summary>
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; return outp;
} }

View file

@ -69,7 +69,15 @@ namespace IslaApocalypse.Tools
public int Seed; public int Seed;
public List<RiverRouting.RoutedRiver> Rivers; public List<RiverRouting.RoutedRiver> Rivers;
public int Trunks, Routed, Lakes; public int Trunks, Routed, Lakes;
/// <summary>rivers/03b classes.</summary>
public int LakeFed, Walled, Joined;
/// <summary>Rivers whose OWN course ends at the sea (tributaries excluded — they have no mouth).</summary>
public int SeaReaching; public int SeaReaching;
/// <summary>⭐ Rivers whose water reaches the sea, counting tributaries through their trunk.</summary>
public int SeaConnected;
public List<string> CapMoved = new();
public List<string> LakeMoved = new();
public List<string> Joins = new();
/// <summary>⭐ DISTINCT ocean mouth cells — routes are computed per giant with nothing /// <summary>⭐ 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.</summary> /// coordinating them, so two can land on the same cell. This is the honest river count.</summary>
public int DistinctMouths; public int DistinctMouths;
@ -90,8 +98,18 @@ namespace IslaApocalypse.Tools
ToolingPaths.Configure(OS.GetUserDataDir()); ToolingPaths.Configure(OS.GetUserDataDir());
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers")); 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); 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 mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize); int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
@ -114,7 +132,7 @@ namespace IslaApocalypse.Tools
TerrainShapeV1.Assert("RiverRouting"); TerrainShapeV1.Assert("RiverRouting");
TerrainShapeV1.AssertErosionDefaultOn("RiverRouting"); TerrainShapeV1.AssertErosionDefaultOn("RiverRouting");
string batchRoot = ToolingPaths.BatchRoot(task, descr); string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
@ -135,7 +153,9 @@ namespace IslaApocalypse.Tools
var dpDefaults = new DrainageAnalysis.Params(); var dpDefaults = new DrainageAnalysis.Params();
GD.Print("=================================================================="); 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("==================================================================");
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}"); GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default"); 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($" ⚠ 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($" \"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."); 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($"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($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed.");
GD.Print($"batch : {batchRoot}"); GD.Print($"batch : {batchRoot}");
@ -208,7 +239,7 @@ namespace IslaApocalypse.Tools
GD.Print($" routing (style {styleS}) — probing the ocean for every giant:"); GD.Print($" routing (style {styleS}) — probing the ocean for every giant:");
ulong tr0 = Time.GetTicksMsec(); ulong tr0 = Time.GetTicksMsec();
var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater, 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; double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
// ═══ ⛔ …and assert they are byte-identical after ═══ // ═══ ⛔ …and assert they are byte-identical after ═══
@ -235,28 +266,48 @@ namespace IslaApocalypse.Tools
}; };
foreach (var rr in rivers) foreach (var rr in rivers)
{ {
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) r.Trunks++; switch (rr.Class)
else if (rr.Class == RiverRouting.RiverClass.RoutedGiant) r.Routed++; {
else r.Lakes++; 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.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); MeasureMouths(r, rivers);
r.Spread = Spread(rivers, mapSize); r.Spread = Spread(rivers, mapSize);
r.Ms = Time.GetTicksMsec() - t0; r.Ms = Time.GetTicksMsec() - t0;
GD.Print($" ⭐ MIX: {r.Trunks} natural trunks + {r.Routed} routed-through + {r.Lakes} lake-enders = {rivers.Count}"); 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}" +
GD.Print($" → SEA-REACHING RIVERS: {r.SeaReaching}, at {r.DistinctMouths} DISTINCT mouths" + (r.Joined > 0 ? $" ({r.Joined} joined as tributaries)" : ""));
(r.SharedMouths.Count > 0 ? $" ⚠ shared mouth: {string.Join(", ", r.SharedMouths)}" : "") + 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}"); $" 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"); GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
WriteRiverCsv(batchRoot, r); 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); 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("\n==================================================================");
GD.Print($" DONE — {batchRoot}"); GD.Print($" DONE — {batchRoot}");
GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set."); 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<int>>(); var at = new Dictionary<(int x, int y), List<int>>();
foreach (var rr in rivers) 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; (int x, int y) key;
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY); 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); 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; int total = 0;
foreach (var r in rivers) foreach (var r in rivers)
{ {
if (!r.ReachesSea) continue; if (r.Joined || !r.ReachesSea) continue;
float mx, my; float mx, my;
if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; } 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; } 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.OceanTrunk => "trunk",
RiverRouting.RiverClass.RoutedGiant => "routed", RiverRouting.RiverClass.RoutedGiant => "routed",
RiverRouting.RiverClass.LakeFed => "lake-fed",
RiverRouting.RiverClass.WalledOff => "walled-off",
_ => "lake-ender", _ => "lake-ender",
}; };
private static void WriteRiverCsv(string batchRoot, SeedResult r) private static void WriteRiverCsv(string batchRoot, SeedResult r)
{ {
var sb = new StringBuilder(); 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_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," + "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,stem_width_px,why"); "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) foreach (var rr in r.Rivers)
{ {
var c = rr.Candidate; 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 // ⚠ 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. // probe row below) — those are what an affordability threshold would be set against.
var pr = rr.OceanProbe; 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.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") : "")}," + $"{(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.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.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") : "")}," + $"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," +
$"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "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}\""); $"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\"");
} }
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString()); WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
} }
private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2, 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}"); string dir = Path.Combine(batchRoot, $"{r.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir); DirAccess.MakeDirRecursiveAbsolute(dir);
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax); Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n, if (refined)
$"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}", DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n,
$"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}", $"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)",
floorPx) $"{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()}",
.SavePng(Path.Combine(dir, "routed_mix.png")); $"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. // 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")); 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()); WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
} }
/// <summary>
/// ⭐ THE REFINEMENT INDEX (rivers/03b) — the before/after, and the one judgment it feeds.
/// </summary>
private static void WriteRefinedIndex(string batchRoot, int mapSize, int[] seeds,
List<SeedResult> 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("| `<seed>/refined_mix.png` | the five classes as a dendritic tree; white dot = confluence, yellow ring = rim crossed |");
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, `basin_has_lake`, faithful class, rim + cap verdict, confluence parent, route geometry, why |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/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) ------------------- // ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors) private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)