rivers/03c: routing polish — lake-termination as a preference, lake-enders come home

Two surgical refinements of rivers/03b's fix 3. Everything else (confluence,
the rim cap, the faithful default) unchanged. Courses only: no height mutated,
no water filled or created — asserted per seed by a raw-bit digest.

- FIX A (ISLA_LAKE_PREFER_RATIO, default 0.4): a dry-basin router goes to the SEA
  unless cost_lake < ratio * cost_ocean, i.e. unless a lake is materially cheaper.
  rivers/03b used "nearest of {ocean, lake}", which is exactly ratio < 1.0 and
  captured 12 rivers, halving the sea mouths. Both leg costs are now recorded for
  every router so the knob is readable off the table without a re-run.
- FIX B (OwnBasinLakeEnder): a natural lake-ender terminates at the water inside
  its OWN terminal basin at any size. The 20,000 px significance threshold had
  exiled 999999937 #3 from its sub-threshold home lake, sending it ~5,800 px along
  the shoreline. The threshold still applies to routers choosing a distant lake.

As predicted, the rim cap re-arms: 31415926 #2 (66.7 m) now goes to the sea and
is refused, rather than being hidden in a lake.

Both fixes are off by default, so faithful mode still reproduces rivers/03 and
refined mode still reproduces rivers/03b.

Taste gate: nothing locked, nothing graduated.
This commit is contained in:
Stewart Howe 2026-08-24 23:28:02 -04:00
parent b5ceca0419
commit 09d23127e7
2 changed files with 344 additions and 39 deletions

View file

@ -297,6 +297,35 @@ namespace IslaApocalypse.Tools
/// False = the reference's behaviour (routers target ocean only).</summary>
public bool LakeTargetForRouters;
/// <summary>
/// ⭐⭐ 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:
/// <code>
/// lake-fed iff cost_lake &lt; LakePreferRatio × cost_ocean
/// </code>
/// 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.
/// </summary>
public float LakePreferRatio;
/// <summary>
/// ⭐⭐ 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.
/// </summary>
public bool OwnBasinLakeEnder;
/// <summary>⭐ 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).</summary>
@ -328,6 +357,14 @@ namespace IslaApocalypse.Tools
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
public float CappedRimM;
public bool RefusedByCap;
/// <summary>⭐ 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.
/// <see cref="CostRatio"/> is cost_lake / cost_ocean; a river is lake-fed iff it is below
/// the configured ratio. NaN where the leg was not reachable.</summary>
public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
/// <summary>Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.</summary>
public bool OwnBasinTargeted;
/// <summary>The class this river WOULD have had under the reference's rules — so every
/// reclassification the divergences caused is legible rather than silent.</summary>
public RiverClass FaithfulClass;
@ -378,9 +415,13 @@ namespace IslaApocalypse.Tools
/// </summary>
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action<string> log, Options opt = null)
Action<string> 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,17 +463,40 @@ 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
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;
rr.FaithfulClass = RiverClass.RoutedGiant;
rr.Lowland = route;
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
bool stoppedAtLake = route.Reached
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;
if (!route.Reached)
{
@ -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,17 +524,42 @@ 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);
Route ext;
if (opt.OwnBasinLakeEnder)
{
// ⭐⭐ 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
@ -476,10 +567,13 @@ namespace IslaApocalypse.Tools
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);

View file

@ -75,6 +75,10 @@ namespace IslaApocalypse.Tools
public int SeaReaching;
/// <summary>⭐ Rivers whose water reaches the sea, counting tributaries through their trunk.</summary>
public int SeaConnected;
/// <summary>⭐ rivers/03c: every router's lake/ocean cost ratio — the tuning lever, as data.</summary>
public List<(int rank, long px, bool lakeFed, float costOcean, float costLake, float ratio)> Ratios = new();
/// <summary>The coast-hugger check: the longest natural-lake-ender extension on this seed.</summary>
public float MaxLakeEnderLenPx; public int MaxLakeEnderRank;
public List<string> CapMoved = new();
public List<string> LakeMoved = new();
public List<string> 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
// ⭐ 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,7 +167,9 @@ namespace IslaApocalypse.Tools
var dpDefaults = new DrainageAnalysis.Params();
GD.Print("==================================================================");
GD.Print(refined
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("==================================================================");
@ -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());
}
/// <summary>
/// ⭐ THE POLISH INDEX (rivers/03c) — the before/after/after, and the ratio table that IS the knob.
/// </summary>
private static void WritePolishIndex(string batchRoot, int mapSize, int[] seeds,
List<SeedResult> 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("| `<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, incl. **`cost_ocean` / `cost_lake` / `cost_ratio`** (fix A's lever) and `own_basin_target` (fix B) |");
if (skipRaw)
sb.AppendLine("| ~~`<seed>/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)