feat: tributary water (tapered), significant-lake targeting, finer steps + deeper beds (terrain-water task 24)

All three gate polish items — they interleave in the same files, so they land as
one buildable commit rather than three that do not compile independently.

1. TRIBUTARY WATER. Tributaries carved in task 22/23 but stayed dry: they were
   never registered with the water stage. They now are (as CarvedRiver entries
   named 'trib', kept OUT of the per-river console table so it stays the 6
   mains). RiverTribWaterMinFlow (40k px of along-course flow) sets where water
   starts; RiverTribTaperPx (120 px) makes the wet->dry transition a FADE, not a
   wall — across the taper the wet strip narrows (0.25x -> 1x half-width) AND its
   surface drops toward the bed, so a stream head thins out and vanishes.
   Along-course flow is modelled quadratically from headwater trickle to full
   drainage at the mouth (the plan records drainage per river, not per sample).
   RiverTribWaterMinFlow=0 waters them end to end — the documented fallback.

2. LAKE-ENDER TARGETING. The join routed to the nearest classify-water CELL,
   which a 322-px puddle satisfies; it stopped ~80 px short of the 197k-px
   lagoon beside it. It now routes to a SIGNIFICANT water mask (bodies of at
   least RiverLakeMinTargetPx = 20k cells, built from the water-body table),
   falling back to any classify water only if no significant body is reachable,
   so a seed with genuinely small ponds still connects. Proven by adjacency:
   task 23 had 63 river cells touching the puddle and 0 touching the lagoon;
   task 24 has 49 touching the lagoon and 0 touching the puddle.

3. FINER STEPS + DEEPER BEDS. RiverStepDropM 2.0 -> 0.6 and RiverWaterDepthM
   1.2 -> 2.2, RiverDepthScale 1.0 -> 1.5. Reaches 298 -> 1034; the level gap
   between adjacent reaches drops from median 0.571 m / p95 2.08 m to median
   0.124 m / p95 0.74 m — the pond-staircase reads as a graded descent. Reaches
   stay trivial bodies (median 44 px).

Guards all held: flood guard 0 newly-below-sea and 0 below-sea cells modified,
BIOME oracle md5-identical to the task-22 baseline (output-only unchanged),
island top 457.65 m exact, crater core excluded, lowest carved cell exactly
sea+margin (37.85 m). Cumulative max cut 25.98 m with 339 cells >20 m —
identical to task 23, so deepening the WATER did not deepen the worst cuts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-11 20:24:07 -04:00
parent 645955d49c
commit 0e39538604
3 changed files with 113 additions and 15 deletions

View file

@ -127,9 +127,18 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public static string Rivers = "off"; public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground"; public static string RiverRoutingStyle = "lowground";
public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask
public static float RiverStepDropM = 2.0f; // Task 24 (the gate's polish): finer steps read as a descending river rather
public static float RiverWaterDepthM = 1.2f; // than a pond staircase; deeper water sits contained in its banks.
public static float RiverDepthScale = 1.0f; // RiverTribWaterMinFlow is the drainage a TRIBUTARY reach needs to carry
// water (0 = water them end to end); above it the water TAPERS to dry over
// RiverTribTaperPx so a stream head fades instead of ending in a wall.
// RiverLakeMinTargetPx is the smallest water body a lake-ender may target.
public static float RiverStepDropM = 0.6f;
public static float RiverWaterDepthM = 2.2f;
public static int RiverTribWaterMinFlow = 40000;
public static int RiverTribTaperPx = 120;
public static int RiverLakeMinTargetPx = 20000;
public static float RiverDepthScale = 1.5f; // deepened at the task-24 gate's ask
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
// Island falloff shaping (task 11). // Island falloff shaping (task 11).
@ -339,6 +348,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"]; if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"]; if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"];
if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"]; if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"];
if (data.ContainsKey("RiverTribWaterMinFlow")) RiverTribWaterMinFlow = (int)data["RiverTribWaterMinFlow"];
if (data.ContainsKey("RiverTribTaperPx")) RiverTribTaperPx = (int)data["RiverTribTaperPx"];
if (data.ContainsKey("RiverLakeMinTargetPx")) RiverLakeMinTargetPx = (int)data["RiverLakeMinTargetPx"];
RiverTribWaterMinFlow = Mathf.Max(RiverTribWaterMinFlow, 0);
RiverTribTaperPx = Mathf.Clamp(RiverTribTaperPx, 0, 2000);
RiverLakeMinTargetPx = Mathf.Max(RiverLakeMinTargetPx, 0);
RiverStepDropM = Mathf.Clamp(RiverStepDropM, 0.25f, 10f); RiverStepDropM = Mathf.Clamp(RiverStepDropM, 0.25f, 10f);
RiverWaterDepthM = Mathf.Clamp(RiverWaterDepthM, 0.2f, 5f); RiverWaterDepthM = Mathf.Clamp(RiverWaterDepthM, 0.2f, 5f);
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f); RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);

