using System;
using System.Collections.Generic;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
///
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
///
/// ═══ ⛔ THE RED LINE ═══
///
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
///
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
///
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
/// channel over the spill, **with no water filled**.
///
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
/// corridor is the lowest ground even when that wanders. **The locked style.**
///
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
///
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
/// declared default.
///
public static class RiverRouting
{
public const byte StyleShort = 0;
public const byte StyleLowground = 1;
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
// heavily for climbing (50 per metre).
public const float ShortUphillPerM = 8f;
public const float LowgroundElevPerM = 1f;
public const float LowgroundBase = 0.05f;
public const float LowgroundUphillPerM = 50f;
/// The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.
public const int LakeMinTargetPx = 20_000;
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
private static readonly float[] DIST = {
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
/// One lowland route, with the diagnostics the gate needs to judge it.
public sealed class Route
{
/// Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.
public List<(float x, float y)> Path = new();
/// The same reach after RDP + Chaikin. This is what is drawn and spliced.
public List<(float x, float y)> Smoothed = new();
/// ⭐ Did a path exist at all? Empty list on no path — never thrown.
public bool Reached;
/// Dijkstra cost at the goal (cost-model units, not metres).
public float Cost;
/// ⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
/// says whether a route crawls over a saddle or vaults a wall.
public float MaxStepUphillM;
/// ⭐ Total metres climbed along the route, and how many steps climbed at all.
public float TotalUphillM;
public int UphillSteps;
/// Highest point on the route, metres above sea — the rim's absolute height.
public float MaxElevM;
/// Net climb from the terminal to the route's high point, metres — what "over the rim" costs.
public float RimClimbM;
/// ⭐ WHERE the route tops out — the rim cell, ringed on the plate.
public (float x, float y) RimPoint;
public float LenPx, StraightPx, WanderRatio;
/// Cells settled by the search — the honest cost of a Dijkstra at this map size.
public long Expanded;
public (float x, float y) Target;
}
///
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
///
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
///
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
/// lake-ender's extension. One routine, two uses — as the reference has it.
///
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
///
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
{
int total = n * n;
var gcost = new float[total];
var parent = new int[total];
var closed = new bool[total];
Array.Fill(gcost, float.MaxValue);
Array.Fill(parent, -1);
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
// level. Without the clamp a route would dive for the deepest hole it could find.
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
var pq = new PriorityQueue();
int start = sx * n + sy;
gcost[start] = 0f;
pq.Enqueue(start, (0f, start));
int goal = -1;
long expanded = 0;
while (pq.Count > 0)
{
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
expanded++;
if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n;
float hc = height[cx, cy];
for (int k = 0; k < 8; k++)
{
int nx = cx + DX[k], ny = cy + DY[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (closed[ni]) continue;
float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
float step = style == StyleShort
? DIST[k] + dhM * ShortUphillPerM
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
+ dhM * LowgroundUphillPerM;
float nc = gcost[c] + step;
if (nc < gcost[ni])
{
gcost[ni] = nc;
parent[ni] = c;
pq.Enqueue(ni, (nc, ni));
}
}
}
var r = new Route { Expanded = expanded };
if (goal < 0) return r; // no path — an empty route, reported upstream
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
r.Path.Reverse();
r.Reached = true;
r.Cost = gcost[goal];
r.Target = r.Path[^1];
Measure(r, height, n, sea);
r.Smoothed = SmoothCourse(r.Path);
return r;
}
///
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
///
private static void Measure(Route r, float[,] height, int n, float sea)
{
float startElev = ElevAt(r.Path[0]);
float maxElev = startElev;
r.RimPoint = r.Path[0];
for (int i = 1; i < r.Path.Count; i++)
{
var a = r.Path[i - 1]; var b = r.Path[i];
float dx = b.x - a.x, dy = b.y - a.y;
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
float climb = ElevAt(b) - ElevAt(a);
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
}
r.MaxElevM = maxElev;
r.RimClimbM = maxElev - startElev;
var s = r.Path[0]; var e = r.Path[^1];
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
float ElevAt((float x, float y) p) =>
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
}
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
//
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
// reference took the max cut from 14.6 m to 27.3 m.
/// RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.
public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
{
if (raw.Count < 3) return raw;
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
if (dec.Count < 3) return raw;
var sm = dec;
for (int pass = 0; pass < 4; pass++)
{
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
for (int i = 0; i + 1 < sm.Count; i++)
{
var a = sm[i]; var b = sm[i + 1];
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
}
nxt.Add(sm[^1]);
sm = nxt;
}
return sm;
}
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
{
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
var a = pts[i0]; var b = pts[i1];
float abx = b.x - a.x, aby = b.y - a.y;
float abLen = MathF.Sqrt(abx * abx + aby * aby);
float maxD = 0f; int maxI = i0;
for (int i = i0 + 1; i < i1; i++)
{
float d = abLen < 1e-6f
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
if (d > maxD) { maxD = d; maxI = i; }
}
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
var left = Rdp(pts, i0, maxI, tol);
var right = Rdp(pts, maxI, i1, tol);
left.RemoveAt(left.Count - 1);
left.AddRange(right);
return left;
}
/// The three classes the MIX is made of.
public enum RiverClass
{
/// Sea-reaching already, exactly as erosion carved it. No lowland route needed.
OceanTrunk,
/// An endorheic basin connected to the coast by a routed over-the-rim channel.
RoutedGiant,
/// Stays inland: terminates at a significant lake, or at its own terminal.
LakeEnder,
}
/// One promoted river, classified, routed and assembled.
public sealed class RoutedRiver
{
public RiverCandidate Candidate;
public RiverClass Class;
/// The lowland reach actually used: the ocean route for a routed giant, the lake
/// route for a lake-ender. Null for trunks.
public Route Lowland;
/// ⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
/// on . This is what makes an affordability threshold judgeable.
public Route OceanProbe;
/// Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?
public bool LakeReached, LakeWasFallback;
/// Head → terminus, stem + smoothed lowland reach.
public List<(float x, float y)> Course;
public string Why = "";
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
}
///
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
///
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
///
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
///
/// **So the reference's sort is used, because it is the one that actually discriminates:**
///
/// Kind = (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"
///
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
/// (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)
///
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
/// for EVERY giant, lake-enders included (), so the batch can
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
/// **Nothing is locked here — the classification shown is the reference's.**
///
public static List RouteAll(List promoted, float[,] height, int n,
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
Action log)
{
var outp = new List();
foreach (var c in promoted)
{
var rr = new RoutedRiver { Candidate = c };
if (c.IsSea)
{
// A natural ocean trunk needs no lowland route: erosion already carried it to the
// coast, and its outlet is ON the coast by construction. The stem IS the course.
rr.Class = RiverClass.OceanTrunk;
rr.Course = new List<(float x, float y)>(c.Course);
rr.Course.Reverse();
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
continue;
}
// ⭐ The ocean probe, for every giant — the affordability evidence.
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
rr.OceanProbe = probe;
bool refLakeEnder = c.AnalysisKind == "lake-ender";
if (!refLakeEnder)
{
rr.Class = RiverClass.RoutedGiant;
rr.Lowland = probe;
rr.Why = probe.Reached
? $"dry pan → routed; rim climb {probe.RimClimbM:F1} m, max step {probe.MaxStepUphillM:F2} m, cost {probe.Cost:N0}"
: "dry pan → routed, but NO path to the ocean was found (unexpected — report)";
}
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.
// ⚠ 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)
{
// 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.Why = rr.LakeReached
? $"terminal basin holds classify water → lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away"
: "terminal basin holds classify water → lake-ender; no water body reachable, course ends at its terminal";
}
rr.Course = Assemble(c.Course, rr.Lowland);
outp.Add(rr);
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {(rr.Class == RiverClass.RoutedGiant ? "ROUTED " : "LAKE-ENDER")} " +
$"probe{(probe.Reached ? $" 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} expanded {probe.Expanded:N0}" : " NO PATH")}" +
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}");
}
return outp;
}
///
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
/// reach (terminal → target).
///
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
///
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
/// produces courses, and a densified polyline draws and measures identically.
///
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
{
var pts = new List<(float x, float y)>(uplandStem);
pts.Reverse(); // downstream-first → head → terminal
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
return pts;
}
}
}