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.
867 lines
53 KiB
C#
867 lines
53 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — route the promoted rivers, and make the MIX visible.
|
||
///
|
||
/// ═══ WHAT THIS TASK IS FOR ═══
|
||
///
|
||
/// rivers/02 promoted a set; this makes each member reach its true terminus, and shows the split the
|
||
/// developer asked to see clearly:
|
||
///
|
||
/// NATURAL OCEAN TRUNKS sea-reaching already, left exactly as erosion made them
|
||
/// ROUTED-THROUGH GIANTS endorheic basins connected to the coast over their rim
|
||
/// INLAND LAKE-ENDERS staying inland, joining a significant water body
|
||
///
|
||
/// The judgment it feeds: **does routing the giants yield enough substantial, well-spread coastal
|
||
/// rivers to dissolve the parked sea-river quota (rivers/02b), or not?**
|
||
///
|
||
/// ═══ ⛔ THE RED LINE — COURSES ONLY ═══
|
||
///
|
||
/// **No height is mutated. No water is filled. Nothing is carved.** This ports the ROUTING PORTION
|
||
/// of the reference's `RiverCarvePass` — `RouteToOcean`, the routed/lake-ender sort, `SmoothCourse`
|
||
/// — and deliberately NOT `CarveRiver` (bed stamp) or `AddSteppedWater` (water bodies), which are
|
||
/// separate later tasks. The tool ASSERTS both height fields are unchanged across routing, in the
|
||
/// flood-guard discipline erosion and drainage established: a claim that is checked, not promised.
|
||
///
|
||
/// ═══ RUNNING IT ═══
|
||
///
|
||
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverRoutingTool.tscn
|
||
///
|
||
/// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
|
||
/// ISLA_PROMOTE_N the promoted count to route (default 12 — the PURE set, no quota)
|
||
/// ISLA_PROMOTE_FLOOR_PX candidate significance floor (default 5000, as rivers/02)
|
||
/// ISLA_PROMOTE_MAX analysis reporting caps, so every promoted river has a traced stem (24)
|
||
/// ISLA_ROUTING_STYLE "lowground" (default, the locked style) | "short"
|
||
/// ISLA_LAKE_MIN_TARGET_PX significant-water threshold (default 20000, the reference's effective)
|
||
/// </summary>
|
||
public partial class RiverRoutingTool : Node
|
||
{
|
||
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
|
||
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||
private const int DefaultMapSize = 8192;
|
||
private const int DefaultCalibSize = 2048;
|
||
|
||
public override void _Ready()
|
||
{
|
||
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits with no
|
||
// main loop to end it, so a misconfigured run HANGS. Catch, say what was refused, exit 2.
|
||
try { Run(); }
|
||
catch (Exception e)
|
||
{
|
||
GD.PrintErr("==================================================================");
|
||
GD.PrintErr($" REFUSED: {e.Message}");
|
||
GD.PrintErr(e.StackTrace);
|
||
GD.PrintErr("==================================================================");
|
||
GetTree().Quit(2);
|
||
}
|
||
}
|
||
|
||
private sealed class SeedResult
|
||
{
|
||
public int Seed;
|
||
public List<RiverRouting.RoutedRiver> Rivers;
|
||
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;
|
||
/// <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
|
||
/// coordinating them, so two can land on the same cell. This is the honest river count.</summary>
|
||
public int DistinctMouths;
|
||
/// <summary>Pairs of sea-reaching rivers sharing a mouth cell, as "#a+#b".</summary>
|
||
public List<string> SharedMouths = new();
|
||
public long LandCells, EndorheicCells;
|
||
public int CandidateCount, SeaCandidates, EndoCandidates;
|
||
public int WaterBodiesKept, WaterBodiesTotal;
|
||
public long WaterCellsKept, LargestWaterPx;
|
||
public double RoutingSeconds;
|
||
public long TotalExpanded;
|
||
public string Spread = "";
|
||
public ulong Ms;
|
||
}
|
||
|
||
private void Run()
|
||
{
|
||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||
|
||
// ⭐ rivers/03b — the three approved DIVERGENCES. Default OFF, so this tool still reproduces
|
||
// rivers/03's faithful port bit-for-bit.
|
||
bool refined = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant() == "refined";
|
||
float rimCapM = float.TryParse(EnvStr("ISLA_RIM_CAP_M", "30"), out float rc) ? rc : 30f;
|
||
var opt = refined
|
||
? new RiverRouting.Options { RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true }
|
||
: RiverRouting.Options.Faithful;
|
||
|
||
int task = EnvInt("ISLA_TASK", 3);
|
||
// ⭐ rivers/03b is a LETTERED SUB-TASK of 03 — same authoring task, three changed rules.
|
||
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", refined ? "b" : "");
|
||
string descr = EnvStr("ISLA_BATCH", refined ? "routing_refinement" : "lowland_routing");
|
||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
|
||
int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
|
||
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
|
||
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_TARGET_PX", RiverRouting.LakeMinTargetPx);
|
||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
|
||
string styleS = EnvStr("ISLA_ROUTING_STYLE", "lowground").Trim().ToLowerInvariant();
|
||
if (styleS != "lowground" && styleS != "short")
|
||
throw new InvalidOperationException($"ISLA_ROUTING_STYLE '{styleS}' — expected 'lowground' (the locked style) or 'short'.");
|
||
byte style = styleS == "short" ? RiverRouting.StyleShort : RiverRouting.StyleLowground;
|
||
|
||
if (promoteMax < promoteN)
|
||
throw new InvalidOperationException(
|
||
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}). The cap is what makes the " +
|
||
"analysis trace a real upland stem for each promoted river; below it, a river would have no course to route from.");
|
||
|
||
// ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating.
|
||
TerrainShapeV1.Assert("RiverRouting");
|
||
TerrainShapeV1.AssertErosionDefaultOn("RiverRouting");
|
||
|
||
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
|
||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||
|
||
var anchors = CurveAnchors.Default;
|
||
float sea = 0.15f;
|
||
|
||
// Reporting caps raised so every promoted river has a traced stem — exactly as rivers/02.
|
||
// ⚠⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are NOT touched: they
|
||
// decide which depressions BECOME terminal basins, i.e. they define the routing surface.
|
||
var dp = new DrainageAnalysis.Params
|
||
{
|
||
SeaLevel = sea,
|
||
TrunkCount = promoteMax,
|
||
GiantCount = promoteMax,
|
||
EndorheicMaxCount = promoteMax,
|
||
EndorheicMinInflowPx = (int)floorPx,
|
||
};
|
||
var dpDefaults = new DrainageAnalysis.Params();
|
||
|
||
GD.Print("==================================================================");
|
||
GD.Print(refined
|
||
? " ROUTING REFINEMENT (rivers/03b) — three DELIBERATE DIVERGENCES from the faithful port, COURSES ONLY"
|
||
: " LOWLAND ROUTING (rivers/03) — the routed MIX on the pure top-N, COURSES ONLY");
|
||
GD.Print("==================================================================");
|
||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
|
||
GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). NO QUOTA — rivers/02b's K stays parked and unlocked.");
|
||
GD.Print($"style : {styleS.ToUpperInvariant()} — cost = DIST x ({RiverRouting.LowgroundBase} + elevM x {RiverRouting.LowgroundElevPerM}) + climbM x {RiverRouting.LowgroundUphillPerM} (effective == declared; verified no ConfigManager/[Export] override)");
|
||
GD.Print($"sort : the REFERENCE's — Kind = (basinHasLake && !southern) ? lake-ender : routed.");
|
||
GD.Print($" ⚠ NOT a path test: on an 8-connected grid a path to the ocean ALWAYS exists, so");
|
||
GD.Print($" \"a path exists → routed\" would classify everything as routed. The ocean route is");
|
||
GD.Print($" still probed for EVERY giant so the missing affordability threshold is a number, not a guess.");
|
||
if (refined)
|
||
{
|
||
GD.Print("⚠⚠ THREE DELIBERATE DIVERGENCES FROM THE REFERENCE — approved, and NOT a port:");
|
||
GD.Print($" FIX 1 RIM CAP {rimCapM:F0} m — a route to the sea that must climb higher than this above its");
|
||
GD.Print( " terminal is REFUSED; the river becomes a walled-off inland lake-ender. (Reference: routes at ANY cost.)");
|
||
GD.Print( " FIX 3 LAKE TARGETS — a router stops at the nearer of {ocean, significant lake}, so it cannot");
|
||
GD.Print( " skirt a lake to reach a distant coast. (Reference: routers target ocean only.)");
|
||
GD.Print( " FIX 2 CONFLUENCE — courses laid biggest-first join on TRUE CELL INTERSECTION and the smaller");
|
||
GD.Print( " becomes a tributary. (Reference: no dedup, no join — parallel duplicates to one mouth.)");
|
||
GD.Print( " ⚠ KEPT: basinHasLake stays the sort — a basin that already holds a lake is a natural lake-ender.");
|
||
}
|
||
GD.Print($"lake target: significant water = 8-connected classify-water components >= {lakeMinPx:N0} px (interim for v2's missing water-bodies table)");
|
||
GD.Print($"⛔ RED LINE : courses only — no height mutated, no water filled, nothing carved. ASSERTED per seed.");
|
||
GD.Print($"batch : {batchRoot}");
|
||
GD.Print("==================================================================");
|
||
|
||
if (mapSize != 8192)
|
||
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params are ABSOLUTE PIXEL COUNTS tuned at 8192 " +
|
||
"(rivers/02). At a smaller size few or no depressions qualify as terminal basins, so the " +
|
||
"routed/lake-ender SORT cannot be exercised. A smaller run validates the PIPELINE only and " +
|
||
"MUST NOT be used to judge the mix.");
|
||
|
||
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
|
||
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||
GD.Print($" {knots}");
|
||
|
||
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
|
||
{
|
||
MapSize = size, Seed = seed, VariantLabel = "routing",
|
||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||
};
|
||
|
||
var results = new List<SeedResult>();
|
||
foreach (int seed in seeds)
|
||
{
|
||
ulong t0 = Time.GetTicksMsec();
|
||
GD.Print($"\n--- seed {seed} ---");
|
||
var cfg = Cfg(mapSize, seed);
|
||
Pass1Result p1 = Topography.Generate(cfg);
|
||
Pass2Result shaped = Shaping.Shape(p1, cfg);
|
||
var ero = ErosionPass.Apply(shaped, cfg);
|
||
Pass2Result p2 = ero.Shaped;
|
||
|
||
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066).
|
||
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
|
||
var isClassifyWater = new bool[mapSize * mapSize];
|
||
for (int x = 0; x < mapSize; x++)
|
||
for (int y = 0; y < mapSize; y++)
|
||
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
|
||
|
||
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
|
||
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
|
||
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %); terminal basins {plan.TerminalBasinCount}");
|
||
|
||
var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
|
||
var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
|
||
RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
|
||
int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
|
||
GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates: {e.SeaCount} sea / {e.Ranked.Count - e.SeaCount} endorheic)");
|
||
|
||
// ⭐ The interim significant-water mask — v2 has no water-bodies table (a known port gap).
|
||
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
|
||
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
|
||
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest body {largestPx:N0} px)");
|
||
|
||
// ═══ ⛔ THE RED-LINE GUARD — snapshot both height fields BEFORE routing ═══
|
||
ulong hRenderBefore = Digest(p2.Height, mapSize);
|
||
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
|
||
|
||
GD.Print($" routing (style {styleS}) — probing the ocean for every giant:");
|
||
ulong tr0 = Time.GetTicksMsec();
|
||
var rivers = RiverRouting.RouteAll(promoted, p2.Height, mapSize, isOcean, isClassifyWater,
|
||
significant, sea, style, m => GD.Print(m), opt);
|
||
double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
|
||
|
||
// ═══ ⛔ …and assert they are byte-identical after ═══
|
||
ulong hRenderAfter = Digest(p2.Height, mapSize);
|
||
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
|
||
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
|
||
throw new InvalidOperationException(
|
||
"[RiverRouting] RED-LINE VIOLATION: a height field CHANGED across routing.\n" +
|
||
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
|
||
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
|
||
"This task produces COURSES ONLY — it must never mutate a height, fill water, or carve. " +
|
||
"The bed carve and the stepped-water model are separate later tasks. Refusing to continue.");
|
||
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across routing — nothing carved, no water filled.");
|
||
|
||
var r = new SeedResult
|
||
{
|
||
Seed = seed, Rivers = rivers,
|
||
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
|
||
CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount,
|
||
EndoCandidates = e.Ranked.Count - e.SeaCount,
|
||
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies,
|
||
WaterCellsKept = keptCells, LargestWaterPx = largestPx,
|
||
RoutingSeconds = routingSec,
|
||
};
|
||
foreach (var rr in rivers)
|
||
{
|
||
switch (rr.Class)
|
||
{
|
||
case RiverRouting.RiverClass.OceanTrunk: r.Trunks++; break;
|
||
case RiverRouting.RiverClass.RoutedGiant: r.Routed++; break;
|
||
case RiverRouting.RiverClass.LakeFed: r.LakeFed++; break;
|
||
case RiverRouting.RiverClass.WalledOff: r.Walled++; break;
|
||
default: r.Lakes++; break;
|
||
}
|
||
if (rr.OceanProbe != null) r.TotalExpanded += rr.OceanProbe.Expanded;
|
||
if (rr.Joined) { r.Joined++; r.Joins.Add($"#{rr.Candidate.Rank}→#{rr.ConfluenceParentRank} at ({rr.JunctionCell.x},{rr.JunctionCell.y})"); }
|
||
// ⭐ Make every reclassification the divergences caused legible, not silent.
|
||
if (rr.RefusedByCap) r.CapMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px, rim {rr.CappedRimM:F1} m)");
|
||
if (rr.Class == RiverRouting.RiverClass.LakeFed) r.LakeMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px)");
|
||
}
|
||
// ⚠ A tributary has NO mouth of its own — it reaches the sea through its trunk. So the two
|
||
// numbers are different and both are reported: how many rivers END at the sea, and how many
|
||
// rivers' water GETS there.
|
||
foreach (var rr in rivers)
|
||
{
|
||
if (!rr.Joined && rr.ReachesSea) r.SeaReaching++;
|
||
if (RiverRouting.Root(rr, rivers).ReachesSea) r.SeaConnected++;
|
||
}
|
||
MeasureMouths(r, rivers);
|
||
r.Spread = Spread(rivers, mapSize);
|
||
r.Ms = Time.GetTicksMsec() - t0;
|
||
|
||
GD.Print($" ⭐ MIX: {r.Trunks} trunks + {r.Routed} routed-to-sea + {r.LakeFed} lake-fed + {r.Lakes} natural lake-enders + {r.Walled} walled-off = {rivers.Count}" +
|
||
(r.Joined > 0 ? $" ({r.Joined} joined as tributaries)" : ""));
|
||
GD.Print($" → {r.DistinctMouths} DISTINCT SEA MOUTHS from {r.SeaReaching} river(s) ending at the sea; {r.SeaConnected} rivers' water reaches the sea" +
|
||
(r.SharedMouths.Count > 0 ? $" ⚠⚠ STILL SHARED: {string.Join(", ", r.SharedMouths)}" : " ✅ no two rivers share a mouth") +
|
||
$" spread: {r.Spread}");
|
||
if (r.CapMoved.Count > 0) GD.Print($" ⚠ rim cap moved routed→walled-off: {string.Join(", ", r.CapMoved)}");
|
||
if (r.LakeMoved.Count > 0) GD.Print($" ⭐ lake-target moved routed→lake-fed: {string.Join(", ", r.LakeMoved)}");
|
||
GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
|
||
|
||
WriteRiverCsv(batchRoot, r);
|
||
RenderSeed(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, promoteN, skipRaw, refined, rimCapM);
|
||
results.Add(r);
|
||
}
|
||
|
||
if (refined) WriteRefinedIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, dpDefaults, skipRaw);
|
||
else WriteIndex(batchRoot, mapSize, calibSize, seeds, results, promoteN, floorPx, promoteMax, lakeMinPx, styleS, dpDefaults, skipRaw);
|
||
GD.Print("\n==================================================================");
|
||
GD.Print($" DONE — {batchRoot}");
|
||
GD.Print(" ⛔ TASTE GATE: the MIX is PRESENTED, not decided. No count, no K, no style, no default was set.");
|
||
GD.Print(" ⛔ COURSES ONLY: no height mutated, no water filled, nothing carved — asserted per seed.");
|
||
GD.Print("==================================================================");
|
||
GetTree().Quit(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// FNV-1a over the raw float bits of a whole field. ⚠ Over the BITS, not the values: this must
|
||
/// catch a change no float comparison would (a −0 written over a +0, a NaN payload), because the
|
||
/// claim being checked is "byte-identical", not "numerically close".
|
||
/// </summary>
|
||
private static ulong Digest(float[,] f, int n)
|
||
{
|
||
ulong h = 14695981039346656037UL;
|
||
for (int x = 0; x < n; x++)
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
|
||
for (int b = 0; b < 4; b++)
|
||
{
|
||
h ^= (byte)(bits >> (b * 8));
|
||
h *= 1099511628211UL;
|
||
}
|
||
}
|
||
return h;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ How many DISTINCT places the island's rivers actually meet the sea.
|
||
///
|
||
/// ⚠⚠ NOT the same as the sea-reaching count, and the difference is not a rounding detail. Each
|
||
/// giant is routed INDEPENDENTLY to its nearest ocean cell, with nothing coordinating the
|
||
/// routes — so two basins whose cheapest corridor is the same valley arrive at the same cell and
|
||
/// share one mouth. The analysis dedups NATURAL outlets (`MinOutletSeparationPx = 400`); nothing
|
||
/// dedups ROUTED ones. Measured rather than assumed, because "how many coastal rivers" is the
|
||
/// question this whole gate exists to answer.
|
||
/// </summary>
|
||
private static void MeasureMouths(SeedResult r, List<RiverRouting.RoutedRiver> rivers)
|
||
{
|
||
var at = new Dictionary<(int x, int y), List<int>>();
|
||
foreach (var rr in rivers)
|
||
{
|
||
// ⚠ A tributary adopted its trunk's terminus — it is not a separate mouth.
|
||
if (rr.Joined || !rr.ReachesSea) continue;
|
||
(int x, int y) key;
|
||
if (rr.Class == RiverRouting.RiverClass.OceanTrunk) key = (rr.Candidate.TermX, rr.Candidate.TermY);
|
||
else if (rr.Lowland != null && rr.Lowland.Reached) key = ((int)rr.Lowland.Target.x, (int)rr.Lowland.Target.y);
|
||
else continue;
|
||
if (!at.TryGetValue(key, out var l)) { l = new List<int>(); at[key] = l; }
|
||
l.Add(rr.Candidate.Rank);
|
||
}
|
||
r.DistinctMouths = at.Count;
|
||
foreach (var kv in at)
|
||
if (kv.Value.Count > 1)
|
||
r.SharedMouths.Add("#" + string.Join("+#", kv.Value) + $" at ({kv.Key.x},{kv.Key.y})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Where the sea-reaching rivers actually meet the coast. ⚠ In this codebase a cell is
|
||
/// <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is SOUTH on the plate.
|
||
/// </summary>
|
||
private static string Spread(List<RiverRouting.RoutedRiver> rivers, int n)
|
||
{
|
||
var counts = new Dictionary<string, int>();
|
||
int total = 0;
|
||
foreach (var r in rivers)
|
||
{
|
||
if (r.Joined || !r.ReachesSea) continue;
|
||
float mx, my;
|
||
if (r.Class == RiverRouting.RiverClass.OceanTrunk) { mx = r.Candidate.TermX; my = r.Candidate.TermY; }
|
||
else if (r.Lowland != null && r.Lowland.Reached) { mx = r.Lowland.Target.x; my = r.Lowland.Target.y; }
|
||
else continue;
|
||
string c = Compass(mx, my, n);
|
||
counts.TryGetValue(c, out int cur);
|
||
counts[c] = cur + 1;
|
||
total++;
|
||
}
|
||
if (total == 0) return "none";
|
||
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
|
||
var parts = new List<string>();
|
||
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
|
||
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
|
||
}
|
||
|
||
private static string Compass(float x, float y, int n)
|
||
{
|
||
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
|
||
const float band = 0.35f;
|
||
string ns = dy < -band ? "N" : dy > band ? "S" : "";
|
||
string ew = dx < -band ? "W" : dx > band ? "E" : "";
|
||
return ns + ew == "" ? "centre" : ns + ew;
|
||
}
|
||
|
||
private static string ClassName(RiverRouting.RiverClass c) => c switch
|
||
{
|
||
RiverRouting.RiverClass.OceanTrunk => "trunk",
|
||
RiverRouting.RiverClass.RoutedGiant => "routed",
|
||
RiverRouting.RiverClass.LakeFed => "lake-fed",
|
||
RiverRouting.RiverClass.WalledOff => "walled-off",
|
||
_ => "lake-ender",
|
||
};
|
||
|
||
private static void WriteRiverCsv(string batchRoot, SeedResult r)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("rank,class,basin_has_lake,analysis_kind,faithful_class,terminus_type,drainage_px,term_x,term_y,course_pts," +
|
||
"route_reached,route_target_x,route_target_y,route_len_px,route_straight_px,wander," +
|
||
"route_cost,rim_climb_m,cap_verdict,max_step_uphill_m,max_elev_m,total_uphill_m,uphill_steps," +
|
||
"rim_x,rim_y,cells_expanded,lake_reached,lake_was_fallback,joined,confluence_parent_rank," +
|
||
"junction_x,junction_y,stem_width_px,why");
|
||
foreach (var rr in r.Rivers)
|
||
{
|
||
var c = rr.Candidate;
|
||
var lo = rr.Lowland;
|
||
// ⚠ For a lake-ender the OCEAN PROBE is reported too (in the rim/cost columns of the
|
||
// probe row below) — those are what an affordability threshold would be set against.
|
||
var pr = rr.OceanProbe;
|
||
sb.AppendLine($"{c.Rank},{ClassName(rr.Class)},{(c.IsSea ? "" : (c.AnalysisKind == "lake-ender" ? "yes" : "no"))},{(c.IsSea ? "" : c.AnalysisKind)}," +
|
||
$"{(c.IsSea ? "" : ClassName(rr.FaithfulClass))},{c.TerminusName},{c.DrainagePx},{c.TermX},{c.TermY},{rr.Course.Count}," +
|
||
$"{(lo != null && lo.Reached ? "yes" : "no")},{(lo != null && lo.Reached ? ((int)lo.Target.x).ToString() : "")},{(lo != null && lo.Reached ? ((int)lo.Target.y).ToString() : "")}," +
|
||
$"{(lo != null ? lo.LenPx.ToString("F1") : "")},{(lo != null ? lo.StraightPx.ToString("F1") : "")},{(lo != null ? lo.WanderRatio.ToString("F3") : "")}," +
|
||
$"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")},{(pr != null ? pr.RimClimbM.ToString("F2") : "")}," +
|
||
$"{(rr.RefusedByCap ? "REFUSED" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under cap" : "")}," +
|
||
$"{(pr != null ? pr.MaxStepUphillM.ToString("F3") : "")}," +
|
||
$"{(pr != null ? pr.MaxElevM.ToString("F2") : "")},{(pr != null ? pr.TotalUphillM.ToString("F2") : "")},{(pr != null ? pr.UphillSteps.ToString() : "")}," +
|
||
$"{(pr != null && pr.Reached ? ((int)pr.RimPoint.x).ToString() : "")},{(pr != null && pr.Reached ? ((int)pr.RimPoint.y).ToString() : "")}," +
|
||
$"{(pr != null ? pr.Expanded.ToString() : "")},{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeReached ? "yes" : "no") : "")}," +
|
||
$"{(rr.Class == RiverRouting.RiverClass.LakeEnder ? (rr.LakeWasFallback ? "yes" : "no") : "")}," +
|
||
$"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")}," +
|
||
$"{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," +
|
||
$"{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{rr.Why}\"");
|
||
}
|
||
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
|
||
}
|
||
|
||
private static void RenderSeed(string batchRoot, SeedResult r, bool[] isOcean, Pass2Result p2,
|
||
int n, float sea, long floorPx, int promoteN, bool skipRaw, bool refined, float rimCapM)
|
||
{
|
||
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||
|
||
if (refined)
|
||
DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n,
|
||
$"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)",
|
||
$"{r.DistinctMouths} DISTINCT SEA MOUTHS - {r.Trunks} TRUNK + {r.Routed} ROUTED + {r.LakeFed} LAKE-FED + {r.Lakes} NATURAL LAKE-ENDER + {r.Walled} WALLED-OFF, {r.Joined} JOINED. SPREAD: {r.Spread.ToUpperInvariant()}",
|
||
$"CHEAPEST ROUTE TO THE SEA CLIMBS MORE THAN THE {rimCapM:F0} M RIM CAP, SO IT ENDS AT ITS OWN TERMINAL")
|
||
.SavePng(Path.Combine(dir, "refined_mix.png"));
|
||
else
|
||
DrainageRenderer.RoutedMix(r.Rivers, baseImg.Duplicate() as Image, n,
|
||
$"SEED {r.Seed} - THE ROUTED MIX ON THE PURE TOP {promoteN}",
|
||
$"{r.Trunks} NATURAL TRUNKS + {r.Routed} ROUTED-THROUGH + {r.Lakes} LAKE-ENDERS = {r.SeaReaching} SEA-REACHING RIVERS. SPREAD: {r.Spread.ToUpperInvariant()}",
|
||
floorPx)
|
||
.SavePng(Path.Combine(dir, "routed_mix.png"));
|
||
|
||
// Grayscale beside the pretty render — the field must be inspectable without the palette.
|
||
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
|
||
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
|
||
}
|
||
|
||
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds,
|
||
List<SeedResult> rows, int promoteN, long floorPx, int promoteMax, int lakeMinPx,
|
||
string styleS, DrainageAnalysis.Params def, bool skipRaw)
|
||
{
|
||
var sb = new StringBuilder();
|
||
int primary = seeds.Length > 0 ? seeds[0] : 0;
|
||
|
||
sb.AppendLine($"# Batch 03 — lowland routing: the MIX on the pure top {promoteN}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — no count, no K, no routing style, no default in");
|
||
sb.AppendLine("`TerrainGenConfig` or `DrainageAnalysis.Params`.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⛔ COURSES ONLY. No height was mutated, no water was filled, nothing was carved** — asserted");
|
||
sb.AppendLine("per seed by an FNV digest of both height fields taken before and after routing. The bed carve and");
|
||
sb.AppendLine("the stepped-water model are separate later tasks.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## 👉 The pick");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Open **`{primary}/routed_mix.png`**. Then check the other three: " +
|
||
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||
sb.AppendLine("> **Does routing the giants give enough substantial, well-distributed coastal rivers to dissolve");
|
||
sb.AppendLine("> the parked sea-river quota (rivers/02b's K) — or not?**");
|
||
sb.AppendLine(">");
|
||
sb.AppendLine("> Read three things off the plate: **how many** rivers now reach the sea, **how big** they are");
|
||
sb.AppendLine("> (the fixed width scale is shared with rivers/02b, so widths are comparable across both tasks),");
|
||
sb.AppendLine("> and **where** they land (the spread column below). Then read the rim column — a route that");
|
||
sb.AppendLine("> climbs a large rim is a channel cut over a wall, which may or may not be acceptable geography.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**Colour key.** Cyan = natural ocean trunk (erosion already reached the coast). Green = routed-through");
|
||
sb.AppendLine("giant, **dark for its natural upland stem and bright for the lowland reach routing added** — so the");
|
||
sb.AppendLine("plate separates terrain from routing. A yellow ring on a green reach is the **rim it climbed over**.");
|
||
sb.AppendLine("Orange = inland lake-ender, disc at its terminal and ring at the water body it joins.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ The MIX, per seed");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"| Seed | natural trunks | routed-through | lake-enders | sea-reaching | ⭐⭐ DISTINCT MOUTHS | shared mouth | spread |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||
foreach (var r in rows)
|
||
sb.AppendLine($"| `{r.Seed}` | {r.Trunks} | {r.Routed} | {r.Lakes} | {r.SeaReaching} of {r.Rivers.Count} | " +
|
||
$"**{r.DistinctMouths}** | {(r.SharedMouths.Count > 0 ? string.Join("; ", r.SharedMouths) : "—")} | {r.Spread} |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠⚠ READ THE *DISTINCT MOUTHS* COLUMN, NOT THE SEA-REACHING ONE.");
|
||
sb.AppendLine("> **On every seed, two routed rivers arrive at the SAME ocean cell.** Each giant is routed");
|
||
sb.AppendLine("> independently to its nearest ocean cell and nothing coordinates the routes, so two basins whose");
|
||
sb.AppendLine("> cheapest corridor is the same valley share one mouth. The analysis dedups NATURAL outlets");
|
||
sb.AppendLine($"> (`MinOutletSeparationPx = {def.MinOutletSeparationPx}`); **nothing dedups ROUTED ones.**");
|
||
sb.AppendLine(">");
|
||
sb.AppendLine("> Whether that is a defect or a delta is a real judgment: two rivers meeting at one mouth is a");
|
||
sb.AppendLine("> confluence, which is ordinary geography — but they arrive there *without ever having joined*,");
|
||
sb.AppendLine("> which is not. It is reported rather than deduped, because deduping would silently drop a");
|
||
sb.AppendLine("> promoted river the developer chose. **→ a decision for the carve/water tasks.**");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠ Compare against rivers/02b before concluding");
|
||
sb.AppendLine($"> On the pure top {promoteN} the *unrouted* sea count was 1–2 per seed. The SEA-REACHING column above is");
|
||
sb.AppendLine("> what routing turns that into. **If it is comfortably above the quota's K, the quota is redundant");
|
||
sb.AppendLine("> — that was the question rivers/02b parked.** The spread column is the second half of the answer:");
|
||
sb.AppendLine("> a count that all lands on one coast does not serve placement.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐⭐ The per-river diagnostic — why each river landed where it did");
|
||
sb.AppendLine();
|
||
sb.AppendLine("`rim climb` is metres from the basin floor to the route's high point — **the wall the channel crosses**.");
|
||
sb.AppendLine("`max step` is the steepest single 1-px climb on it. `wander` is polyline length over straight-line.");
|
||
sb.AppendLine("**For lake-enders the rim/cost columns are the OCEAN PROBE** — what it *would* have cost to route them");
|
||
sb.AppendLine("to the sea. That is the number an affordability threshold would be set against (see the sort note).");
|
||
sb.AppendLine();
|
||
foreach (var r in rows)
|
||
{
|
||
sb.AppendLine($"### `{r.Seed}`");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| rank | class | drainage px | terminus | route len px | wander | ⭐ rim climb m | max step m | route cost | cells settled |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
|
||
foreach (var rr in r.Rivers)
|
||
{
|
||
var c = rr.Candidate; var pr = rr.OceanProbe; var lo = rr.Lowland;
|
||
string cls = rr.Class switch
|
||
{
|
||
RiverRouting.RiverClass.OceanTrunk => "trunk",
|
||
RiverRouting.RiverClass.RoutedGiant => "**routed**",
|
||
_ => "lake-ender",
|
||
};
|
||
string term = 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",
|
||
_ => rr.LakeReached ? $"lake ({(int)lo.Target.x},{(int)lo.Target.y}){(rr.LakeWasFallback ? " ⚠fallback" : "")}" : "⚠ its own terminal",
|
||
};
|
||
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {term} | " +
|
||
$"{(lo != null && lo.Reached ? lo.LenPx.ToString("F0") : "—")} | " +
|
||
$"{(lo != null && lo.Reached ? lo.WanderRatio.ToString("F2") : "—")} | " +
|
||
$"{(pr != null && pr.Reached ? $"**{pr.RimClimbM:F1}**" : "—")} | " +
|
||
$"{(pr != null && pr.Reached ? pr.MaxStepUphillM.ToString("F2") : "—")} | " +
|
||
$"{(pr != null && pr.Reached ? pr.Cost.ToString("N0") : "—")} | " +
|
||
$"{(pr != null ? pr.Expanded.ToString("N0") : "—")} |");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea / {r.EndoCandidates} endorheic) · " +
|
||
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px " +
|
||
$"({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · routing {r.RoutingSeconds:F1}s, " +
|
||
$"{r.TotalExpanded:N0} cells settled.*");
|
||
sb.AppendLine();
|
||
}
|
||
sb.AppendLine("## ⚠⚠ The sort — what actually decides routed vs lake-ender, and the threshold nobody has set");
|
||
sb.AppendLine();
|
||
sb.AppendLine("The task states the sort as *\"an affordable over-the-rim LOWGROUND path to the ocean exists →");
|
||
sb.AppendLine("routed-through; none → lake-ender.\"* **Ported literally that classifies EVERYTHING as routed**, because");
|
||
sb.AppendLine("on an 8-connected grid with all-finite costs a path to the ocean always exists — `RouteToOcean`");
|
||
sb.AppendLine("returns empty only if the queue drains without reaching a target, which cannot happen. There is no");
|
||
sb.AppendLine("\"none\". The word carrying the meaning is *affordable*, and **no threshold is specified anywhere**.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("So this port uses **the reference's own sort**, which is the one that discriminates:");
|
||
sb.AppendLine();
|
||
sb.AppendLine("```csharp");
|
||
sb.AppendLine("Kind = (basinHasLake[id] && !SouthernCandidate) ? \"lake-ender\" : \"routed\"");
|
||
sb.AppendLine("```");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**Does the terminal basin already hold classify water?** A basin that is a lake is a natural");
|
||
sb.AppendLine("lake-ender; a dry pan is routed to the sea. That is `DrainageAnalysis.Giant.Kind`, consumed rather");
|
||
sb.AppendLine("than reinvented. (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**And the missing threshold is surfaced instead of guessed:** the ocean route is probed for EVERY");
|
||
sb.AppendLine("giant, lake-enders included, so the rim/cost columns above say exactly what routing each one would");
|
||
sb.AppendLine("cost. If the developer wants an affordability bar, those are the numbers to put it under.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## What was ported, and what was deliberately NOT");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Ported (the routing portion) | Not ported (later tasks) |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine("| `RouteToOcean` — deterministic LOWGROUND Dijkstra, uphill penalised, empty-on-no-path | `CarveRiver` — the bed stamp, **mutates render height** |");
|
||
sb.AppendLine("| the routed / lake-ender sort | `AddSteppedWater` — **creates water bodies** |");
|
||
sb.AppendLine("| lake-ender targeting (significant water, classify-water fallback) | tributary carving |");
|
||
sb.AppendLine("| `SmoothCourse` — RDP tol 4 + 4 Chaikin, endpoints pinned, **lowland reach only** | the densify-to-1px step (carve-time) |");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Cost model, effective == declared** (verified: these are `private const` in `RiverCarvePass` with no");
|
||
sb.AppendLine($"`ConfigManager` key and no `[Export]` anywhere in the reference repo):");
|
||
sb.AppendLine();
|
||
sb.AppendLine("```");
|
||
sb.AppendLine($"LOWGROUND step = DIST × ({RiverRouting.LowgroundBase} + elevM × {RiverRouting.LowgroundElevPerM}) + climbM × {RiverRouting.LowgroundUphillPerM}");
|
||
sb.AppendLine($"SHORT step = DIST + climbM × {RiverRouting.ShortUphillPerM} (rejected by the reference's gate as \"a dead-straight canal\")");
|
||
sb.AppendLine($"elevM = max(0, (height − sea) × {WorldScale.MetresPerRawUnit:F0}) — clamped at local sea");
|
||
sb.AppendLine("```");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Style used: **{styleS}** (the reference's effective `RiverRoutingStyle`). ⚠ The declared-vs-effective gap");
|
||
sb.AppendLine("`00_ground` warned about (WidthScale 1.0→1.75, DepthScale 1.0→1.5) is **carve-time and out of scope here**.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⚠ The `isSignificantWater` port gap.** The reference builds it from a water-bodies table");
|
||
sb.AppendLine($"(`PixelCount >= RiverLakeMinTargetPx`); **v2 has no such table.** Interim substitute, per the task:");
|
||
sb.AppendLine($"8-connected components of classify water (ocean excluded), keeping those ≥ {lakeMinPx:N0} px — same");
|
||
sb.AppendLine("threshold, same semantics, no table built. The size filter is the point: routing to the *nearest wet");
|
||
sb.AppendLine("pixel* put a lake-ender into a three-cell puddle short of the obvious lagoon (the reference's own finding).");
|
||
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($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON.");
|
||
sb.AppendLine($"Promoted set: the **pure top {promoteN}** by drainage — the unified ranking, **no quota** (rivers/02b's K stays parked).");
|
||
sb.AppendLine($"Reporting caps raised to `{promoteMax}` so every promoted river has a traced stem. Candidate floor `{floorPx:N0}` px.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m, `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they define the routing");
|
||
sb.AppendLine($"surface itself. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. `DrainageAnalysis` is reused,");
|
||
sb.AppendLine("never rebuilt or edited. Terminus classified by `RegionLabeling.OceanMask` (`Dir == D_SEA`) — no bare `h < sea`.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Files");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| File | What it is |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine("| `<seed>/routed_mix.png` | the three classes, with the added lowland reach and the rim it crossed drawn distinctly |");
|
||
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, analysis kind, route geometry, rim, cost, cells settled, why |");
|
||
if (skipRaw)
|
||
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `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/03_lowland_routing.report.md`");
|
||
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) -------------------
|
||
|
||
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
|
||
{
|
||
var rawPool = new LandHistogram(sea);
|
||
var pass1 = new Dictionary<int, Pass1Result>();
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||
pass1[s] = p1;
|
||
rawPool.Accumulate(p1.Height, calibSize);
|
||
}
|
||
var knots = new CurveKnots(2, "v2_balanced",
|
||
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
|
||
float ceilingRaw = knots.K2;
|
||
var rawAbove = new LandHistogram(sea);
|
||
var outAbove = new LandHistogram(sea);
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var scfg = new TerrainGenConfig
|
||
{
|
||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||
}.WithFamilyOff();
|
||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
}
|
||
var pcts = ClimbCalibration.DefaultPercentiles;
|
||
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
|
||
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
|
||
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
|
||
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
|
||
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
|
||
}
|
||
|
||
// ---- env / io -----------------------------------------------------------------------------
|
||
|
||
private static void WriteText(string path, string text)
|
||
{
|
||
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
|
||
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
|
||
f.StoreString(text);
|
||
}
|
||
|
||
private static string EnvStr(string k, string fallback)
|
||
{
|
||
string v = System.Environment.GetEnvironmentVariable(k);
|
||
return string.IsNullOrWhiteSpace(v) ? fallback : v;
|
||
}
|
||
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
|
||
private static int[] EnvSeeds(string k, int[] fallback)
|
||
{
|
||
string v = EnvStr(k, null);
|
||
if (v == null) return fallback;
|
||
var outp = new List<int>();
|
||
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
|
||
return outp.Count > 0 ? outp.ToArray() : fallback;
|
||
}
|
||
}
|
||
}
|