View file

@ -845,6 +845,17 @@ public partial class MapGenerator : TextureRect
isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y); isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y);
} }
// Task 24: SIGNIFICANT water — cells of bodies at least RiverLakeMinTargetPx
// in size, from the water-bodies table the stage above already built. A
// lake-ender routes to this so it enters the lagoon, not a puddle.
var bigBodies = new System.Collections.Generic.HashSet<ushort>();
foreach (var b in _waterBodies)
if (b.PixelCount >= ConfigManager.RiverLakeMinTargetPx) bigBodies.Add(b.Id);
bool[] isSignificantWater = new bool[MapSize * MapSize];
for (int x = 0; x < MapSize; x++)
for (int y = 0; y < MapSize; y++)
isSignificantWater[x * MapSize + y] = bigBodies.Contains(_waterBodyIds[x, y]);
float southX = -1f, southY = -1f; float southX = -1f, southY = -1f;
foreach (var t in _towns) foreach (var t in _towns)
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; } if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
@ -855,10 +866,13 @@ public partial class MapGenerator : TextureRect
? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND, ? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
WidthScale = ConfigManager.RiverWidthScale, WidthScale = ConfigManager.RiverWidthScale,
DepthScale = ConfigManager.RiverDepthScale, DepthScale = ConfigManager.RiverDepthScale,
SeaMarginM = ConfigManager.RiverSeaMargin SeaMarginM = ConfigManager.RiverSeaMargin,
TribWaterMinFlowPx = ConfigManager.RiverTribWaterMinFlow,
TribTaperPx = ConfigManager.RiverTribTaperPx,
LakeMinTargetPx = ConfigManager.RiverLakeMinTargetPx
}; };
var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater, var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
southX, southY, seaMap, seaFlat, isSignificantWater, southX, southY, seaMap, seaFlat,
_impactCenter.X, _impactCenter.Y, _impactCenter.X, _impactCenter.Y,
_impactRadius * ConfigManager.CraterErosionCore, _impactRadius * ConfigManager.CraterErosionCore,
() => (Time.GetTicksMsec()) / 1000.0, p); () => (Time.GetTicksMsec()) / 1000.0, p);
@ -899,8 +913,15 @@ public partial class MapGenerator : TextureRect
}); });
riverWetPx += reach.PixelCount; riverWetPx += reach.PixelCount;
} }
GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches across {st.Carved.Count} rivers, " + int mainCarved = 0, tribCarved = 0;
$"{riverWetPx} wet px, step drop {ConfigManager.RiverStepDropM:F1} m, depth {ConfigManager.RiverWaterDepthM:F1} m."); foreach (var cr in st.Carved) { if (cr.Kind == "tributary") tribCarved++; else mainCarved++; }
int tribReaches = 0; long tribWet = 0;
foreach (var reach in reaches)
if (reach.River == "trib") { tribReaches++; tribWet += reach.PixelCount; }
GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches ({tribReaches} on tributaries) " +
$"across {mainCarved} mains + {tribCarved} tributaries, {riverWetPx} wet px " +
$"({tribWet} tributary), step drop {ConfigManager.RiverStepDropM:F2} m, depth {ConfigManager.RiverWaterDepthM:F1} m, " +
$"trib flow threshold {ConfigManager.RiverTribWaterMinFlow} px taper {ConfigManager.RiverTribTaperPx} px.");
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +

