islaApocalypse-v2/Tools/Scripts/RiverCandidates.cs
beezm 4e4be6a83e rivers/03: lowland routing — the routed MIX on the pure N=12, courses only
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.
2026-08-24 04:54:30 -04:00

233 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.");
}
}
}