From 09d23127e721859273844ae0d7af2b1b89f51014 Mon Sep 17 00:00:00 2001 From: beezm Date: Mon, 24 Aug 2026 23:28:02 -0400 Subject: [PATCH] =?UTF-8?q?rivers/03c:=20routing=20polish=20=E2=80=94=20la?= =?UTF-8?q?ke-termination=20as=20a=20preference,=20lake-enders=20come=20ho?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Tools/Scripts/RiverRouting.cs | 142 +++++++++++++++--- Tools/Scripts/RiverRoutingTool.cs | 241 ++++++++++++++++++++++++++++-- 2 files changed, 344 insertions(+), 39 deletions(-) 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)