View file

@ -66,6 +66,14 @@ public static class RiverCarvePass
public float WidthScale = 1.0f; public float WidthScale = 1.0f;
public float DepthScale = 1.0f; public float DepthScale = 1.0f;
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
// Task 24: a tributary reach is WET where its along-course flow exceeds this;
// upstream of that it TAPERS to dry over TribTaperPx rather than ending in a
// wall of water. 0 = water tributaries end to end.
public int TribWaterMinFlowPx = 40_000;
public int TribTaperPx = 120;
// Lake-enders route to the nearest water body of at least this size — the
// nearest wet PIXEL was a puddle (task-23 gate finding).
public int LakeMinTargetPx = 20_000;
public DrainageAnalysis.Params PlanParams = new(); public DrainageAnalysis.Params PlanParams = new();
} }
@ -94,6 +102,12 @@ public static class RiverCarvePass
public float[] Bed; // raw units, monotone non-increasing public float[] Bed; // raw units, monotone non-increasing
public float[] HalfW; // px public float[] HalfW; // px
public bool ReachedWaterTerminal; // lake-enders: extension reached classify water public bool ReachedWaterTerminal; // lake-enders: extension reached classify water
// Task 24: along-course flow (px of drainage) per sample, and the first index
// that carries water. Between WetFrom-TaperPx and WetFrom the water tapers
// (narrowing and shallowing to the bed) so a stream head fades out.
public float[] Flow;
public int WetFrom;
public int TaperPx;
} }
public class Stats public class Stats
@ -107,8 +121,13 @@ public static class RiverCarvePass
public double AnalysisSeconds, RoutingSeconds, CarveSeconds; public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
} }
/// <param name="isSignificantWater">Row-major mask of classify water belonging to
/// bodies of at least LakeMinTargetPx cells (task 24). Lake-enders route to THIS,
/// not to any wet pixel: the task-23 build routed one into a puddle a few hundred
/// px short of the obvious lagoon, because "nearest classify water" is satisfied
/// by a 3-cell pond.</param>
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean, public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
bool[] isClassifyWater, float southX, float southY, bool[] isClassifyWater, bool[] isSignificantWater, float southX, float southY,
float[,] seaMap, float seaFlat, float[,] seaMap, float seaFlat,
float craterCx, float craterCy, float craterCoreRadius, float craterCx, float craterCy, float craterCoreRadius,
Func<double> secondsNow, Params p) Func<double> secondsNow, Params p)
@ -171,7 +190,13 @@ public static class RiverCarvePass
bool reachedLake = false; bool reachedLake = false;
if (g.Kind == "lake-ender") if (g.Kind == "lake-ender")
{ {
var ext = RouteToOcean(height, n, isClassifyWater, // Target SIGNIFICANT water (task 24). Fall back to any classify water
// only if no significant body is reachable, so a seed whose lake-ender
// genuinely has only small ponds still connects rather than dead-ending.
var ext = RouteToOcean(height, n, isSignificantWater,
(int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt);
if (ext.Count == 0)
ext = RouteToOcean(height, n, isClassifyWater,
(int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt); (int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt);
if (ext.Count > 0) { route = ext; reachedLake = true; } if (ext.Count > 0) { route = ext; reachedLake = true; }
} }
@ -304,7 +329,10 @@ public static class RiverCarvePass
{ {
var course = new List<(float x, float y)>(trib.Course); var course = new List<(float x, float y)>(trib.Course);
course.Reverse(); // head → confluence course.Reverse(); // head → confluence
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null, // Task 24: tributaries are registered as carved rivers (name "trib") so the
// water stage sees them — they carved but stayed dry in task 23. They are NOT
// added to stats.Rivers, so the per-river console table stays the 6 mains.
CarveRiver("trib", "tributary", trib.DrainageAreaPx, course, null,
height, n, seaAt, coreSq, craterCx, craterCy, p, stats); height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
} }
@ -452,11 +480,29 @@ public static class RiverCarvePass
if (name != null) if (name != null)
{ {
stats.Rivers.Add(rs); if (kind != "tributary") stats.Rivers.Add(rs);
// Along-course flow: drainage grows from a headwater trickle to the full
// figure at the mouth. Quadratic in t so the substantial lower half
// dominates — a first-order stand-in for real accumulation, which the
// plan only records per-river.
var flow = new float[m];
for (int i = 0; i < m; i++)
{
float t = m > 1 ? (float)i / (m - 1) : 1f;
flow[i] = drainagePx * (0.05f + 0.95f * t * t);
}
int wetFrom = 0;
if (kind == "tributary" && p.TribWaterMinFlowPx > 0)
{
wetFrom = m; // dry unless the threshold is met
for (int i = 0; i < m; i++)
if (flow[i] >= p.TribWaterMinFlowPx) { wetFrom = i; break; }
}
stats.Carved.Add(new CarvedRiver stats.Carved.Add(new CarvedRiver
{ {
Name = name, Kind = kind, DrainagePx = drainagePx, Name = name, Kind = kind, DrainagePx = drainagePx,
Dense = dense, Bed = bed, HalfW = halfW Dense = dense, Bed = bed, HalfW = halfW,
Flow = flow, WetFrom = wetFrom, TaperPx = p.TribTaperPx
}); });
} }
return rs; return rs;
@ -501,7 +547,11 @@ public static class RiverCarvePass
{ {
int m = r.Dense.Count; int m = r.Dense.Count;
if (m < 2) continue; if (m < 2) continue;
int i = 0; // Task 24: start at the wet-from index (tributary threshold; 0 for mains),
// but back up by the taper length so the transition is a FADE, not a wall.
int i = Math.Max(0, r.WetFrom - r.TaperPx);
if (i >= m) continue; // entirely below threshold: dry
int taperStart = i, taperEnd = Math.Min(m - 1, r.WetFrom);
float lastLevel = float.MaxValue; float lastLevel = float.MaxValue;
while (i < m) while (i < m)
{ {
@ -518,7 +568,14 @@ public static class RiverCarvePass
var reach = new Reach { Id = nextId, River = r.Name, Level = level }; var reach = new Reach { Id = nextId, River = r.Name, Level = level };
for (int k2 = i; k2 < j; k2++) for (int k2 = i; k2 < j; k2++)
{ {
float hw = r.HalfW[k2]; // Taper: 0 at the dry end of the fade zone → 1 at full water. The
// water narrows AND its surface drops to the bed, so a stream head
// thins out and disappears instead of ending in a flat wall.
float taper = 1f;
if (taperEnd > taperStart && k2 < taperEnd)
taper = (float)(k2 - taperStart) / (taperEnd - taperStart);
if (taper <= 0.02f) continue;
float hw = r.HalfW[k2] * (0.25f + 0.75f * taper);
int x0 = (int)MathF.Floor(r.Dense[k2].x - hw), x1 = (int)MathF.Ceiling(r.Dense[k2].x + hw); int x0 = (int)MathF.Floor(r.Dense[k2].x - hw), x1 = (int)MathF.Ceiling(r.Dense[k2].x + hw);
int y0 = (int)MathF.Floor(r.Dense[k2].y - hw), y1 = (int)MathF.Ceiling(r.Dense[k2].y + hw); int y0 = (int)MathF.Floor(r.Dense[k2].y - hw), y1 = (int)MathF.Ceiling(r.Dense[k2].y + hw);
for (int x = x0; x <= x1; x++) for (int x = x0; x <= x1; x++)
@ -535,7 +592,12 @@ public static class RiverCarvePass
float h = height[x, y]; float h = height[x, y];
float sea = SeaAt(x, y); float sea = SeaAt(x, y);
if (h < sea) continue; // ocean/seam territory if (h < sea) continue; // ocean/seam territory
if (h >= level) continue; // bank above the water line // The tapered surface sits between the bed and the reach
// level, so the wet strip shallows as it narrows.
float localLevel = taper >= 1f
? level
: r.Bed[k2] + (level - r.Bed[k2]) * taper;
if (h >= localLevel) continue; // bank above the water line
wbid[x, y] = nextId; wbid[x, y] = nextId;
reach.PixelCount++; reach.PixelCount++;
reach.Cx += x; reach.Cy += y; reach.Cx += x; reach.Cy += y;