Ports the ROUTING PORTION of the reference's RiverCarvePass (RouteToOcean, the routed/lake-ender sort, SmoothCourse). NOT CarveRiver (bed stamp) and NOT AddSteppedWater (water bodies) — those are later tasks. RED LINE: no height mutated, no water filled, nothing carved. Asserted per seed by an FNV digest of both height fields before/after routing. - RiverRouting: deterministic LOWGROUND Dijkstra, uphill penalised so a route may cross the basin rim, empty-list-on-no-path. Effective == declared constants (verified: private const, no ConfigManager key, no [Export] in the reference). - The sort is the REFERENCE's — Kind = basinHasLake ? lake-ender : routed. The task's stated "a path exists -> routed" cannot discriminate: on an 8-connected grid a path to the ocean always exists, confirmed empirically (43/43 probes reached). The ocean route is probed for every giant anyway, so the missing affordability threshold is reported as a number rather than guessed. - RegionLabeling.SignificantWaterMask: interim substitute for v2's missing water-bodies table — 8-connected classify-water components >= 20,000 px. - RiverCandidates: the candidate enumeration extracted out of RiverPromotionTool so routing ranks the identical set the count gate was judged on. Behaviour neutral — rivers/02b's twelve plates are byte-identical across the extraction. - DrainageRenderer.RoutedMix: three classes, with each routed river's added lowland reach and the rim it crossed drawn distinctly from its natural stem. Taste gate: no count, no K, no style, no default set.
233 lines
11 KiB
C#
233 lines
11 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ THE CANDIDATE SET — one implementation, shared by every task that ranks rivers.
|
||
///
|
||
/// Extracted from `RiverPromotionTool` at rivers/03, unchanged in behaviour, because routing needs
|
||
/// exactly the same promoted set the count gate was judged on. **Two copies of this enumeration
|
||
/// would be two answers to "which rivers does the island have", and the epic rests on there being
|
||
/// one.** `RiverPromotionTool` now delegates here; its plates are byte-identical across the change.
|
||
///
|
||
/// ═══ WHAT IT DOES, AND WHAT IT DELIBERATELY DOES NOT ═══
|
||
///
|
||
/// It derives the COMPLETE candidate set from the arrays `DrainageAnalysis.Plan` exposes —
|
||
/// `Dir` / `Acc` / `BasinId` / `BasinInflow` / `FullFilled` — rather than from `Plan.Trunks` /
|
||
/// `Plan.Giants`, which are already truncated by the analysis's lean reporting caps. Ranking over
|
||
/// the truncated lists would measure the caps rather than the terrain.
|
||
///
|
||
/// ⚠ **`DrainageAnalysis` is reused, never rebuilt.** Every derived quantity here is a
|
||
/// reconstruction of a value the analysis computed internally, from state it exposes. Nothing the
|
||
/// analysis owns is reimplemented — least of all stem tracing, which is BOUND from the analysis's
|
||
/// own `Trunk` / `Giant` (see <see cref="BindCourses"/>).
|
||
/// </summary>
|
||
public static class RiverCandidates
|
||
{
|
||
/// <summary>The enumeration's full result: the ranking, plus what the separation rule cost.</summary>
|
||
public sealed class Enumeration
|
||
{
|
||
/// <summary>Separated and above the floor, descending by <c>DrainagePx</c>, `Rank` assigned.</summary>
|
||
public List<RiverCandidate> Ranked;
|
||
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||
public int TerminalBasins, SeaOutletsAll;
|
||
/// <summary>Sea outlets dropped by the separation rule — ALL of them, incl. one-cell trickles.</summary>
|
||
public int SuppressedCount;
|
||
public long SuppressedPx;
|
||
/// <summary>⭐ The two that matter: only outlets clearing the floor could ever have been promoted.</summary>
|
||
public int SuppressedAboveFloor;
|
||
public long SuppressedAboveFloorPx;
|
||
/// <summary>Of those, suppressed by an outlet on a DIFFERENT landmass — not a delta mouth by any definition.</summary>
|
||
public int SuppressedCrossLandmass;
|
||
public long SuppressedCrossLandmassPx;
|
||
public List<long> SuppressedAboveFloorAccs = new();
|
||
|
||
public int SeaCount { get { int s = 0; foreach (var c in Ranked) if (c.IsSea) s++; return s; } }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Enumerate every candidate major drainage, cap-free.
|
||
///
|
||
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> there, then the
|
||
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
|
||
/// delta are not three rivers.
|
||
/// ENDORHEIC every terminal basin in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>, with
|
||
/// terminal cell / area / depth re-derived from the exposed surfaces.
|
||
///
|
||
/// ⚠⚠ Throws unless the metric-comparability identity holds exactly — see below.
|
||
/// </summary>
|
||
public static Enumeration Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
|
||
long floorPx, int separationPx, RegionLabels regions)
|
||
{
|
||
int total = n * n;
|
||
var r = new Enumeration
|
||
{
|
||
LandCells = plan.LandCells, SeaReachingCells = plan.SeaReachingCells,
|
||
EndorheicCells = plan.EndorheicCells, UnroutedCells = plan.UnroutedCells,
|
||
TerminalBasins = plan.TerminalBasinCount,
|
||
};
|
||
|
||
// ---- SEA: every outlet, then the separation rule ----
|
||
var outlets = new List<(int cell, long acc)>();
|
||
long seaSum = 0;
|
||
for (int i = 0; i < total; i++)
|
||
if (plan.Dir[i] == DrainageAnalysis.D_SEA) { outlets.Add((i, plan.Acc[i])); seaSum += plan.Acc[i]; }
|
||
outlets.Sort((a, b) => b.acc.CompareTo(a.acc));
|
||
r.SeaOutletsAll = outlets.Count;
|
||
|
||
var sea = new List<RiverCandidate>();
|
||
var kept = new List<int>();
|
||
foreach (var (cell, acc) in outlets)
|
||
{
|
||
int cx = cell / n, cy = cell % n;
|
||
bool far = true; int suppressor = -1;
|
||
foreach (int pcell in kept)
|
||
{
|
||
float ddx = cx - pcell / n, ddy = cy - pcell % n;
|
||
if (ddx * ddx + ddy * ddy < (float)separationPx * separationPx) { far = false; suppressor = pcell; break; }
|
||
}
|
||
var c = new RiverCandidate { IsSea = true, Cell = cell, X = cx, Y = cy, TermX = cx, TermY = cy, DrainagePx = acc, SuppressedBySeparation = !far };
|
||
if (far) kept.Add(cell);
|
||
else
|
||
{
|
||
r.SuppressedCount++; r.SuppressedPx += acc;
|
||
if (acc >= floorPx)
|
||
{
|
||
r.SuppressedAboveFloor++; r.SuppressedAboveFloorPx += acc; r.SuppressedAboveFloorAccs.Add(acc);
|
||
// ⚠⚠ IS THE SUPPRESSOR EVEN ON THE SAME LANDMASS? The separation rule is a plain
|
||
// Euclidean distance test — it has no idea what land a coastline belongs to. On
|
||
// this deliberately fragmented archipelago (→ D-063) an ISLAND's only river can
|
||
// be suppressed by a mainland mouth 400 px away ACROSS WATER. Measured, not
|
||
// argued; the rule itself is NOT changed (it belongs to the analysis).
|
||
if (regions != null && suppressor >= 0)
|
||
{
|
||
int a = regions.Id[cell], b = regions.Id[suppressor];
|
||
if (a != 0 && b != 0 && a != b) { r.SuppressedCrossLandmass++; r.SuppressedCrossLandmassPx += acc; }
|
||
}
|
||
}
|
||
}
|
||
if (acc >= floorPx) sea.Add(c);
|
||
}
|
||
|
||
// ---- ENDORHEIC: every terminal basin, metrics re-derived ----
|
||
// After the analysis's reversion, BasinId is non-zero ONLY on terminal-basin cells, and
|
||
// Filled == the original height there — so FullFilled − height IS the fill depth, and the
|
||
// basin minimum is the argmin of height over the basin's cells. Both reconstruct exactly
|
||
// what the analysis computed internally as basinMinCell / basinDepthM / basinAreaPx.
|
||
int maxId = 0;
|
||
for (int i = 0; i < total; i++) if (plan.BasinId[i] > maxId) maxId = plan.BasinId[i];
|
||
var area = new long[maxId + 1];
|
||
var minCell = new int[maxId + 1];
|
||
var minH = new float[maxId + 1];
|
||
var depth = new float[maxId + 1];
|
||
for (int id = 0; id <= maxId; id++) { minCell[id] = -1; minH[id] = float.MaxValue; }
|
||
for (int i = 0; i < total; i++)
|
||
{
|
||
int id = plan.BasinId[i];
|
||
if (id == 0) continue;
|
||
area[id]++;
|
||
float h = height[i / n, i % n];
|
||
if (h < minH[id]) { minH[id] = h; minCell[id] = i; }
|
||
float d = WorldScale.MetresFromRaw(plan.FullFilled[i] - h);
|
||
if (d > depth[id]) depth[id] = d;
|
||
}
|
||
|
||
var endo = new List<RiverCandidate>();
|
||
long endoSum = 0;
|
||
for (int id = 1; id <= maxId; id++)
|
||
{
|
||
if (minCell[id] < 0) continue;
|
||
long inflow = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0;
|
||
endoSum += inflow;
|
||
if (inflow < floorPx) continue;
|
||
endo.Add(new RiverCandidate
|
||
{
|
||
IsSea = false, Cell = minCell[id], X = minCell[id] / n, Y = minCell[id] % n,
|
||
TermX = minCell[id] / n, TermY = minCell[id] % n, // replaced at bind time by the stem's pooling point
|
||
DrainagePx = inflow, BasinId = id, BasinAreaPx = area[id], BasinDepthM = depth[id],
|
||
});
|
||
}
|
||
|
||
// ═══ ⚠⚠ THE COMPARABILITY ASSERTION — the whole unified ranking rests on this ═══
|
||
//
|
||
// Both metrics are counts of contributing LAND CELLS on the same D8 field, and every land
|
||
// cell has exactly one destination — so the two populations partition the land exactly.
|
||
// If this identity ever fails, the two numbers are not the same unit and ranking them in
|
||
// one list is meaningless. It is asserted per seed rather than argued in a comment.
|
||
long partition = seaSum + endoSum + plan.UnroutedCells;
|
||
if (seaSum != plan.SeaReachingCells || endoSum != plan.EndorheicCells || partition != plan.LandCells)
|
||
throw new InvalidOperationException(
|
||
"[RiverCandidates] METRIC COMPARABILITY VIOLATION — the unified ranking is not sound on this field.\n" +
|
||
$" Σ Acc over sea outlets = {seaSum:N0}, expected SeaReachingCells = {plan.SeaReachingCells:N0}\n" +
|
||
$" Σ BasinInflow = {endoSum:N0}, expected EndorheicCells = {plan.EndorheicCells:N0}\n" +
|
||
$" sum + unrouted = {partition:N0}, expected LandCells = {plan.LandCells:N0}\n" +
|
||
"Sea-outlet drainage area and endorheic credited inflow must be the same unit over the same " +
|
||
"population for one ranking to mean anything. Refusing to rank. (rivers/02 Part 0 §2.)");
|
||
GD.Print($" ✅ comparability: Σ sea Acc {seaSum:N0} + Σ BasinInflow {endoSum:N0} + unrouted {plan.UnroutedCells:N0} == land {plan.LandCells:N0} — same unit, exact partition");
|
||
|
||
// ---- the unified ranking: one list, both termini, descending by contributing cells ----
|
||
var ranked = new List<RiverCandidate>();
|
||
foreach (var c in sea) if (!c.SuppressedBySeparation) ranked.Add(c);
|
||
ranked.AddRange(endo);
|
||
ranked.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||
for (int i = 0; i < ranked.Count; i++) ranked[i].Rank = i + 1;
|
||
r.Ranked = ranked;
|
||
return r;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bind each candidate in <paramref name="need"/> to the <c>Trunk</c> / <c>Giant</c> the
|
||
/// analysis already traced, so plates draw REAL upland stems rather than anything reimplemented
|
||
/// here. Sea binds by outlet cell (identical greedy pick, identical order); endorheic binds by
|
||
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells at
|
||
/// the minimum height and the analysis's DFS tie-break need not match a row-major scan.
|
||
///
|
||
/// ⚠ Also transfers the analysis's own <see cref="RiverCandidate.Kind"/> and
|
||
/// <see cref="RiverCandidate.TerminalInClassifyWater"/> for endorheic candidates — rivers/03
|
||
/// needs the reference's routed/lake-ender verdict to compare against its own.
|
||
/// </summary>
|
||
public static void BindCourses(DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
|
||
{
|
||
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
|
||
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
|
||
var byBasin = new Dictionary<int, DrainageAnalysis.Giant>();
|
||
foreach (var g in plan.Giants)
|
||
{
|
||
int cell = (int)g.Terminal.x * n + (int)g.Terminal.y;
|
||
int id = plan.BasinId[cell];
|
||
if (id > 0 && !byBasin.ContainsKey(id)) byBasin[id] = g;
|
||
}
|
||
|
||
int missing = 0;
|
||
foreach (var c in need)
|
||
{
|
||
if (c.Course != null) continue;
|
||
if (c.IsSea)
|
||
{
|
||
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
|
||
}
|
||
else
|
||
{
|
||
// ⚠ Take the RIVER's terminus from the Giant, not the basin minimum this candidate
|
||
// is keyed on — see RiverCandidate.TermX. They differ on a flat basin floor, and
|
||
// marking the wrong one draws every endorheic stem detached from its own endpoint.
|
||
if (byBasin.TryGetValue(c.BasinId, out var g))
|
||
{
|
||
c.Course = g.Course; c.TermX = (int)g.Terminal.x; c.TermY = (int)g.Terminal.y;
|
||
c.AnalysisKind = g.Kind;
|
||
c.TerminalInClassifyWater = g.TerminalInClassifyWater;
|
||
}
|
||
}
|
||
if (c.Course == null) missing++;
|
||
}
|
||
if (missing > 0)
|
||
throw new InvalidOperationException(
|
||
$"[RiverCandidates] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
|
||
"reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
|
||
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
|
||
}
|
||
}
|
||
}
|