From 6960c2a2e825454d611a772773f014aad22dd60e Mon Sep 17 00:00:00 2001 From: beezm Date: Tue, 11 Aug 2026 19:16:37 -0400 Subject: [PATCH] feat: river bed carving with SHORT/LOWGROUND lowland routing A/B (terrain-water task 22, C0b part 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the frozen task-21b plan as real terrain — the first river pass to modify the render map. NO WATER (part 2b waters the gated routing style). RiverCarvePass (D-035 numeric): reruns DrainageAnalysis in-pipeline (deterministic — same seed, same eroded surface, same plan), routes each routed giant's lowland reach to the nearest ocean by deterministic Dijkstra under two config-gated cost models — 'short' (distance + uphill penalty; heads direct) vs 'lowground' (cost ~ elevation above sea; follows the lowest ground and meanders) — then carves every promoted course as a parabolic channel with a smoothstep shoulder. Width/depth grow downstream with sqrt(drainage); bed profile forced monotone non-increasing toward the outlet (water must flow) and clamped to sea + RiverSeaMargin EVERYWHERE, with below-sea cells read-only and the crater core excluded — the erosion flood-guard discipline, asserted per generation by the water-pixel A/B (throws on any change). Pipeline slot: AFTER GenerateTowns (towns read the render map for land/slope/ water checks — carving first would move towns and destabilise every A/B) and BEFORE roads (a full run's A* should see the beds). ISLA_SERVER_CONFIG env override added to ConfigManager: batch/A-B tooling loads an alternate config file and the developer's live ServerConfig.json is never written by tooling again (the task-19 restore near-miss class is retired). Measured, seed 1280587109 (both variants): oracle md5-identical to baseline (BIOM/WBID bitwise equal), 0 newly-below-sea cells, below-sea untouched, island top 457.65 m exact, lowest carved cell exactly sea+margin (37.85 m), and Rivers='off' is bitwise identical to the task-19 blueprint. All routed giants reach the ocean in both styles. Wander (polyline/straight): SHORT 1.02-1.06 vs LOWGROUND 1.24-1.38; SHORT carves 811k m3 (cuts through rises, max cumulative cut 14.6 m), LOWGROUND 481k m3 (goes around, 14.3 m). Pass cost ~25 s (plan 21 s, routing 1.4-2.5 s, carve 0.3 s). Co-Authored-By: Claude Fable 5 --- Core/Scripts/ConfigManager.cs | 60 ++++++ Tools/Scripts/MapGenerator.cs | 86 ++++++++ Tools/Scripts/RiverCarvePass.cs | 361 ++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 Tools/Scripts/RiverCarvePass.cs diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index a56809f..2ae8816 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -105,6 +105,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public const float CRATER_EROSION_CORE_DEFAULT = 0.80f; public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f; + // Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's + // beds into the RENDER map — ocean trunks, routed giants (lowland reach to + // the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing- + // style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks + // the lowland routing for routed giants: "short" heads direct (lightly + // terrain-aware), "lowground" follows the lowest ground and wanders like a + // real river — the task-22 gate decides which ships; "lowground" is the + // provisional default pending that verdict. Width/depth scales are taste + // dials on the flow-proportional bed profile. RiverSeaMargin is the bed's + // absolute floor above sea — the erosion flood-guard discipline: no river + // bed may create inland below-sea cells, so the rendered coastline cannot + // move even with rivers carved. + public static string Rivers = "off"; + public static string RiverRoutingStyle = "lowground"; + public static float RiverWidthScale = 1.0f; + public static float RiverDepthScale = 1.0f; + public static float RiverSeaMargin = 0.2f; // m above sea, bed floor + // Island falloff shaping (task 11). // // CoastProfile: "wide" adds the submarine shelf — the height curve is identity @@ -140,6 +158,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different { string path = "res://ServerConfig.json"; + // Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate + // config FILE to load instead of the repo's ServerConfig.json. Batch and + // A/B runs point this at a scratch config, so the developer's live + // ServerConfig.json is never written by tooling again — the whole + // backup/restore dance (and its task-19 near-miss) goes away. Loud, so + // a forgotten env var cannot silently masquerade as the repo config. + string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG"); + if (!string.IsNullOrEmpty(envPath)) + { + if (FileAccess.FileExists(envPath)) + { + path = envPath; + GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json)."); + } + else + GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config."); + } + if (!FileAccess.FileExists(path)) { GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K."); @@ -272,6 +308,30 @@ namespace IslaApocalypse.Core // Change this if your namespace is different ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f); if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap) GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m."); + // Rivers gate + dials (task 22) + if (data.ContainsKey("Rivers")) + { + string rv = (string)data["Rivers"]; + if (rv == "off" || rv == "v1") + Rivers = rv; + else + GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'."); + } + if (data.ContainsKey("RiverRoutingStyle")) + { + string st = (string)data["RiverRoutingStyle"]; + if (st == "short" || st == "lowground") + RiverRoutingStyle = st; + else + GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'."); + } + if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"]; + if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"]; + if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"]; + RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f); + RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f); + RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f); + // Crater erosion treatment (task 19) if (data.ContainsKey("CraterErosionMode")) { diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 7daf51c..e8ff480 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -53,6 +53,7 @@ public partial class MapGenerator : TextureRect // Hydraulic erosion (task 17): output-only droplet pass on the RENDER map, // after detail, before the crater carve. The classify map never sees it. private bool _erosionOn; + private bool _riversOn; private byte _craterErosionMode; // The carve's radius as a fraction of CraterRadius — the ONLY place this number @@ -120,6 +121,7 @@ public partial class MapGenerator : TextureRect _curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null; _curveOn = _curveKnots != null; _erosionOn = ConfigManager.Erosion == "v1"; + _riversOn = ConfigManager.Rivers == "v1"; _craterErosionMode = ConfigManager.CraterErosionMode == "feather" ? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL; // (The monotonicity assertion now runs inside GenerateTopography, against the @@ -228,6 +230,15 @@ public partial class MapGenerator : TextureRect GD.Print($"{T()} Towns placed: {_towns.Count}."); await CaptureStage("2_towns"); + // --- RIVERS (task 22, C0b part 2a): carve the frozen plan's beds. --- + // AFTER towns (GenerateTowns reads the render map for land/slope/water + // checks, so carving first would move towns and destabilise every A/B) and + // BEFORE roads (a full run's A* should see the carved beds). RENDER map + // only: biomes and WBID are already computed from classify — the oracle is + // untouched by construction. NO WATER — part 2b. + if (_riversOn) + CarveRivers(); + if (ConfigManager.SkipRoads) { // Iteration toggle (terrain-water task 03): the road pass is ~25 min of a @@ -790,6 +801,81 @@ public partial class MapGenerator : TextureRect } } + /// + /// The river-carving pass (task 22): recomputes the frozen task-21b plan on the + /// eroded surface, routes the routed giants' lowland reaches (SHORT/LOWGROUND), + /// and carves every promoted bed. Render map only; flood-guarded exactly like + /// erosion (water-pixel count A/B around the pass, throw on any change). + /// + private void CarveRivers() + { + ulong tRiv0 = Time.GetTicksMsec(); + float[,] seaMap = null; + float seaFlat = ConfigManager.SeaLevelValue; + if (ConfigManager.SeaLevelModel != "flat") + { + seaMap = new float[MapSize, MapSize]; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + seaMap[x, y] = GetSeaLevel(_tempMap[x, y]); + } + long wetBefore = CountRenderWaterPixels(seaMap, seaFlat); + float topBefore = 0f; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + if (_heightMap[x, y] > topBefore) topBefore = _heightMap[x, y]; + + // Masks from the SHARED water predicates — identical by construction to the + // WBID-derived masks the task-21b tool used. + bool[] isOcean = new bool[MapSize * MapSize]; + bool[] isClassifyWater = new bool[MapSize * MapSize]; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + { + isOcean[x * MapSize + y] = IsOceanPixel(x, y); + isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y); + } + + float southX = -1f, southY = -1f; + foreach (var t in _towns) + if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; } + + var p = new RiverCarvePass.Params + { + RoutingStyle = ConfigManager.RiverRoutingStyle == "short" + ? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND, + WidthScale = ConfigManager.RiverWidthScale, + DepthScale = ConfigManager.RiverDepthScale, + SeaMarginM = ConfigManager.RiverSeaMargin + }; + var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater, + southX, southY, seaMap, seaFlat, + _impactCenter.X, _impactCenter.Y, + _impactRadius * ConfigManager.CraterErosionCore, + () => (Time.GetTicksMsec()) / 1000.0, p); + + long wetAfter = CountRenderWaterPixels(seaMap, seaFlat); + if (wetAfter != wetBefore) + throw new System.InvalidOperationException( + $"[MapGenerator] RIVER FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate."); + float topAfter = 0f; + for (int x = 0; x < MapSize; x++) + for (int y = 0; y < MapSize; y++) + if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y]; + + GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " + + $"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " + + $"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " + + $"{st.CarvedCells} cell-writes, {st.CarvedVolumeM3:F0} m³, max cut {st.MaxCutM:F1} m. " + + $"Water pixels {wetBefore} -> {wetAfter} (flood guard holds); island top {topBefore * 251f:F2} -> {topAfter * 251f:F2} m."); + foreach (var r in st.Rivers) + GD.Print($"{T()} [Rivers] {r.Name} [{r.Kind}{(r.SouthernCandidate ? " SOUTHERN" : "")}]: " + + $"drainage {r.DrainagePx}, course {r.CourseLenPx} px" + + (r.RouteLenPx > 0 ? $", lowland route {r.RouteLenPx} px (straight {r.RouteStraightPx:F0}, wander {r.WanderRatio:F2})" : "") + + $", {(r.ReachedOcean ? "reaches ocean/terminal" : "*** ROUTE INCOMPLETE ***")}, " + + $"max cut {r.MaxCutM:F1} m, {r.VolumeM3:F0} m³."); + } + // Render-map water pixel count — the erosion flood-guard's external check. private long CountRenderWaterPixels(float[,] seaMap, float seaFlat) { diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs new file mode 100644 index 0000000..a5d803b --- /dev/null +++ b/Tools/Scripts/RiverCarvePass.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; + +/// +/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass +/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel +/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds +/// once the routing-style gate picks SHORT or LOWGROUND. +/// +/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in- +/// pipeline (deterministic: same seed → same eroded surface → same plan), then: +/// +/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its +/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra. +/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls. +/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available +/// ground and wanders like a real lowland river. +/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland +/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with +/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and +/// depth grow downstream with drainage. The bed elevation is made MONOTONE +/// NON-INCREASING toward the outlet (water must flow), and is clamped to +/// sea + margin everywhere — the flood-guard discipline erosion established: +/// below-sea cells are read-only, no carve may create inland below-sea cells, +/// so the rendered coastline cannot move. The bed meets the ocean AT the +/// coast, where the terrain itself descends through sea level. +/// 3. Crater: nothing inside the protected core is modified (erosion's rule). +/// +/// Output-only: the caller applies this to the RENDER map after classify-side +/// data (biomes, WBID) is already computed — the oracle stays byte-identical. +/// +public static class RiverCarvePass +{ + public const float M_PER_UNIT = 251f; + public const byte STYLE_SHORT = 0; + public const byte STYLE_LOWGROUND = 1; + + private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }; + private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 }; + private static readonly float[] DIST = { + 1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f }; + + // Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise, + // so a 10 m wall costs like an 80 px detour — walls are avoided, direction is + // kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus + // heavily for climbing, so the cheapest corridor is the lowest ground even when + // that wanders. + private const float SHORT_UPHILL_PER_M = 8f; + private const float LOWGROUND_ELEV_PER_M = 1f; + private const float LOWGROUND_BASE = 0.05f; + private const float LOWGROUND_UPHILL_PER_M = 50f; + + // Bed geometry: sizes grow downstream from head to mouth, scaled by + // sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at + // the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion + // detailing philosophy. + private const float DEPTH_HEAD_M = 1.0f; + private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale + private const float HALFWIDTH_HEAD_PX = 2.0f; + private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale + private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent + + public class Params + { + public byte RoutingStyle = STYLE_LOWGROUND; + public float WidthScale = 1.0f; + public float DepthScale = 1.0f; + public float SeaMarginM = 0.2f; // bed floor above sea, everywhere + public DrainageAnalysis.Params PlanParams = new(); + } + + public class RiverStat + { + public string Name; + public string Kind; + public bool SouthernCandidate; + public long DrainagePx; + public int CourseLenPx; + public int RouteLenPx; // lowland reach only (routed giants) + public float RouteStraightPx; + public float WanderRatio; // routeLen / straight-line + public bool ReachedOcean; + public float MaxCutM; + public double VolumeM3; + } + + public class Stats + { + public List Rivers = new(); + public long CarvedCells; + public double CarvedVolumeM3; + public float MaxCutM; + public double AnalysisSeconds, RoutingSeconds, CarveSeconds; + } + + public static Stats Apply(float[,] height, int mapSize, bool[] isOcean, + bool[] isClassifyWater, float southX, float southY, + float[,] seaMap, float seaFlat, + float craterCx, float craterCy, float craterCoreRadius, + Func secondsNow, Params p) + { + int n = mapSize; + var stats = new Stats(); + float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat; + float coreSq = craterCoreRadius * craterCoreRadius; + + // --- the frozen plan, recomputed deterministically in-pipeline --- + double t0 = secondsNow(); + var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams); + stats.AnalysisSeconds = secondsNow() - t0; + + // --- lowland routing for the routed giants (the A/B) --- + t0 = secondsNow(); + var giantRoutes = new List>(); + foreach (var g in plan.Giants) + { + if (g.Kind != "routed") { giantRoutes.Add(null); continue; } + giantRoutes.Add(RouteToOcean(height, n, isOcean, + (int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt)); + } + stats.RoutingSeconds = secondsNow() - t0; + + // --- carve --- + t0 = secondsNow(); + int riverIdx = 0; + foreach (var t in plan.Trunks) + { + riverIdx++; + var course = new List<(float x, float y)>(t.Course); + course.Reverse(); // head → mouth + var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx, + course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); + rs.ReachedOcean = true; // outlet is on the coast by construction + foreach (var trib in t.Tributaries) + CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); + } + int gi = 0; + foreach (var g in plan.Giants) + { + var route = giantRoutes[gi]; gi++; + var course = new List<(float x, float y)>(g.Course); + course.Reverse(); // head → terminal + var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx, + course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); + rs.SouthernCandidate = g.SouthernCandidate; + rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0); + foreach (var trib in g.Tributaries) + CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats); + } + stats.CarveSeconds = secondsNow() - t0; + return stats; + } + + /// + /// Deterministic Dijkstra from the start cell to the nearest ocean cell under + /// the selected style's cost model. Returns the path start → ocean (1-px steps), + /// or an empty list if no path exists (reported upstream, never asserted away). + /// + private static List<(float x, float y)> RouteToOcean(float[,] height, int n, + bool[] isOcean, int sx, int sy, byte style, Func seaAt) + { + int total = n * n; + var gcost = new float[total]; + var parent = new int[total]; + var closed = new bool[total]; + Array.Fill(gcost, float.MaxValue); + Array.Fill(parent, -1); + + float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT); + + var pq = new PriorityQueue(); + int start = sx * n + sy; + gcost[start] = 0f; + pq.Enqueue(start, (0f, start)); + int goal = -1; + + while (pq.Count > 0) + { + int c = pq.Dequeue(); + if (closed[c]) continue; + closed[c] = true; + if (isOcean[c]) { goal = c; break; } + int cx = c / n, cy = c % n; + float hc = height[cx, cy]; + 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 (closed[ni]) continue; + float dhM = MathF.Max(0f, (height[nx, ny] - hc) * M_PER_UNIT); + float step = style == STYLE_SHORT + ? DIST[k] + dhM * SHORT_UPHILL_PER_M + : DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M) + + dhM * LOWGROUND_UPHILL_PER_M; + float nc = gcost[c] + step; + if (nc < gcost[ni]) + { + gcost[ni] = nc; + parent[ni] = c; + pq.Enqueue(ni, (nc, ni)); + } + } + } + + var path = new List<(float x, float y)>(); + if (goal >= 0) + { + for (int c = goal; c >= 0; c = parent[c]) + path.Add((c / n, c % n)); + path.Reverse(); + } + return path; + } + + private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n, + Func seaAt, float coreSq, float craterCx, float craterCy, + Params p, Stats stats) + { + var course = new List<(float x, float y)>(trib.Course); + course.Reverse(); // head → confluence + CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null, + height, n, seaAt, coreSq, craterCx, craterCy, p, stats); + } + + /// + /// Carves one river: densify the course, build a monotone-descending clamped + /// bed profile, stamp the channel. Returns the per-river stat (also appended + /// to stats.Rivers unless name is null — tributaries fold into the totals). + /// + private static RiverStat CarveRiver(string name, string kind, long drainagePx, + List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute, + float[,] height, int n, Func seaAt, + float coreSq, float craterCx, float craterCy, Params p, Stats stats) + { + // Full head→mouth polyline: upland stem, then the lowland reach if any. + var pts = new List<(float x, float y)>(upland); + if (lowlandRoute != null && lowlandRoute.Count > 1) + pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1)); + + // Densify to ~1-px samples (plan courses are decimated ×4). + var dense = new List<(float x, float y)>(); + for (int i = 0; i + 1 < pts.Count; i++) + { + var a = pts[i]; var b = pts[i + 1]; + float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)); + int steps = Math.Max(1, (int)MathF.Ceiling(segLen)); + for (int s2 = 0; s2 < steps; s2++) + dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps)); + } + if (pts.Count > 0) dense.Add(pts[^1]); + if (dense.Count < 2) return new RiverStat(); + + float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f); + var rs = new RiverStat + { + Name = name, Kind = kind, DrainagePx = drainagePx, + CourseLenPx = dense.Count, + RouteLenPx = lowlandRoute?.Count ?? 0 + }; + if (lowlandRoute != null && lowlandRoute.Count > 1) + { + var a = lowlandRoute[0]; var b = lowlandRoute[^1]; + rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)); + // Wander = POLYLINE length over straight-line — cell count undercounts + // diagonal steps and can read below 1, which is geometrically impossible. + float polyLen = 0f; + for (int i = 1; i < lowlandRoute.Count; i++) + { + float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x; + float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y; + polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy); + } + rs.RouteLenPx = (int)polyLen; + rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f; + } + + // Bed profile: raw = terrain − depth(t); then monotone non-increasing + // downstream; then clamped to sea + margin. The clamp can flatten the tail + // near the mouth — allowed: non-increasing is what water needs, and the + // flood guard is absolute. + int m = dense.Count; + var bed = new float[m]; + var depth = new float[m]; + var halfW = new float[m]; + for (int i = 0; i < m; i++) + { + float t = m > 1 ? (float)i / (m - 1) : 1f; + depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale; + if (depth[i] < 0.5f) depth[i] = 0.5f; + halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale; + if (halfW[i] < 1.5f) halfW[i] = 1.5f; + int cx = (int)dense[i].x, cy = (int)dense[i].y; + bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT; + } + for (int i = 1; i < m; i++) + { + float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT; + if (bed[i] > maxAllowed) bed[i] = maxAllowed; + } + for (int i = 0; i < m; i++) + { + int cx = (int)dense[i].x, cy = (int)dense[i].y; + float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT; + if (bed[i] < floor) bed[i] = floor; + } + + // Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain. + for (int i = 0; i < m; i++) + { + float hw = halfW[i]; + float outer = hw * 2f; + int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer); + int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer); + float rimH = bed[i] + depth[i] / M_PER_UNIT; + for (int x = cx0; x <= cx1; x++) + { + if (x < 0 || x >= n) continue; + for (int y = cy0; y <= cy1; y++) + { + if (y < 0 || y >= n) continue; + float rx = x - dense[i].x, ry = y - dense[i].y; + float r = MathF.Sqrt(rx * rx + ry * ry); + if (r > outer) continue; + float ddx = x - craterCx, ddy = y - craterCy; + if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected + float sea = seaAt(x, y); + float old = height[x, y]; + if (old < sea) continue; // below-sea cells read-only + float target; + if (r <= hw) + { + float f = r / hw; + target = bed[i] + (depth[i] / M_PER_UNIT) * f * f; + } + else + { + float f = (r - hw) / hw; // 0..1 across the shoulder + f = f * f * (3f - 2f * f); // smoothstep + target = rimH + (old - rimH) * f; + } + float floor = sea + p.SeaMarginM / M_PER_UNIT; + if (target < floor) target = floor; + if (target < old) + { + float cutM = (old - target) * M_PER_UNIT; + height[x, y] = target; + stats.CarvedCells++; + stats.CarvedVolumeM3 += cutM; + if (cutM > stats.MaxCutM) stats.MaxCutM = cutM; + if (cutM > rs.MaxCutM) rs.MaxCutM = cutM; + rs.VolumeM3 += cutM; + } + } + } + } + + if (name != null) stats.Rivers.Add(rs); + return rs; + } +}