diff --git a/Tools/Scripts/RiverRouting.cs b/Tools/Scripts/RiverRouting.cs
index 3b0f963..d7d5284 100644
--- a/Tools/Scripts/RiverRouting.cs
+++ b/Tools/Scripts/RiverRouting.cs
@@ -297,6 +297,35 @@ namespace IslaApocalypse.Tools
/// False = the reference's behaviour (routers target ocean only).
public bool LakeTargetForRouters;
+ ///
+ /// ⭐⭐ rivers/03c FIX A — lake-termination becomes a PREFERENCE instead of an unconditional
+ /// capture. Set > 0 to enable; it then supersedes the plain nearest-of-union rule above.
+ ///
+ /// ⚠⚠ WHY rivers/03b OVERSHOT. "Nearest of {ocean ∪ lake}" lets a lake that is merely a
+ /// *little* closer capture a river that had a clear shot at the coast — and it moved **12
+ /// rivers** to lake-fed, roughly halving the island's sea mouths (5/5/6/5 → 4/2/3/3). The
+ /// rule here is deliberately sea-biased instead:
+ ///
+ /// lake-fed iff cost_lake < LakePreferRatio × cost_ocean
+ ///
+ /// so a lake must be MATERIALLY cheaper to reach, not just nearer. **Lower ratio → more sea
+ /// rivers.** Both costs are recorded per router whether or not the lake wins, so the knob can
+ /// be read off the table without a re-run.
+ ///
+ public float LakePreferRatio;
+
+ ///
+ /// ⭐⭐ rivers/03c FIX B — a NATURAL lake-ender terminates at the water inside its OWN
+ /// terminal basin, at any size.
+ ///
+ /// ⚠ The 20,000 px significance threshold is what exiled `999999937 #3` from its own home:
+ /// its basin's lake was sub-threshold, so it marched ~5,800 px along the shoreline hunting a
+ /// distant "significant" body. A basin's own water is where its flow goes regardless of how
+ /// big it is. **The threshold still applies to ROUTERS choosing a DISTANT lake** — a dry
+ /// basin still cannot connect itself to a three-cell puddle.
+ ///
+ public bool OwnBasinLakeEnder;
+
/// ⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell
/// intersection instead of running as parallel duplicates to the same mouth.
/// False = the reference's behaviour (no dedup, no join).
@@ -328,6 +357,14 @@ namespace IslaApocalypse.Tools
/// ⚠ The rim climb that was tested against the cap, and whether it was refused.
public float CappedRimM;
public bool RefusedByCap;
+
+ /// ⭐ rivers/03c FIX A's lever, recorded for EVERY router — lake-fed or not — so the
+ /// developer can read off which ratio value flips which river without a re-run.
+ /// is cost_lake / cost_ocean; a river is lake-fed iff it is below
+ /// the configured ratio. NaN where the leg was not reachable.
+ public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
+ /// Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.
+ public bool OwnBasinTargeted;
/// The class this river WOULD have had under the reference's rules — so every
/// reclassification the divergences caused is legible rather than silent.
public RiverClass FaithfulClass;
@@ -378,9 +415,13 @@ namespace IslaApocalypse.Tools
///
public static List RouteAll(List promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
- Action log, Options opt = null)
+ Action log, Options opt = null, int[] basinId = null)
{
opt ??= Options.Faithful;
+ if (opt.OwnBasinLakeEnder && basinId == null)
+ throw new InvalidOperationException(
+ "[RiverRouting] OwnBasinLakeEnder needs Plan.BasinId to know which water is a basin's OWN. " +
+ "Pass it; refusing to silently fall back to the distant-significant-body rule that produced the coast-hugger.");
// ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which
// is the reference. With it on, a significant lake is an equally valid place for a river to
@@ -422,18 +463,41 @@ namespace IslaApocalypse.Tools
bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder)
{
- // Route to the nearest of the target mask — {ocean} faithfully, {ocean ∪ lakes} refined.
- var route = opt.LakeTargetForRouters
- ? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
- : probe;
rr.FaithfulClass = RiverClass.RoutedGiant;
+ Route route;
+ bool stoppedAtLake;
+
+ if (opt.LakePreferRatio > 0f)
+ {
+ // ⭐⭐ rivers/03c FIX A — the two legs are costed SEPARATELY and compared, instead of
+ // racing in one search. That is the whole difference: a shared search returns
+ // whichever is nearer, this one returns the sea unless the lake is materially cheaper.
+ var lakeLeg = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, style, sea);
+ rr.CostOcean = probe.Reached ? probe.Cost : float.NaN;
+ rr.CostLake = lakeLeg.Reached ? lakeLeg.Cost : float.NaN;
+ rr.CostRatio = probe.Reached && lakeLeg.Reached && probe.Cost > 0f
+ ? lakeLeg.Cost / probe.Cost : float.NaN;
+
+ // ⚠ No reachable lake → the sea, always. No reachable ocean → the lake if there is one.
+ stoppedAtLake = lakeLeg.Reached && probe.Reached
+ && lakeLeg.Cost < opt.LakePreferRatio * probe.Cost;
+ if (lakeLeg.Reached && !probe.Reached) stoppedAtLake = true;
+ route = stoppedAtLake ? lakeLeg : probe;
+ }
+ else
+ {
+ // rivers/03b — the nearest of the union mask, whichever that turns out to be.
+ route = opt.LakeTargetForRouters
+ ? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
+ : probe;
+ stoppedAtLake = route.Reached
+ && isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
+ && !isOcean[(int)route.Target.x * n + (int)route.Target.y];
+ }
+
rr.Lowland = route;
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
- bool stoppedAtLake = route.Reached
- && isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
- && !isOcean[(int)route.Target.x * n + (int)route.Target.y];
-
if (!route.Reached)
{
rr.Class = RiverClass.RoutedGiant;
@@ -441,10 +505,12 @@ namespace IslaApocalypse.Tools
}
else if (stoppedAtLake)
{
- // ⭐ FIX 3 — it met a significant lake first. It terminates there. NO WATER CREATED:
- // the course simply ends at an existing body.
+ // It ends at a significant lake — because that lake was nearer (03b) or materially
+ // cheaper (03c). NO WATER CREATED: the course simply ends at an existing body.
rr.Class = RiverClass.LakeFed;
- rr.Why = $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
+ rr.Why = opt.LakePreferRatio > 0f
+ ? $"dry pan → LAKE-FED: reaching a significant lake costs {rr.CostLake:N0} vs {rr.CostOcean:N0} to the sea (ratio {rr.CostRatio:F3} < {opt.LakePreferRatio:F2}) — materially cheaper, so it ends at the lake"
+ : $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
}
else if (route.RimClimbM > opt.RimCapM)
{
@@ -458,28 +524,56 @@ namespace IslaApocalypse.Tools
else
{
rr.Class = RiverClass.RoutedGiant;
- rr.Why = $"dry pan → routed to the sea; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, max step {route.MaxStepUphillM:F2} m, cost {route.Cost:N0}";
+ rr.Why = $"dry pan → routed to the SEA; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, cost {route.Cost:N0}" +
+ (opt.LakePreferRatio > 0f && !float.IsNaN(rr.CostRatio) ? $"; the nearest lake was not materially cheaper (ratio {rr.CostRatio:F3} ≥ {opt.LakePreferRatio:F2})" : "");
}
}
else
{
rr.Class = RiverClass.LakeEnder;
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
- // minimum — a blind descent dead-ends there immediately. Route to the nearest
- // SIGNIFICANT body with the same lowground Dijkstra, so the course joins the lake.
+ // minimum — a blind descent dead-ends there immediately. Route with the same lowground
+ // Dijkstra so the course actually joins the water.
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
- var ext = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
- if (!ext.Reached)
+ Route ext;
+ if (opt.OwnBasinLakeEnder)
{
- // Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
- // small ponds still connects rather than dead-ending.
- var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
- if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
+ // ⭐⭐ rivers/03c FIX B — its OWN basin's water, at any size. This is where its flow
+ // goes; it has no business hunting a distant body. Killing the coast-hugger outright.
+ var ownWater = new bool[n * n];
+ int owned = 0;
+ for (int i = 0; i < ownWater.Length; i++)
+ if (basinId[i] == c.BasinId && isClassifyWater[i] && !isOcean[i]) { ownWater[i] = true; owned++; }
+ if (owned > 0)
+ {
+ ext = RouteTo(height, n, ownWater, c.TermX, c.TermY, StyleLowground, sea);
+ rr.OwnBasinTargeted = ext.Reached;
+ }
+ else
+ {
+ // ⚠ Should not happen — basinHasLake is what put it in this branch — but a
+ // basin whose water is all ocean-masked would land here. Report, do not crash.
+ ext = new Route();
+ }
+ }
+ else
+ {
+ var far = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
+ ext = far;
+ if (!ext.Reached)
+ {
+ // Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
+ // small ponds still connects rather than dead-ending.
+ var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
+ if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
+ }
}
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
rr.FaithfulClass = RiverClass.LakeEnder;
rr.Why = rr.LakeReached
- ? $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
+ ? (rr.OwnBasinTargeted
+ ? $"terminal basin holds classify water → natural lake-ender; terminates at its OWN basin's water {ext.LenPx:F0} px away (fix B — no distant-body hunt, so no coast-hugging)"
+ : $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away")
: "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal";
}
@@ -489,7 +583,9 @@ namespace IslaApocalypse.Tools
$"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" +
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" +
$"{(rr.RefusedByCap ? " ⚠ REFUSED BY CAP" : "")}" +
- $"{(rr.Class == RiverClass.LakeFed ? " ⭐ stopped at a lake, not the coast" : "")}");
+ $"{(rr.Class == RiverClass.LakeFed ? $" ⭐ LAKE-FED (ratio {rr.CostRatio:F3})" : "")}" +
+ $"{(!float.IsNaN(rr.CostRatio) && rr.Class == RiverClass.RoutedGiant ? $" → SEA (lake ratio {rr.CostRatio:F3})" : "")}" +
+ $"{(rr.OwnBasinTargeted ? $" ⭐ own-basin water, {rr.Lowland.LenPx:F0} px" : "")}");
}
if (opt.Confluence) Confluence(outp, log);
diff --git a/Tools/Scripts/RiverRoutingTool.cs b/Tools/Scripts/RiverRoutingTool.cs
index 41ed2ba..f6d4bc9 100644
--- a/Tools/Scripts/RiverRoutingTool.cs
+++ b/Tools/Scripts/RiverRoutingTool.cs
@@ -75,6 +75,10 @@ namespace IslaApocalypse.Tools
public int SeaReaching;
/// ⭐ Rivers whose water reaches the sea, counting tributaries through their trunk.
public int SeaConnected;
+ /// ⭐ rivers/03c: every router's lake/ocean cost ratio — the tuning lever, as data.
+ public List<(int rank, long px, bool lakeFed, float costOcean, float costLake, float ratio)> Ratios = new();
+ /// The coast-hugger check: the longest natural-lake-ender extension on this seed.
+ public float MaxLakeEnderLenPx; public int MaxLakeEnderRank;
public List CapMoved = new();
public List LakeMoved = new();
public List Joins = new();
@@ -100,16 +104,26 @@ namespace IslaApocalypse.Tools
// ⭐ 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";
+ string mode = EnvStr("ISLA_ROUTING_MODE", "faithful").Trim().ToLowerInvariant();
+ bool polished = mode == "polished"; // rivers/03c
+ bool refined = mode == "refined" || polished;
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;
+ // ⭐ rivers/03c FIX A — sea-aggressive by default, per the developer's lean.
+ float lakeRatio = float.TryParse(EnvStr("ISLA_LAKE_PREFER_RATIO", "0.4"), out float lr) ? lr : 0.4f;
+ var opt = polished
+ ? new RiverRouting.Options
+ {
+ RimCapM = rimCapM, LakeTargetForRouters = true, Confluence = true,
+ LakePreferRatio = lakeRatio, OwnBasinLakeEnder = true,
+ }
+ : 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");
+ string taskSfx = EnvStr("ISLA_TASK_SUFFIX", polished ? "c" : refined ? "b" : "");
+ string descr = EnvStr("ISLA_BATCH", polished ? "routing_polish" : refined ? "routing_refinement" : "lowland_routing");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
@@ -153,9 +167,11 @@ namespace IslaApocalypse.Tools
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(polished
+ ? " ROUTING POLISH (rivers/03c) — lake-termination as a PREFERENCE + own-basin lake-enders, COURSES ONLY"
+ : 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");
@@ -177,6 +193,17 @@ namespace IslaApocalypse.Tools
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.");
}
+ if (polished)
+ {
+ GD.Print("⭐⭐ rivers/03c — TWO SURGICAL REFINEMENTS OF FIX 3 (rivers/03b overshot: 12 rivers moved to");
+ GD.Print(" lake-fed and the sea mouths roughly halved, 5/5/6/5 → 4/2/3/3):");
+ GD.Print($" FIX A LAKE-TERMINATION IS NOW A PREFERENCE, ratio {lakeRatio:F2} — a router goes to the SEA unless");
+ GD.Print($" cost_lake < {lakeRatio:F2} x cost_ocean, i.e. unless the lake is MATERIALLY cheaper. Lower → more sea rivers.");
+ GD.Print( " Both costs are recorded for EVERY router so the knob is readable off the table without a re-run.");
+ GD.Print( " FIX B A NATURAL LAKE-ENDER ENDS AT ITS OWN BASIN'S WATER, at any size — the 20,000 px significance");
+ GD.Print( " threshold no longer exiles it from home. (It still applies to ROUTERS choosing a DISTANT lake.)");
+ GD.Print($" ⚠ EXPECT THE RIM CAP TO RE-ARM: fix A sends high-rim routers at the sea again, so {rimCapM:F0} m starts refusing.");
+ }
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}");
@@ -239,7 +266,7 @@ namespace IslaApocalypse.Tools
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);
+ significant, sea, style, m => GD.Print(m), opt, plan.BasinId);
double routingSec = (Time.GetTicksMsec() - tr0) / 1000.0;
// ═══ ⛔ …and assert they are byte-identical after ═══
@@ -278,7 +305,14 @@ namespace IslaApocalypse.Tools
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)");
+ if (rr.Class == RiverRouting.RiverClass.LakeFed) r.LakeMoved.Add($"#{rr.Candidate.Rank} ({rr.Candidate.DrainagePx:N0} px{(float.IsNaN(rr.CostRatio) ? "" : $", ratio {rr.CostRatio:F3}")})");
+ if (!float.IsNaN(rr.CostRatio) || rr.Class == RiverRouting.RiverClass.LakeFed || rr.Class == RiverRouting.RiverClass.RoutedGiant || rr.Class == RiverRouting.RiverClass.WalledOff)
+ if (!rr.Candidate.IsSea && rr.Candidate.AnalysisKind != "lake-ender")
+ r.Ratios.Add((rr.Candidate.Rank, rr.Candidate.DrainagePx, rr.Class == RiverRouting.RiverClass.LakeFed, rr.CostOcean, rr.CostLake, rr.CostRatio));
+ // ⭐ The coast-hugger check — how far a NATURAL lake-ender had to travel to find water.
+ if (rr.Class == RiverRouting.RiverClass.LakeEnder && rr.Lowland != null && rr.Lowland.Reached
+ && rr.Lowland.LenPx > r.MaxLakeEnderLenPx)
+ { r.MaxLakeEnderLenPx = rr.Lowland.LenPx; r.MaxLakeEnderRank = rr.Candidate.Rank; }
}
// ⚠ 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
@@ -298,7 +332,10 @@ namespace IslaApocalypse.Tools
(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)}");
+ if (r.LakeMoved.Count > 0) GD.Print($" ⭐ lake-fed: {string.Join(", ", r.LakeMoved)}");
+ GD.Print($" ⭐ coast-hugger check: longest NATURAL lake-ender extension = {r.MaxLakeEnderLenPx:F0} px" +
+ (r.MaxLakeEnderRank > 0 ? $" (#{r.MaxLakeEnderRank})" : "") +
+ (r.MaxLakeEnderLenPx > 2000f ? " ⚠⚠ STILL WANDERING" : " ✅"));
GD.Print($" routing {routingSec:F1}s, {r.TotalExpanded:N0} cells settled across all probes");
WriteRiverCsv(batchRoot, r);
@@ -306,7 +343,8 @@ namespace IslaApocalypse.Tools
results.Add(r);
}
- if (refined) WriteRefinedIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, dpDefaults, skipRaw);
+ if (polished) WritePolishIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, rimCapM, lakeRatio, dpDefaults, skipRaw);
+ else 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}");
@@ -417,7 +455,7 @@ namespace IslaApocalypse.Tools
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," +
+ "route_cost,cost_ocean,cost_lake,cost_ratio,own_basin_target,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)
@@ -431,7 +469,10 @@ namespace IslaApocalypse.Tools
$"{(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") : "")}," +
+ $"{(pr != null && pr.Reached ? pr.Cost.ToString("F0") : "")}," +
+ $"{(float.IsNaN(rr.CostOcean) ? "" : rr.CostOcean.ToString("F0"))},{(float.IsNaN(rr.CostLake) ? "" : rr.CostLake.ToString("F0"))}," +
+ $"{(float.IsNaN(rr.CostRatio) ? "" : rr.CostRatio.ToString("F4"))},{(rr.OwnBasinTargeted ? "yes" : "")}," +
+ $"{(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() : "")}," +
@@ -454,7 +495,7 @@ namespace IslaApocalypse.Tools
if (refined)
DrainageRenderer.RefinedMix(r.Rivers, baseImg.Duplicate() as Image, n,
- $"SEED {r.Seed} - THE RESHAPED MIX ON THE PURE TOP {promoteN} (RIVERS/03B)",
+ $"SEED {r.Seed} - THE MIX ON THE PURE TOP {promoteN}",
$"{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"));
@@ -801,6 +842,174 @@ namespace IslaApocalypse.Tools
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
+ ///
+ /// ⭐ THE POLISH INDEX (rivers/03c) — the before/after/after, and the ratio table that IS the knob.
+ ///
+ private static void WritePolishIndex(string batchRoot, int mapSize, int[] seeds,
+ List rows, int promoteN, int lakeMinPx, float rimCapM, float lakeRatio,
+ DrainageAnalysis.Params def, bool skipRaw)
+ {
+ var sb = new StringBuilder();
+ int primary = seeds.Length > 0 ? seeds[0] : 0;
+
+ sb.AppendLine($"# Batch 03c — routing polish: lake-termination as a PREFERENCE, and lake-enders come home");
+ sb.AppendLine();
+ sb.AppendLine("**⛔ TASTE GATE. Nothing locked, nothing graduated.** The ratio and the cap are both knobs.");
+ 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. Fix B *terminates a course");
+ sb.AppendLine("at* existing in-basin water; it creates nothing.");
+ sb.AppendLine();
+ sb.AppendLine("## 👉 The pick — a before / after / after");
+ sb.AppendLine();
+ sb.AppendLine($"- **`999999937/refined_mix.png`** beside **`../03b_routing_refinement/999999937/refined_mix.png`**");
+ sb.AppendLine(" — the coast-hugger seed. `#3` marched ~5,800 px along the shoreline in 03b; it should now stop at home.");
+ sb.AppendLine($"- **`{primary}/refined_mix.png`** beside **`../03_lowland_routing/{primary}/routed_mix.png`**");
+ sb.AppendLine(" — the full arc, from the faithful port to here.");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
+ sb.AppendLine("> **Does the island read good now — more sea rivers, no coast-hugger, no uphill river, a natural");
+ sb.AppendLine("> dendritic tree — and is the mouth count and coastal spread healthy?**");
+ sb.AppendLine(">");
+ sb.AppendLine($"> The tuning lever is the ratio table below. To add more sea rivers, **lower `ISLA_LAKE_PREFER_RATIO`**");
+ sb.AppendLine($"> from {lakeRatio:F2} — the table shows exactly which rivers flip at which value. If too many now skirt");
+ sb.AppendLine("> lakes to reach the sea, raise it. One value, one re-run.");
+ sb.AppendLine();
+ sb.AppendLine("## The two fixes");
+ sb.AppendLine();
+ sb.AppendLine("| | rivers/03b did | rivers/03c does |");
+ sb.AppendLine("|---|---|---|");
+ sb.AppendLine($"| **A** | router goes to the **nearest** of {{ocean ∪ significant lake}} — a lake merely a bit closer captures it | router goes to the **sea** unless `cost_lake < {lakeRatio:F2} × cost_ocean`, i.e. unless the lake is **materially** cheaper |");
+ sb.AppendLine($"| **B** | a natural lake-ender hunts the nearest **significant** (≥ {lakeMinPx:N0} px) body — exiling it from a sub-threshold home lake | it terminates at **its own basin's** water, **any size** |");
+ sb.AppendLine();
+ sb.AppendLine($"⚠ The {lakeMinPx:N0} px threshold **still applies to routers** choosing a *distant* lake — a dry basin still");
+ sb.AppendLine("cannot connect itself to a three-cell puddle. It simply no longer exiles a lake-ender from home.");
+ sb.AppendLine();
+ sb.AppendLine("**Unchanged from rivers/03b:** the confluence post-pass, the rim cap, the `basinHasLake` sort, and the");
+ sb.AppendLine("faithful default (which still reproduces rivers/03 bit-for-bit).");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The MIX");
+ 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();
+ bool anyShared = false;
+ foreach (var r in rows) if (r.SharedMouths.Count > 0) anyShared = true;
+ sb.AppendLine(anyShared
+ ? "> ⚠⚠ Some mouths are still shared — see the per-seed lines in the log."
+ : "> ✅ **No two rivers share a mouth on any seed** — the confluence pass still holds.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐⭐ The ratio table — THIS IS THE KNOB");
+ sb.AppendLine();
+ sb.AppendLine("Every dry-basin router, with the cost of reaching a significant lake against the cost of reaching the");
+ sb.AppendLine($"sea. **A river is lake-fed iff its ratio is below `{lakeRatio:F2}`.** Read down the ratio column to see");
+ sb.AppendLine("exactly which rivers a different setting would flip — no re-run needed.");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | river | drainage px | cost to sea | cost to lake | ⭐ ratio | verdict |");
+ sb.AppendLine("|---|---|---|---|---|---|---|");
+ foreach (var r in rows)
+ {
+ var sorted = new List<(int rank, long px, bool lakeFed, float costOcean, float costLake, float ratio)>(r.Ratios);
+ sorted.Sort((a, b) => (float.IsNaN(a.ratio) ? float.MaxValue : a.ratio).CompareTo(float.IsNaN(b.ratio) ? float.MaxValue : b.ratio));
+ foreach (var t in sorted)
+ sb.AppendLine($"| `{r.Seed}` | #{t.rank} | {t.px:N0} | {(float.IsNaN(t.costOcean) ? "—" : t.costOcean.ToString("N0"))} | " +
+ $"{(float.IsNaN(t.costLake) ? "— (no lake)" : t.costLake.ToString("N0"))} | " +
+ $"{(float.IsNaN(t.ratio) ? "—" : $"**{t.ratio:F3}**")} | {(t.lakeFed ? "lake-fed" : "→ sea")} |");
+ }
+ sb.AppendLine();
+ sb.AppendLine("## What the cap now catches, and the min-rim re-check");
+ sb.AppendLine();
+ sb.AppendLine($"Fix A sends high-rim routers at the sea again, so the **{rimCapM:F0} m rim cap re-arms** — in rivers/03b it");
+ sb.AppendLine("caught nothing, because fix 3 had already hidden those rivers in lakes.");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | ⚠ walled off by the cap | lake-fed (with ratio) | confluences |");
+ 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 — check each walled-off river on the plate");
+ sb.AppendLine("> The cap tests the **least-cost route's** rim, not a theoretical minimum-rim path. LOWGROUND penalises");
+ sb.AppendLine("> uphill heavily so the chosen route is almost always the low-rim one — **but if a walled-off basin");
+ sb.AppendLine("> visibly should have had a low way out, that is the signal a bottleneck (min-rim) search is wanted.**");
+ sb.AppendLine("> Not built here; flag it if you see it.");
+ sb.AppendLine();
+ sb.AppendLine("## ⭐ The coast-hugger check (fix B)");
+ sb.AppendLine();
+ sb.AppendLine("The longest **natural lake-ender** extension per seed. In rivers/03b `999999937 #3` ran **5,841 px**.");
+ sb.AppendLine();
+ sb.AppendLine("| Seed | longest natural lake-ender extension | verdict |");
+ sb.AppendLine("|---|---|---|");
+ foreach (var r in rows)
+ sb.AppendLine($"| `{r.Seed}` | {r.MaxLakeEnderLenPx:F0} px{(r.MaxLakeEnderRank > 0 ? $" (#{r.MaxLakeEnderRank})" : "")} | " +
+ $"{(r.MaxLakeEnderLenPx > 2000f ? "⚠⚠ still wandering" : "✅ home")} |");
+ sb.AppendLine();
+ sb.AppendLine("> ### ⚠ One thing this task deliberately does NOT fix");
+ sb.AppendLine("> A **routed-to-sea** river still tracing a bit of coastline near its mouth is the **deferred**");
+ sb.AppendLine("> LOWGROUND-on-flat-lowland arc — the cheapest corridor across a flat plain runs near the shore. That");
+ sb.AppendLine("> is judged **after the bed carve gives rivers width and depth**, not corrected here. It is a different");
+ sb.AppendLine("> thing from the lake-ender wander this task kills outright.");
+ 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 | ratio | rim climb m | cap | joins |");
+ 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 ? $"own lake ({(int)lo.Target.x},{(int)lo.Target.y}), {lo.LenPx:F0} px" : $"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} | " +
+ $"{(float.IsNaN(rr.CostRatio) ? "—" : rr.CostRatio.ToString("F3"))} | " +
+ $"{(pr != null && pr.Reached ? pr.RimClimbM.ToString("F1") : "—")} | " +
+ $"{(rr.RefusedByCap ? "**REFUSED**" : rr.Class == RiverRouting.RiverClass.RoutedGiant ? "under" : "—")} | " +
+ $"{(rr.Joined ? $"#{rr.ConfluenceParentRank}" : "—")} |");
+ }
+ 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($"Pure top {promoteN}, no quota. `ISLA_LAKE_PREFER_RATIO={lakeRatio:F2}`, `ISLA_RIM_CAP_M={rimCapM:F0}`, significance {lakeMinPx:N0} px.");
+ sb.AppendLine();
+ sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused); `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define");
+ sb.AppendLine($"the routing surface); `MinOutletSeparationPx` {def.MinOutletSeparationPx}; `StemMinAccPx` {def.StemMinAccPx}. Termini classified by `OceanMask`, the");
+ sb.AppendLine("significant-lake mask, and per-basin classify water only — no bare `h < sea`. `ProvisionalRoute` never drawn.");
+ sb.AppendLine();
+ sb.AppendLine("**⚠ No spatial term anywhere** — the southern-coast question stays a placement-era one.");
+ sb.AppendLine();
+ sb.AppendLine("## Files");
+ sb.AppendLine();
+ sb.AppendLine("| File | What it is |");
+ sb.AppendLine("|---|---|");
+ sb.AppendLine("| `/refined_mix.png` | the five classes as a dendritic tree; white dot = confluence, yellow ring = rim crossed |");
+ sb.AppendLine("| `/grayscale.png` | the eroded render field, no palette |");
+ sb.AppendLine("| `rivers_.csv` | per river, incl. **`cost_ocean` / `cost_lake` / `cost_ratio`** (fix A's lever) and `own_basin_target` (fix B) |");
+ if (skipRaw)
+ sb.AppendLine("| ~~`/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
+ 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/03c_routing_polish.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)