feat: mixed river plan with provisional routes (terrain-water task 21b, C0b part 1 revised)

Still PURE ANALYSIS — zero terrain change, zero water; the source blueprint is
never written (md5-verified). Extends task 21 per the developer's b+c decision:
promote ~5-6 rivers, the natural ocean trunks PLUS the top endorheic giants.

Giants (top GiantCount terminal basins by per-basin total inflow) get their main
stem anchored on the STRONGEST FEEDER into the basin, not the basin's deepest
cell — on a flat basin floor the deepest cell sees only local trickles (the
task-21 lesson applied to stems). Classification is by what the terminal BASIN
holds, not the stem's single pooling cell (a stem can pool on dry ground a few
hundred px short of its lagoon and still be a lagoon river): basin holds a
classify lake -> LAKE-ENDER; dry pan -> ROUTED; the giant pooling nearest the
southernmost town is the SOUTHERN CANDIDATE and always ROUTED (shown, not
forced). Routed giants carry a PROVISIONAL route: steepest descent on the FULL
(no-terminal) epsilon fill, so the basin overtops at its spill and the walk
follows the terrain's own drainage to the ocean — the technique part 2 carves
with, here only drawn and flagged provisionalRoute_NOT_WATER in the JSON.

Seed 1280587109 result (defaults, 6 rivers): 3 ocean trunks (unchanged from
task 21) + GIANT 1 [ROUTED - SOUTHERN CANDIDATE] 2.27M px pooling in the SE
lagoon, 932-px route via spill (6598,5866) to the ocean — the island's biggest
river serving the south; GIANT 2 [LAKE-ENDER] 1.82M px ending at the E lagoon;
GIANT 3 [ROUTED] 1.76M px SW dry-pan system, 1716-px route to the SW coast.
All provisional routes reach the ocean. Analysis 22 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-11 18:33:41 -04:00
parent c60510beb7
commit 2de9502e4f
2 changed files with 262 additions and 4 deletions

View file

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

View file

@ -47,6 +47,8 @@ public partial class RiverPlanTool : Node
p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx); p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx);
p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount); p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount);
p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount); p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount);
p.TrunkCount = EnvI("RIVERPLAN_OCEAN_N", p.TrunkCount); // 21b alias
p.GiantCount = EnvI("RIVERPLAN_GIANT_N", p.GiantCount);
p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx); p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx);
p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx); p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx);
p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx); p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx);
@ -81,12 +83,22 @@ public partial class RiverPlanTool : Node
// destinations that make a trunk "sea-reaching". // destinations that make a trunk "sea-reaching".
int nn = bp.MapSize; int nn = bp.MapSize;
bool[] isOcean = new bool[nn * nn]; bool[] isOcean = new bool[nn * nn];
bool[] isClassifyWater = new bool[nn * nn];
for (int x = 0; x < nn; x++) for (int x = 0; x < nn; x++)
for (int y = 0; y < nn; y++) for (int y = 0; y < nn; y++)
isOcean[x * nn + y] = bp.WaterBodyIds[x, y] == 1; {
ushort wb = bp.WaterBodyIds[x, y];
isOcean[x * nn + y] = wb == 1;
isClassifyWater[x * nn + y] = wb != 0;
}
// Southernmost town — the 21b SOUTHERN CANDIDATE anchor (shown, not forced).
float southX = -1f, southY = -1f;
foreach (var t in bp.Towns)
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
var p = ReadParams(); var p = ReadParams();
var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, p); var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, isClassifyWater, southX, southY, p);
ulong t2 = Time.GetTicksMsec(); ulong t2 = Time.GetTicksMsec();
GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s."); GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s.");
@ -123,6 +135,22 @@ public partial class RiverPlanTool : Node
(wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + "."); (wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + ".");
} }
// ---- 21b: the promoted giants ----
int gi = 0;
foreach (var g in plan.Giants)
{
gi++;
GD.Print($"[RiverPlan] GIANT {gi} [{g.Kind.ToUpper()}{(g.SouthernCandidate ? " SOUTHERN CANDIDATE" : "")}]: " +
$"drainage {g.DrainageAreaPx} px, pools at ({g.Terminal.x:F0},{g.Terminal.y:F0}) " +
$"({(g.TerminalInClassifyWater ? "in classify water" : "dry pan")}, basin {g.BasinDepthM:F1} m / {g.BasinAreaPx} px), " +
(g.ExitFound ? $"mountain-exit ({g.MountainExit.x:F0},{g.MountainExit.y:F0}) at {g.MountainExitElevM:F0} m, " : "") +
$"{g.Tributaries.Count} tributaries" +
(g.ProvisionalRoute != null
? $"; PROVISIONAL route {g.ProvisionalRoute.Count * 4} px via spill ({g.Spill.x:F0},{g.Spill.y:F0}) " +
(g.RouteReachedOcean ? "-> reaches the OCEAN" : "-> DID NOT reach the ocean (walk stuck — report)")
: "; ends at its lake") + ".");
}
// ---- the southern-town report (filed fact, not a constraint) ---- // ---- the southern-town report (filed fact, not a constraint) ----
if (bp.Towns.Count > 0) if (bp.Towns.Count > 0)
{ {
@ -209,6 +237,39 @@ public partial class RiverPlanTool : Node
if (i < plan.Trunks.Count - 1) sb.Append(','); if (i < plan.Trunks.Count - 1) sb.Append(',');
sb.Append('\n'); sb.Append('\n');
} }
sb.Append("],\n\"giants\": [\n");
for (int i = 0; i < plan.Giants.Count; i++)
{
var g = plan.Giants[i];
sb.Append(" {\"kind\": \"").Append(g.Kind).Append("\", ");
sb.Append($"\"southernCandidate\": {(g.SouthernCandidate ? "true" : "false")}, ");
sb.Append($"\"drainageAreaPx\": {g.DrainageAreaPx}, ");
sb.Append("\"terminal\": "); Pt(sb, g.Terminal);
sb.Append($", \"terminalInClassifyWater\": {(g.TerminalInClassifyWater ? "true" : "false")}, ");
sb.Append($"\"basinDepthM\": {g.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {g.BasinAreaPx}, ");
sb.Append($"\"exitFound\": {(g.ExitFound ? "true" : "false")}, \"mountainExit\": "); Pt(sb, g.MountainExit);
sb.Append($", \"mountainExitElevM\": {g.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
Course(g.Course);
if (g.ProvisionalRoute != null)
{
sb.Append(",\n \"spill\": "); Pt(sb, g.Spill);
sb.Append($", \"routeReachedOcean\": {(g.RouteReachedOcean ? "true" : "false")}");
sb.Append(",\n \"provisionalRoute_NOT_WATER\": ");
Course(g.ProvisionalRoute);
}
sb.Append(",\n \"tributaries\": [");
for (int j = 0; j < g.Tributaries.Count; j++)
{
var tr = g.Tributaries[j];
if (j > 0) sb.Append(',');
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
Course(tr.Course);
sb.Append('}');
}
sb.Append("]\n }");
if (i < plan.Giants.Count - 1) sb.Append(',');
sb.Append('\n');
}
sb.Append("],\n\"endorheics\": ["); sb.Append("],\n\"endorheics\": [");
for (int i = 0; i < plan.Endorheics.Count; i++) for (int i = 0; i < plan.Endorheics.Count; i++)
{ {