A re-selection over rivers/02's candidates, not a new analysis: DrainageAnalysis is reused untouched and the 8-seed distribution sweep is not re-run. - RiverPromotionTool: ISLA_PROMOTE_MODE=composition builds both compositions at one fixed total — PURE (top N by drainage, terminus irrelevant) and QUOTA (the K largest sea-reaching forced in + the N-K largest endorheic). Deterministic; shortfall is handled and flagged rather than back-filled. BindCourses now takes an explicit candidate set, because a forced sea river can sit far below rank N (measured: #29). - DrainageRenderer.RiverComposition: the composition plate, at ONE ABSOLUTE width->drainage constant (1/180 px per sqrt(drainage px)) shared across both plates and all seeds — per-plate normalisation cannot answer "is this river thin?" — plus per-river drainage/rank labels with collision-avoided placement. - ToolingPaths.BatchRoot: additive overload for a lettered sub-task, so 02b writes to 02b_composition instead of claiming task 03's number. Taste gate: no N, no K, no default set anywhere. Nothing carved, no routing, and Giant.ProvisionalRoute is still never drawn.
1228 lines
76 KiB
C#
1228 lines
76 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ THE RIVER-PROMOTION BATCH (rivers/02) — DIAGNOSTIC FIRST, then a taste gate on the count.
|
||
///
|
||
/// ═══ WHAT THIS TASK IS FOR ═══
|
||
///
|
||
/// Choose the river COUNT against the terrain that actually exists. The M3 count of 3 was tuned on
|
||
/// topography that the southern stretch (→ D-065) and coastal fragmentation (→ D-063) have since
|
||
/// replaced, and `DrainageAnalysis.Params.TrunkCount / GiantCount / EndorheicMaxCount = 3` are
|
||
/// **LEAN REPORTING CAPS, NOT A STATEMENT ABOUT THE TERRAIN** — this island carries ~116 terminal
|
||
/// basins per seed and drains ~68 % of its land inland.
|
||
///
|
||
/// So: measure the full candidate distribution FIRST, see whether the terrain has a natural break,
|
||
/// and only then show 8 / 12 / 16 on the map. **This tool promotes a ladder, not a winner.**
|
||
///
|
||
/// ═══ WHAT IT DOES NOT DO ═══
|
||
///
|
||
/// No lowland routing (rivers/03), no water bodies, no carving, no crater. `DrainageAnalysis` is
|
||
/// REUSED, not rebuilt — it is ported and proven (chat2/12, re-derived bit-identically at
|
||
/// `03fe75b`). Everything here is enumeration, ranking, selection and render AROUND it.
|
||
///
|
||
/// ⚠⚠ `Giant.ProvisionalRoute` is never drawn. It is the steepest-descent placeholder (the "comb")
|
||
/// that rivers/03 replaces; drawing it would make a count judgment look like a river network.
|
||
///
|
||
/// ═══ ⭐ THE UNIFIED RANKING, AND WHERE IT IS BUILT ═══
|
||
///
|
||
/// The ranking is built over the **RAW CANDIDATES derived from the exposed `Plan` arrays**
|
||
/// (`Dir` / `Acc` / `BasinId` / `BasinInflow`), not over `Plan.Trunks` / `Plan.Giants`. That choice
|
||
/// matters: the `Trunks` / `Giants` lists are already truncated by the reporting caps, so ranking
|
||
/// over them would measure the caps rather than the terrain. Deriving from the arrays gives the
|
||
/// COMPLETE distribution, cap-free — which is the whole point of a diagnostic.
|
||
///
|
||
/// The caps are then raised (ISLA_PROMOTE_MAX) purely so the analysis's own `TraceStem` produces a
|
||
/// real upland course for every candidate that could be promoted; each promoted candidate is BOUND
|
||
/// to its `Trunk` (by outlet cell) or `Giant` (by basin id) to collect that course. No stem-tracing
|
||
/// is reimplemented here.
|
||
///
|
||
/// ═══ RUNNING IT ═══
|
||
///
|
||
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/RiverPromotionTool.tscn
|
||
///
|
||
/// ISLA_TASK / ISLA_BATCH / ISLA_CHAT / ISLA_OUTPUT_DIR / ISLA_SKIP_RAW
|
||
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 8192 / 2048)
|
||
/// ISLA_SEEDS distribution seeds (default: the 8 gallery seeds)
|
||
/// ISLA_RENDER_SEEDS seeds that also get maps (default: 4 of them)
|
||
/// ISLA_PROMOTE_FLOOR_PX significance floor for the DISTRIBUTION (default 5000)
|
||
/// ISLA_PROMOTE_N_LADDER the A/B counts (default 8,12,16)
|
||
/// ISLA_PROMOTE_MAX cap raised on the analysis so stems exist (default 24)
|
||
///
|
||
/// ═══ ⭐⭐ THE COMPOSITION MODE (rivers/02b) — ISLA_PROMOTE_MODE=composition ═══
|
||
///
|
||
/// rivers/02 (above) answered "how many?" and found the terrain has no natural count, and that its
|
||
/// honest top-of-distribution is INLAND-DOMINANT — 6 of 8 seeds have zero sea-reaching rivers in
|
||
/// their top 8. The developer wants to keep the total at a comfortable N but GUARANTEE coastal
|
||
/// presence for gameplay (the southern shipwreck, boats, coastal freshwater).
|
||
///
|
||
/// > ### That is a CONSCIOUS, MOTIVATED OVERRIDE of the pure unified ranking, not a correction of it.
|
||
/// > The unified ranking stays the mechanism. A **gameplay-motivated sea-river floor of K** is
|
||
/// > layered on top: the K largest SEA-REACHING candidates are forced in, and N−K inland ones fill
|
||
/// > the rest. It deliberately reopens the "no sea quota" call from rivers/02 — now that the honest
|
||
/// > distribution has been seen, and for a stated reason that is about the game, not the terrain.
|
||
///
|
||
/// This mode renders the two compositions side by side at the same total so the developer can judge
|
||
/// the one thing that decides it: **do the forced sea rivers read as real rivers, just smaller — or
|
||
/// as sad thin threads beside the big inland ones?**
|
||
///
|
||
/// ISLA_PROMOTE_MODE=composition pure-vs-quota instead of the count ladder
|
||
/// ISLA_PROMOTE_N the fixed total (default 12)
|
||
/// ISLA_PROMOTE_QUOTA_K the sea-river floor (default 3)
|
||
///
|
||
/// ⚠ It re-runs NOTHING it does not need: the 8-seed distribution sweep, `candidates_all` and
|
||
/// `distribution` are unchanged by a re-selection and are NOT regenerated. `DrainageAnalysis` is
|
||
/// again reused untouched — the quota is a re-selection over the candidates rivers/02 already
|
||
/// enumerated, not a new analysis. **And it locks nothing: no N, no K, no default.**
|
||
/// </summary>
|
||
public partial class RiverPromotionTool : Node
|
||
{
|
||
/// <summary>The 8 gallery seeds — the terrain `terrain-shape-v1` was judged across.</summary>
|
||
private static readonly int[] GallerySeeds =
|
||
{ 1063685222, 999999937, 20260822, 31415926, 27182818, 16180339, 14142135, 17320508 };
|
||
|
||
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
||
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||
|
||
private static readonly int[] DefaultRenderSeeds = { 1063685222, 999999937, 31415926, 14142135 };
|
||
private const int DefaultMapSize = 8192;
|
||
private const int DefaultCalibSize = 2048;
|
||
|
||
public override void _Ready()
|
||
{
|
||
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits with no
|
||
// main loop to end it, so a misconfigured run HANGS. Catch, say what was refused, exit 2.
|
||
try { Run(); }
|
||
catch (Exception e)
|
||
{
|
||
GD.PrintErr("==================================================================");
|
||
GD.PrintErr($" REFUSED: {e.Message}");
|
||
GD.PrintErr(e.StackTrace);
|
||
GD.PrintErr("==================================================================");
|
||
GetTree().Quit(2);
|
||
}
|
||
}
|
||
|
||
private sealed class SeedResult
|
||
{
|
||
public int Seed;
|
||
public List<RiverCandidate> Ranked; // separated + above floor, descending
|
||
public int SuppressedCount; // sea outlets dropped by the separation rule (all)
|
||
public long SuppressedPx; // …and the drainage they carried (all)
|
||
// ⚠ The two numbers that actually matter: a fragmented coastline has tens of thousands of
|
||
// one-cell outlets, so an aggregate "suppressed" figure is dominated by drainage that was
|
||
// never a candidate. THESE count only outlets that cleared the significance floor.
|
||
public int SuppressedAboveFloor;
|
||
public long SuppressedAboveFloorPx;
|
||
// ⚠ Of those, how many were suppressed by an outlet on a DIFFERENT LANDMASS — i.e. cannot
|
||
// possibly be "another mouth of the same delta". Measured, not assumed. → the note in Run().
|
||
public int SuppressedCrossLandmass;
|
||
public long SuppressedCrossLandmassPx;
|
||
// ⭐ THE DECISIVE NUMBER. Every suppressed above-floor outlet's drainage, kept so we can ask
|
||
// the only question that actually matters: would any of them have made the ladder? A rule
|
||
// that discards candidates too small to be promoted costs the decision nothing.
|
||
public List<long> SuppressedAboveFloorAccs = new();
|
||
public Dictionary<int, int> WouldHaveMadeN = new(); // ladder N -> suppressed outlets ≥ that cutoff
|
||
public long LandCells, SeaReachingCells, EndorheicCells, UnroutedCells;
|
||
public int TerminalBasins, SeaOutletsAll;
|
||
public int BreakRankRatio; public double BreakRatio; // the knee (log gap)
|
||
public int BreakRankAbs; public long BreakAbs; // the largest absolute gap
|
||
public Dictionary<int, (int sea, int endo, long areaAtN, bool enough)> AtN = new();
|
||
public ulong Ms;
|
||
/// <summary>rivers/02b only — the two compositions at the fixed total. Null in ladder mode.</summary>
|
||
public Composition Comp;
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ THE TWO COMPOSITIONS AT ONE FIXED TOTAL (rivers/02b) — same N, different mix.
|
||
///
|
||
/// PURE the top N candidates by drainage area, terminus irrelevant. What rivers/02 produces.
|
||
/// QUOTA the K largest SEA-REACHING candidates forced in, plus the N−K largest endorheic.
|
||
///
|
||
/// ⚠ Both are selections over the SAME unified ranking, computed by the SAME metric. The quota
|
||
/// does not re-rank anything and does not touch the analysis — it re-picks from the list.
|
||
/// </summary>
|
||
private sealed class Composition
|
||
{
|
||
public int N, K;
|
||
public List<RiverCandidate> Pure = new();
|
||
public List<RiverCandidate> Quota = new();
|
||
/// <summary>The K (or fewer) forced sea rivers, descending.</summary>
|
||
public List<RiverCandidate> ForcedSea = new();
|
||
/// <summary>The N−K (or fewer) inland rivers kept beside them, descending.</summary>
|
||
public List<RiverCandidate> KeptInland = new();
|
||
/// <summary>In PURE but not in QUOTA — the inland basins the floor displaced to make room.</summary>
|
||
public List<RiverCandidate> Displaced = new();
|
||
/// <summary>In QUOTA but not in PURE — the sea rivers the floor promoted.</summary>
|
||
public List<RiverCandidate> Added = new();
|
||
public int SeaAvailable, EndoAvailable;
|
||
/// <summary>⚠ Fewer than K sea / N−K inland candidates existed above the floor. Should not fire.</summary>
|
||
public bool SeaShort, EndoShort;
|
||
}
|
||
|
||
private void Run()
|
||
{
|
||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||
|
||
// ⭐ rivers/02b: "composition" swaps the count ladder for one fixed total in two mixes.
|
||
bool composition = EnvStr("ISLA_PROMOTE_MODE", "ladder").Trim().ToLowerInvariant() == "composition";
|
||
int compN = EnvInt("ISLA_PROMOTE_N", 12);
|
||
int compK = EnvInt("ISLA_PROMOTE_QUOTA_K", 3);
|
||
|
||
int task = EnvInt("ISLA_TASK", 2);
|
||
// ⭐ rivers/02b is a LETTERED SUB-TASK of 02 — same authoring task, one changed choice — so it
|
||
// writes to `02b_composition`, not to a fresh task number that would claim to be task 03.
|
||
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", composition ? "b" : "");
|
||
string descr = EnvStr("ISLA_BATCH", composition ? "composition" : "promotion");
|
||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||
// ⚠ In composition mode the DEFAULT seed set is the 4 RENDER seeds, not the 8 gallery seeds:
|
||
// the 8-seed distribution sweep is unchanged by a re-selection and re-running it would be
|
||
// ~2x the work for byte-identical numbers. rivers/02's sweep stands as the distribution of
|
||
// record. (Override with ISLA_SEEDS if that is ever wanted.)
|
||
int[] seeds = EnvSeeds("ISLA_SEEDS", composition ? DefaultRenderSeeds : GallerySeeds);
|
||
int[] renderSe = EnvSeeds("ISLA_RENDER_SEEDS", DefaultRenderSeeds);
|
||
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
|
||
int[] ladder = composition ? new[] { compN } : EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
|
||
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
|
||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||
|
||
if (composition)
|
||
{
|
||
if (compK < 0 || compK > compN)
|
||
throw new InvalidOperationException(
|
||
$"ISLA_PROMOTE_QUOTA_K ({compK}) must be between 0 and ISLA_PROMOTE_N ({compN}) — the quota is a FLOOR " +
|
||
"inside a fixed total, not a second budget on top of it.");
|
||
// The forced sea rivers come from Plan.Trunks and the kept inland from Plan.Giants, both
|
||
// truncated at the raised cap — so the cap must cover BOTH halves of the quota as well
|
||
// as a pure top-N that happens to be all inland. Refuse rather than draw a bare marker.
|
||
if (promoteMax < compK || promoteMax < compN - compK)
|
||
throw new InvalidOperationException(
|
||
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the quota's halves (K={compK} sea, N-K={compN - compK} inland). The cap is " +
|
||
"what makes the analysis trace a real upland stem for each; below it, a quota'd river would have no course to draw.");
|
||
}
|
||
|
||
int maxLadder = 0; foreach (int v in ladder) if (v > maxLadder) maxLadder = v;
|
||
if (promoteMax < maxLadder)
|
||
throw new InvalidOperationException(
|
||
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the largest ladder count ({maxLadder}). The cap is what " +
|
||
"makes the analysis trace a real upland stem for every promotable candidate; below the ladder, the " +
|
||
"top plate would have rivers with no course to draw. Raise it.");
|
||
|
||
// ⚠ rivers/01: the shape AND erosion come from the bare defaults. Assert before generating —
|
||
// a count chosen on drifted terrain is a count chosen for terrain nobody approved.
|
||
TerrainShapeV1.Assert("RiverPromotion");
|
||
TerrainShapeV1.AssertErosionDefaultOn("RiverPromotion");
|
||
|
||
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
|
||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||
|
||
var anchors = CurveAnchors.Default;
|
||
float sea = 0.15f;
|
||
|
||
// The analysis params. ⚠⚠ ONLY THE REPORTING CAPS MOVE. EndorheicMinDepthM and
|
||
// EndorheicMinAreaPx are NOT touched: they decide which depressions BECOME terminal basins,
|
||
// i.e. they define the routing surface itself. Changing them would change the drainage this
|
||
// task is meant to measure, not just how much of it is reported.
|
||
var dp = new DrainageAnalysis.Params
|
||
{
|
||
SeaLevel = sea,
|
||
TrunkCount = promoteMax, // reporting cap ↑ so stems exist
|
||
GiantCount = promoteMax, // reporting cap ↑
|
||
EndorheicMaxCount = promoteMax, // reporting cap ↑
|
||
EndorheicMinInflowPx = (int)floorPx, // reporting floor ↓ to the diagnostic floor
|
||
};
|
||
var dpDefaults = new DrainageAnalysis.Params();
|
||
|
||
GD.Print("==================================================================");
|
||
GD.Print(composition
|
||
? $" RIVER COMPOSITION (rivers/02b) — N={compN} PURE vs N={compN} with a gameplay sea-river floor of K={compK}"
|
||
: " RIVER PROMOTION (rivers/02) — measure the candidate distribution, THEN show the count ladder");
|
||
GD.Print("==================================================================");
|
||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||
GD.Print($"seeds : distribution {seeds.Length} — {string.Join(", ", seeds)}");
|
||
GD.Print($" : rendered {renderSe.Length} — {string.Join(", ", renderSe)}");
|
||
GD.Print($"ranking : UNIFIED — every major drainage by contributing-cell count, both termini in ONE list.");
|
||
if (composition)
|
||
{
|
||
GD.Print($" ⭐ and then, ON PURPOSE, a GAMEPLAY SEA-RIVER FLOOR of K={compK} layered over it:");
|
||
GD.Print($" PURE = top {compN} by drainage, terminus irrelevant (the mechanism, unaltered)");
|
||
GD.Print($" QUOTA = the {compK} largest SEA-REACHING forced + the {compN - compK} largest endorheic ({compK} sea + {compN - compK} inland = {compN})");
|
||
GD.Print($" The floor is motivated by GAMEPLAY (shipwreck / boats / coastal freshwater), not by the terrain,");
|
||
GD.Print($" and it deliberately reopens rivers/02's \"no sea quota\" call now the honest distribution is known.");
|
||
GD.Print($" width : ONE fixed constant, shared across both plates and all seeds — {DrainageRenderer.StemWidthLaw()}");
|
||
}
|
||
else
|
||
GD.Print($" the sea/endorheic split FALLS OUT; it is never quota'd. (departs from the reference's two lists)");
|
||
GD.Print($"floor : {floorPx:N0} px (diagnostic significance floor — NOT the promotion threshold)");
|
||
if (!composition)
|
||
GD.Print($"ladder : N = {string.Join(", ", ladder)} caps raised to {promoteMax} so every promotable river has a traced stem");
|
||
else
|
||
GD.Print($"fixed N : {compN} caps raised to {promoteMax} so every promotable river has a traced stem");
|
||
GD.Print($"UNCHANGED : EndorheicMinDepthM {dpDefaults.EndorheicMinDepthM} m · EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0} · MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx} · StemMinAccPx {dpDefaults.StemMinAccPx}");
|
||
// ⚠⚠ THE PARAMS ARE ABSOLUTE PIXEL COUNTS, SO THIS ANALYSIS IS SCALE-DEPENDENT.
|
||
// Measured at rivers/02: at 1024 a 10,000-cell basin is ~1 % of the map and NOTHING qualifies as
|
||
// endorheic (0 terminal basins, 100 % sea-reaching), while a 400 px separation is 39 % of the map
|
||
// width and suppresses 15,043 of 15,048 sea outlets. At 8192 the same numbers are 0.015 % and
|
||
// 4.9 %. A small-map probe of this tool therefore measures the PARAMS, not the terrain.
|
||
if (mapSize != 8192)
|
||
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the DrainageAnalysis params (EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0}, " +
|
||
$"MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx}) are ABSOLUTE PIXEL COUNTS tuned at 8192. At {mapSize} they scale " +
|
||
$"differently against the map ({100.0 * dpDefaults.EndorheicMinAreaPx / ((double)mapSize * mapSize):F3} % of area, " +
|
||
$"{100.0 * dpDefaults.MinOutletSeparationPx / mapSize:F1} % of width), so the candidate distribution is NOT comparable " +
|
||
"to the 8192 result and MUST NOT be used to choose a count. Pipeline smoke only.");
|
||
GD.Print($"batch : {batchRoot}");
|
||
GD.Print("==================================================================");
|
||
|
||
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, family-off pinned) ---");
|
||
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||
GD.Print($" {knots}");
|
||
|
||
TerrainGenConfig Cfg(int size, int seed) => new TerrainGenConfig
|
||
{
|
||
MapSize = size, Seed = seed, VariantLabel = "promotion",
|
||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||
};
|
||
|
||
var renderSet = new HashSet<int>(renderSe);
|
||
var results = new List<SeedResult>();
|
||
|
||
foreach (int seed in seeds)
|
||
{
|
||
ulong t0 = Time.GetTicksMsec();
|
||
GD.Print($"\n--- seed {seed} ---");
|
||
var cfg = Cfg(mapSize, seed);
|
||
Pass1Result p1 = Topography.Generate(cfg);
|
||
Pass2Result shaped = Shaping.Shape(p1, cfg);
|
||
var ero = ErosionPass.Apply(shaped, cfg);
|
||
Pass2Result p2 = ero.Shaped;
|
||
|
||
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field (→ D-066).
|
||
// A terminus "reaches the sea" iff it touches THIS, never a bare h < sea.
|
||
bool[] isOcean = RegionLabeling.OceanMask(p2.HeightClassify, mapSize, sea, out long oceanCells, out long enclosed);
|
||
var isClassifyWater = new bool[mapSize * mapSize];
|
||
for (int x = 0; x < mapSize; x++)
|
||
for (int y = 0; y < mapSize; y++)
|
||
if (p2.HeightClassify[x, y] < sea) isClassifyWater[x * mapSize + y] = true;
|
||
|
||
var plan = DrainageAnalysis.Run(p2.Height, mapSize, isOcean, isClassifyWater, -1f, -1f, dp);
|
||
GD.Print($" land {plan.LandCells:N0} — sea-reaching {plan.SeaReachingCells:N0} ({100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %), " +
|
||
$"endorheic {plan.EndorheicCells:N0} ({100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %), unrouted {plan.UnroutedCells:N0}; " +
|
||
$"terminal basins {plan.TerminalBasinCount}; ocean {oceanCells:N0} / enclosed {enclosed:N0}");
|
||
|
||
var r = Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, seed, p1.Regions);
|
||
r.Ms = Time.GetTicksMsec() - t0;
|
||
Analyse(r, ladder);
|
||
|
||
if (composition)
|
||
{
|
||
// ⚠ Bind the UNION of both compositions, not the top N: a forced sea river can sit far
|
||
// down the unified ranking (measured at rivers/02: rank 29 on the primary seed), so
|
||
// "top maxLadder" would leave it with no traced stem.
|
||
r.Comp = Compose(r, compN, compK);
|
||
var need = new List<RiverCandidate>(r.Comp.Pure);
|
||
foreach (var c in r.Comp.Quota) if (!need.Contains(c)) need.Add(c);
|
||
BindCourses(r, plan, mapSize, need, $"the union of the N={compN} pure and K={compK} quota compositions");
|
||
ReportComposition(r);
|
||
WriteCompositionCsv(batchRoot, r);
|
||
if (renderSet.Contains(seed))
|
||
RenderComposition(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, skipRaw);
|
||
}
|
||
else
|
||
{
|
||
BindCourses(r, plan, mapSize, r.Ranked.GetRange(0, Math.Min(maxLadder, r.Ranked.Count)), $"the top {maxLadder} candidates");
|
||
Report(r, ladder);
|
||
WriteCsv(batchRoot, r);
|
||
if (renderSet.Contains(seed))
|
||
RenderSeed(batchRoot, r, plan, isOcean, p2, mapSize, sea, ladder, floorPx, dpDefaults, skipRaw);
|
||
}
|
||
|
||
results.Add(r);
|
||
}
|
||
|
||
if (composition)
|
||
WriteCompositionIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, compN, compK, floorPx, promoteMax, dpDefaults, skipRaw);
|
||
else
|
||
WriteIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, ladder, floorPx, promoteMax, dpDefaults, skipRaw);
|
||
GD.Print("\n==================================================================");
|
||
GD.Print($" DONE — {batchRoot}");
|
||
GD.Print(composition
|
||
? $" ⛔ TASTE GATE: pure and quota K={compK} are PRESENTED, not decided. No N, no K, no default was set."
|
||
: " ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
|
||
GD.Print("==================================================================");
|
||
GetTree().Quit(0);
|
||
}
|
||
|
||
// ═══ ⭐⭐ ENUMERATION — the complete candidate set, derived from the exposed Plan arrays ═══
|
||
|
||
/// <summary>
|
||
/// Enumerate every candidate major drainage, cap-free.
|
||
///
|
||
/// SEA every cell with <c>Dir == D_SEA</c>, carrying <c>Acc</c> at that cell, then the
|
||
/// analysis's own greedy <c>MinOutletSeparationPx</c> rule so three mouths of one
|
||
/// delta are not three rivers.
|
||
/// ENDORHEIC every terminal basin present in <c>BasinId</c>, carrying <c>BasinInflow[id]</c>,
|
||
/// with its terminal cell / area / depth re-derived from the exposed surfaces.
|
||
///
|
||
/// ⚠ Nothing here re-runs or re-implements the analysis: `Dir`, `Acc`, `BasinId`, `BasinInflow`
|
||
/// and `FullFilled` are all exposed on `Plan`, and every derived quantity below is a
|
||
/// reconstruction of a value the analysis computed internally, from those arrays.
|
||
/// </summary>
|
||
private static SeedResult Enumerate(DrainageAnalysis.Plan plan, float[,] height, int n,
|
||
long floorPx, int separationPx, int seed, RegionLabels regions)
|
||
{
|
||
int total = n * n;
|
||
var r = new SeedResult
|
||
{
|
||
Seed = seed,
|
||
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) that means an ISLAND's only
|
||
// river can be suppressed by a mainland river's mouth 400 px away ACROSS WATER,
|
||
// which is not a delta by any definition. Measured here rather than argued.
|
||
// ⚠ The rule is NOT changed — it belongs to the analysis, and changing it would
|
||
// move the candidate set the developer is being asked to judge. This counts
|
||
// what it costs, so the count decision is made knowing it.
|
||
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(
|
||
"[RiverPromotion] 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>
|
||
/// The natural-break analysis and the per-N splits.
|
||
///
|
||
/// ⚠ TWO break statistics, because the obvious one is useless here. Drainage areas span three
|
||
/// or more decades, so the LARGEST ABSOLUTE GAP is almost always between rank 1 and rank 2 —
|
||
/// it measures the biggest river, not a natural count. The meaningful knee is the largest
|
||
/// RATIO between consecutive ranks, searched over a stated window that excludes the top of the
|
||
/// list. Both are reported; the ratio one is the answer.
|
||
/// </summary>
|
||
private static void Analyse(SeedResult r, int[] ladder)
|
||
{
|
||
var v = r.Ranked;
|
||
int lo = 3, hi = Math.Min(v.Count - 1, 40); // the stated window
|
||
r.BreakRankRatio = 0; r.BreakRatio = 1.0;
|
||
for (int i = lo - 1; i < hi; i++)
|
||
{
|
||
double ratio = v[i].DrainagePx / (double)Math.Max(1, v[i + 1].DrainagePx);
|
||
if (ratio > r.BreakRatio) { r.BreakRatio = ratio; r.BreakRankRatio = i + 1; }
|
||
}
|
||
r.BreakRankAbs = 0; r.BreakAbs = 0;
|
||
for (int i = 0; i < v.Count - 1; i++)
|
||
{
|
||
long gap = v[i].DrainagePx - v[i + 1].DrainagePx;
|
||
if (gap > r.BreakAbs) { r.BreakAbs = gap; r.BreakRankAbs = i + 1; }
|
||
}
|
||
foreach (int nn in ladder)
|
||
{
|
||
int s = 0, e = 0;
|
||
for (int i = 0; i < Math.Min(nn, v.Count); i++) { if (v[i].IsSea) s++; else e++; }
|
||
// ⭐ Would the separation rule have changed THIS rung? Count the suppressed outlets whose
|
||
// drainage clears the rung's cutoff. Zero means the rule cannot have altered the ladder,
|
||
// and the sea/endorheic split below is trustworthy exactly as shown.
|
||
long cut = nn <= v.Count ? v[nn - 1].DrainagePx : 0;
|
||
int would = 0;
|
||
foreach (long a in r.SuppressedAboveFloorAccs) if (a >= cut) would++;
|
||
r.WouldHaveMadeN[nn] = would;
|
||
// ⚠ A ladder rung above the candidate count is NOT "N with a 0 px smallest" — it is a rung
|
||
// the terrain cannot fill. Carried as a flag so the table says so instead of printing a 0.
|
||
r.AtN[nn] = (s, e, nn <= v.Count ? v[nn - 1].DrainagePx : 0, nn <= v.Count);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bind each promotable candidate to the <c>Trunk</c> / <c>Giant</c> the analysis already
|
||
/// traced, so the 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.
|
||
/// </summary>
|
||
private static void BindCourses(SeedResult r, 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; }
|
||
}
|
||
if (c.Course == null) missing++;
|
||
}
|
||
if (missing > 0)
|
||
throw new InvalidOperationException(
|
||
$"[RiverPromotion] {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.");
|
||
}
|
||
|
||
// ═══ ⭐⭐ THE COMPOSITION (rivers/02b) — the same N, two mixes ════════════════════════════
|
||
|
||
/// <summary>
|
||
/// ⭐⭐ Build both compositions at the fixed total N.
|
||
///
|
||
/// PURE <c>Ranked[0..N)</c> — the unified ranking, untouched. Terminus irrelevant.
|
||
/// QUOTA the K largest SEA-REACHING candidates + the N−K largest ENDORHEIC ones.
|
||
///
|
||
/// The ranking is already descending, so both halves of the quota are just the first K / N−K of
|
||
/// each terminus class. Fully deterministic — no tie-break, no randomness, no re-ranking. **The
|
||
/// metric is the same on both sides and the analysis is not consulted again: this is a
|
||
/// re-selection over candidates that were already enumerated.**
|
||
///
|
||
/// ⚠ Shortfall is handled rather than crashed: if fewer than K sea (or N−K inland) candidates
|
||
/// exist above the floor, ALL available are taken and the plate carries fewer than N rivers,
|
||
/// flagged into the INDEX. It is NOT back-filled from the other class — that would silently
|
||
/// turn "the terrain cannot supply K sea rivers" into a plate that looks like it can. rivers/02
|
||
/// measured 64–78 sea and 45–61 endorheic candidates per seed, so this should not fire.
|
||
/// </summary>
|
||
private static Composition Compose(SeedResult r, int N, int K)
|
||
{
|
||
var v = r.Ranked;
|
||
var comp = new Composition { N = N, K = K };
|
||
comp.Pure = v.GetRange(0, Math.Min(N, v.Count));
|
||
|
||
var sea = new List<RiverCandidate>();
|
||
var endo = new List<RiverCandidate>();
|
||
foreach (var c in v) { if (c.IsSea) sea.Add(c); else endo.Add(c); }
|
||
comp.SeaAvailable = sea.Count;
|
||
comp.EndoAvailable = endo.Count;
|
||
comp.SeaShort = sea.Count < K;
|
||
comp.EndoShort = endo.Count < N - K;
|
||
|
||
for (int i = 0; i < Math.Min(K, sea.Count); i++) comp.ForcedSea.Add(sea[i]);
|
||
for (int i = 0; i < Math.Min(N - K, endo.Count); i++) comp.KeptInland.Add(endo[i]);
|
||
comp.Quota.AddRange(comp.ForcedSea);
|
||
comp.Quota.AddRange(comp.KeptInland);
|
||
comp.Quota.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||
|
||
foreach (var c in comp.Pure) if (!comp.Quota.Contains(c)) comp.Displaced.Add(c);
|
||
foreach (var c in comp.Quota) if (!comp.Pure.Contains(c)) comp.Added.Add(c);
|
||
return comp;
|
||
}
|
||
|
||
/// <summary>The largest / smallest inland river the quota keeps — the yardsticks for "thread".</summary>
|
||
private static long LargestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[0].DrainagePx : 0;
|
||
private static long SmallestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[c.KeptInland.Count - 1].DrainagePx : 0;
|
||
|
||
private static string Compact(List<RiverCandidate> v)
|
||
{
|
||
var parts = new List<string>();
|
||
foreach (var c in v) parts.Add($"#{c.Rank}{(c.IsSea ? "S" : "E")} {c.DrainagePx:N0}");
|
||
return parts.Count == 0 ? "(none)" : string.Join(" · ", parts);
|
||
}
|
||
|
||
private static string Ordinal(int zeroBased)
|
||
{
|
||
int i = zeroBased + 1;
|
||
if (i == 1) return "largest";
|
||
string suffix = (i % 100 is >= 11 and <= 13) ? "th" : (i % 10) switch { 1 => "st", 2 => "nd", 3 => "rd", _ => "th" };
|
||
return $"{i}{suffix}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Coarse compass label for an outlet, from its offset relative to the map centre. ⚠ In this
|
||
/// codebase a cell is <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is
|
||
/// SOUTH on the plate — the sign is easy to get backwards and the label would then be a lie.
|
||
/// </summary>
|
||
private static string Compass(int x, int y, int n)
|
||
{
|
||
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
|
||
const float band = 0.35f; // "central" deadband
|
||
string ns = dy < -band ? "N" : dy > band ? "S" : "";
|
||
string ew = dx < -band ? "W" : dx > band ? "E" : "";
|
||
return ns + ew == "" ? "centre" : ns + ew;
|
||
}
|
||
|
||
private static void ReportComposition(SeedResult r)
|
||
{
|
||
var c = r.Comp;
|
||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||
GD.Print($" candidates above the floor: {r.Ranked.Count} ({c.SeaAvailable} sea / {c.EndoAvailable} endorheic)");
|
||
GD.Print($" PURE N={c.N} sea {pureSea} / inland {c.Pure.Count - pureSea}");
|
||
GD.Print($" {Compact(c.Pure)}");
|
||
GD.Print($" QUOTA N={c.N} K={c.K} sea {c.ForcedSea.Count} / inland {c.KeptInland.Count}");
|
||
GD.Print($" {Compact(c.Quota)}");
|
||
if (c.SeaShort) GD.PrintErr($" ⚠⚠ ONLY {c.SeaAvailable} SEA CANDIDATES EXIST above the floor — fewer than K={c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
|
||
if (c.EndoShort) GD.PrintErr($" ⚠⚠ ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES EXIST above the floor — fewer than N-K={c.N - c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
|
||
long lo = SmallestInland(c), hi = LargestInland(c);
|
||
GD.Print($" ⭐ the thread question, quantified — inland yardsticks: smallest kept {lo:N0} px (the {c.KeptInland.Count}th), largest kept {hi:N0} px");
|
||
foreach (var f in c.ForcedSea)
|
||
GD.Print($" forced sea #{f.Rank,-4} {f.DrainagePx,10:N0} px {(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}x the smallest kept inland " +
|
||
$"{(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}x the largest stem {DrainageRenderer.StemWidthFixed(f.DrainagePx)} px wide" +
|
||
(DrainageRenderer.StemWidthAtFloor(f.DrainagePx) ? " (AT THE LEGIBILITY FLOOR)" : ""));
|
||
GD.Print($" displaced by the floor (in PURE, not in QUOTA): {Compact(c.Displaced)}");
|
||
GD.Print($" promoted by the floor (in QUOTA, not in PURE): {Compact(c.Added)}");
|
||
}
|
||
|
||
private static void WriteCompositionCsv(string batchRoot, SeedResult r)
|
||
{
|
||
var c = r.Comp;
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("composition,slot,terminus,drainage_px,unified_rank,forced,river_term_x,river_term_y,basin_id,stem_width_px,in_other_composition");
|
||
void Rows(string name, List<RiverCandidate> v, List<RiverCandidate> other)
|
||
{
|
||
for (int i = 0; i < v.Count; i++)
|
||
{
|
||
var x = v[i];
|
||
bool forced = name == "quota" && x.IsSea;
|
||
sb.AppendLine($"{name},{i + 1},{x.TerminusName},{x.DrainagePx},{x.Rank},{(forced ? "yes" : "no")},{x.TermX},{x.TermY}," +
|
||
$"{(x.IsSea ? "" : x.BasinId.ToString())},{DrainageRenderer.StemWidthFixed(x.DrainagePx)},{(other.Contains(x) ? "yes" : "no")}");
|
||
}
|
||
}
|
||
Rows("pure", c.Pure, c.Quota);
|
||
Rows("quota", c.Quota, c.Pure);
|
||
WriteText(Path.Combine(batchRoot, $"composition_{r.Seed}.csv"), sb.ToString());
|
||
}
|
||
|
||
/// <summary>
|
||
/// The two plates, on ONE shared faint-terrain base, at the ONE shared width scale.
|
||
///
|
||
/// ⚠ `candidates_all` and `distribution` are NOT re-rendered: a re-selection does not change the
|
||
/// candidate set or its distribution, and rivers/02's plates are the ones of record.
|
||
/// </summary>
|
||
private static void RenderComposition(string batchRoot, SeedResult r, bool[] isOcean,
|
||
Pass2Result p2, int n, float sea, long floorPx, bool skipRaw)
|
||
{
|
||
var c = r.Comp;
|
||
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
|
||
// One base, duplicated — it is 1 SetPixel per cell (67 M at 8192) and both plates share it.
|
||
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||
|
||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||
DrainageRenderer.RiverComposition(c.Pure, baseImg.Duplicate() as Image, n,
|
||
$"SEED {r.Seed} - N{c.N} PURE UNIFIED RANKING",
|
||
$"PURE: THE TOP {c.N} BY DRAINAGE AREA, TERMINUS IRRELEVANT - {pureSea} SEA / {c.Pure.Count - pureSea} INLAND, A SPLIT THAT FELL OUT AND WAS NEVER QUOTA'D",
|
||
r.Ranked.Count, floorPx, true)
|
||
.SavePng(Path.Combine(dir, $"N{c.N}_pure.png"));
|
||
|
||
long lo = SmallestInland(c);
|
||
DrainageRenderer.RiverComposition(c.Quota, baseImg.Duplicate() as Image, n,
|
||
$"SEED {r.Seed} - N{c.N} WITH A GAMEPLAY SEA-RIVER FLOOR OF K={c.K}",
|
||
$"QUOTA: THE {c.ForcedSea.Count} LARGEST SEA-REACHING FORCED IN + THE {c.KeptInland.Count} LARGEST INLAND - SAME TOTAL, DIFFERENT MIX. SMALLEST KEPT INLAND {lo:N0} PX",
|
||
r.Ranked.Count, floorPx, true)
|
||
.SavePng(Path.Combine(dir, $"N{c.N}_quota_K{c.K}.png"));
|
||
|
||
// Grayscale beside the pretty plates — the house rule: the field must be inspectable
|
||
// without the palette in the way. Ranges printed so the plate is readable as data.
|
||
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
|
||
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
|
||
}
|
||
|
||
// ═══ OUTPUT ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
private static void Report(SeedResult r, int[] ladder)
|
||
{
|
||
var v = r.Ranked;
|
||
GD.Print($" candidates above the floor: {v.Count} ({CountSea(v)} sea / {v.Count - CountSea(v)} endorheic)");
|
||
GD.Print($" separation: {r.SeaOutletsAll:N0} raw sea outlets -> {r.SuppressedCount:N0} suppressed ({r.SuppressedPx:N0} px total, mostly one-cell coastal trickles);" +
|
||
$" of those only {r.SuppressedAboveFloor:N0} cleared the floor ({r.SuppressedAboveFloorPx:N0} px) — THAT is what the rule costs the candidate pool");
|
||
GD.Print($" ⚠ of those {r.SuppressedAboveFloor:N0}, {r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) were suppressed by an outlet on a DIFFERENT LANDMASS" +
|
||
" — not a delta mouth by any definition; the rule is landmass-blind (reported, not changed)");
|
||
long maxSup = 0; foreach (long a in r.SuppressedAboveFloorAccs) if (a > maxSup) maxSup = a;
|
||
GD.Print($" ⭐ largest suppressed above-floor outlet: {maxSup:N0} px" +
|
||
$" · suppressed outlets that clear each ladder cutoff: " +
|
||
string.Join(" ", new List<string>(ladder.Length == 0 ? new string[0] : Array.ConvertAll(ladder, nn => $"N={nn}:{(r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0)}"))));
|
||
GD.Print($" knee (largest RATIO between consecutive ranks, window 3..40): rank {r.BreakRankRatio} at {r.BreakRatio:F2}x" +
|
||
$" · largest ABSOLUTE gap: rank {r.BreakRankAbs} ({r.BreakAbs:N0} px — expected near the top; not a count)");
|
||
foreach (int nn in ladder)
|
||
{
|
||
var (s, e, a, enough) = r.AtN[nn];
|
||
GD.Print(enough
|
||
? $" N={nn,-3} sea {s,2} / endorheic {e,2} smallest promoted {a:N0} px"
|
||
: $" N={nn,-3} ⚠ ONLY {r.Ranked.Count} CANDIDATES EXIST above the floor — this rung cannot be filled");
|
||
}
|
||
for (int i = 0; i < Math.Min(12, v.Count); i++)
|
||
GD.Print($" #{v[i].Rank,-3} {v[i].TerminusName,-9} {v[i].DrainagePx,12:N0} px ({v[i].X},{v[i].Y})" +
|
||
(v[i].IsSea ? "" : $" basin {v[i].BasinAreaPx:N0} px / {v[i].BasinDepthM:F1} m"));
|
||
}
|
||
|
||
private static int CountSea(List<RiverCandidate> v) { int s = 0; foreach (var c in v) if (c.IsSea) s++; return s; }
|
||
|
||
private static long MaxOf(List<long> v) { long m = 0; foreach (long a in v) if (a > m) m = a; return m; }
|
||
|
||
private static void WriteCsv(string batchRoot, SeedResult r)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("rank,terminus,drainage_px,basin_min_x,basin_min_y,river_term_x,river_term_y,basin_id,basin_area_px,basin_depth_m");
|
||
foreach (var c in r.Ranked)
|
||
sb.AppendLine($"{c.Rank},{c.TerminusName},{c.DrainagePx},{c.X},{c.Y},{c.TermX},{c.TermY}," +
|
||
$"{(c.IsSea ? "" : c.BasinId.ToString())},{(c.IsSea ? "" : c.BasinAreaPx.ToString())}," +
|
||
$"{(c.IsSea ? "" : c.BasinDepthM.ToString("F2"))}");
|
||
WriteText(Path.Combine(batchRoot, $"candidates_{r.Seed}.csv"), sb.ToString());
|
||
}
|
||
|
||
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
|
||
Pass2Result p2, int n, float sea, int[] ladder, long floorPx, DrainageAnalysis.Params def, bool skipRaw)
|
||
{
|
||
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||
|
||
DrainageRenderer.Distribution(r.Ranked, ladder, def.EndorheicMinInflowPx, def.StemMinAccPx, floorPx,
|
||
$"SEED {r.Seed} - CANDIDATE DRAINAGE DISTRIBUTION")
|
||
.SavePng(Path.Combine(dir, "distribution.png"));
|
||
|
||
// ⚠ The faint-terrain base is 1 SetPixel per cell — 67 M at 8192 — and all four overlay maps
|
||
// share it. Build it once and duplicate; rendering it per map would quadruple the slowest
|
||
// part of this tool for four identical results.
|
||
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||
|
||
DrainageRenderer.PromotionCandidates(r.Ranked, baseImg.Duplicate() as Image, n,
|
||
$"SEED {r.Seed} - ALL CANDIDATES (DIAGNOSTIC, NOTHING PROMOTED)", ladder)
|
||
.SavePng(Path.Combine(dir, "candidates_all.png"));
|
||
|
||
foreach (int nn in ladder)
|
||
{
|
||
var promoted = r.Ranked.GetRange(0, Math.Min(nn, r.Ranked.Count));
|
||
DrainageRenderer.PromotedRivers(promoted, baseImg.Duplicate() as Image, n, nn, floorPx,
|
||
$"SEED {r.Seed} - PROMOTED TOP {nn} (UNIFIED RANKING)")
|
||
.SavePng(Path.Combine(dir, $"promoted_N{nn:D2}.png"));
|
||
}
|
||
|
||
// Grayscale beside the pretty renders — the house rule: a colour map is a reading of a
|
||
// field, and the field itself must be inspectable without the palette in the way.
|
||
GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
|
||
}
|
||
|
||
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
|
||
List<SeedResult> rows, int[] ladder, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
|
||
{
|
||
var sb = new StringBuilder();
|
||
int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
|
||
sb.AppendLine($"# Batch {02:D2} — river promotion: how many rivers does THIS terrain have?");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⛔ THIS IS A TASTE GATE. It presents 8 / 12 / 16 and stops — no count is chosen here, and no");
|
||
sb.AppendLine("default is set.** The measurement below exists so the pick is made on the drainage-area");
|
||
sb.AppendLine("distribution rather than on a number inherited from terrain that no longer exists.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## 👉 The pick");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Open the three count plates for seed `{primary}` **side by side**:");
|
||
sb.AppendLine();
|
||
foreach (int nn in ladder) sb.AppendLine($"- **`{primary}/promoted_N{nn:D2}.png`**");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Then `{primary}/distribution.png` beside them — it shows where each N falls on the curve, and");
|
||
sb.AppendLine("whether the terrain has a knee to justify one. **The question is not \"which looks prettiest\"**");
|
||
sb.AppendLine("but: *at which N do the promoted rivers still read as the island's major drainages, and at which");
|
||
sb.AppendLine("N does the set start including things that are not rivers?*");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠ Read the colours as information, not decoration.");
|
||
sb.AppendLine("> **Cyan = reaches the ocean, orange = ends inland.** On this terrain ~68 % of land drains");
|
||
sb.AppendLine("> inland by design (→ D-065), so a plate that is mostly orange is the CORRECT result, not a");
|
||
sb.AppendLine("> broken one. An endorheic terminus is a pass, equal to reaching the sea — never a fallback.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐ The unified ranking — what changed, and why");
|
||
sb.AppendLine();
|
||
sb.AppendLine("The reference promoted from **two lists with two quotas** (N sea trunks, N endorheic giants).");
|
||
sb.AppendLine("That structure assumes reaching the sea is what makes a drainage a river. **This terrain does not");
|
||
sb.AppendLine("satisfy that assumption**, so selection here is unified: every major drainage is ranked by");
|
||
sb.AppendLine("contributing-cell count in ONE list, the top N is promoted, and the sea/endorheic split is an");
|
||
sb.AppendLine("*outcome*. A quota would have promoted small coastal drainages over far larger inland ones purely");
|
||
sb.AppendLine("because of where they end. *(A deliberate departure from the reference's structure; only the");
|
||
sb.AppendLine("SELECTION is unified — the per-river terminus tag is retained, because rivers/03's routing branches on it.)*");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**The metric is the same unit on both sides, and that is asserted, not assumed:** sea-outlet");
|
||
sb.AppendLine("`Acc` and endorheic `BasinInflow` are both counts of contributing land cells on the same D8");
|
||
sb.AppendLine("field, and every land cell has exactly one destination. The tool refuses to rank unless");
|
||
sb.AppendLine("`Σ sea Acc + Σ BasinInflow + unrouted == LandCells` holds exactly, per seed. It held on every seed.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The natural-break question — does a count generalize across the terrain?");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**The knee is the largest RATIO between consecutive ranks** (window 3..40). The largest *absolute*");
|
||
sb.AppendLine("gap is reported too, and is deliberately not the answer: areas span 3+ decades, so the absolute");
|
||
sb.AppendLine("gap almost always sits at rank 1–2 and measures the biggest river, not a natural count.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Seed | candidates | sea / endo | ⭐ knee rank (ratio) | largest abs gap (rank) | " +
|
||
string.Join(" | ", Array.ConvertAll(ladder, x => $"N={x} sea/endo · smallest px")) + " |");
|
||
sb.AppendLine("|---|---|---|---|---|" + string.Concat(Array.ConvertAll(ladder, _ => "---|")));
|
||
foreach (var r in rows)
|
||
{
|
||
var cells = new List<string>();
|
||
foreach (int nn in ladder)
|
||
{
|
||
var (s, e, a, enough) = r.AtN[nn];
|
||
cells.Add(enough ? $"{s} / {e} · {a:N0}" : $"⚠ only {r.Ranked.Count}");
|
||
}
|
||
sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} | {CountSea(r.Ranked)} / {r.Ranked.Count - CountSea(r.Ranked)} | " +
|
||
$"**{r.BreakRankRatio}** ({r.BreakRatio:F2}×) | {r.BreakRankAbs} ({r.BreakAbs:N0} px) | " +
|
||
string.Join(" | ", cells) + " |");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Seed | land cells | → ocean | → endorheic | unrouted | terminal basins | raw sea outlets | suppressed (all) | suppressed ABOVE the floor | of those, cross-landmass | largest suppressed | ⭐⭐ would have made N=8/12/16 |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|");
|
||
foreach (var r in rows)
|
||
sb.AppendLine($"| `{r.Seed}` | {r.LandCells:N0} | {r.SeaReachingCells:N0} ({100.0 * r.SeaReachingCells / Math.Max(1, r.LandCells):F1} %) | " +
|
||
$"**{r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %)** | {r.UnroutedCells:N0} | " +
|
||
$"{r.TerminalBasins} | {r.SeaOutletsAll:N0} | {r.SuppressedCount:N0} ({r.SuppressedPx:N0} px) | " +
|
||
$"**{r.SuppressedAboveFloor:N0} ({r.SuppressedAboveFloorPx:N0} px)** | " +
|
||
$"{r.SuppressedCrossLandmass:N0} ({r.SuppressedCrossLandmassPx:N0} px) | " +
|
||
$"{MaxOf(r.SuppressedAboveFloorAccs):N0} px | " +
|
||
$"**{string.Join(" / ", Array.ConvertAll(ladder, nn => (r.WouldHaveMadeN.TryGetValue(nn, out int w) ? w : 0).ToString()))}** |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠ What the separation rule discards, stated plainly");
|
||
sb.AppendLine($"> `MinOutletSeparationPx = {def.MinOutletSeparationPx}` drops a sea outlet when a larger one sits within that radius —");
|
||
sb.AppendLine("> so three mouths of one delta are not three rivers. **But under D8 each cell has exactly one flow");
|
||
sb.AppendLine("> path, so those mouths have DISJOINT contributing areas: the rule discards a dropped outlet's");
|
||
sb.AppendLine("> drainage rather than merging it into the kept one.** For counting rivers that is the intent; the");
|
||
sb.AppendLine("> two suppressed columns are how much it removes. ⚠ Read the SECOND one: a fragmented coastline");
|
||
sb.AppendLine("> has tens of thousands of one-cell outlets, so the aggregate figure is dominated by drainage that");
|
||
sb.AppendLine("> was never a candidate. Only outlets that cleared the floor could ever have been promoted, and");
|
||
sb.AppendLine("> that is the honest cost of the rule.");
|
||
sb.AppendLine(">");
|
||
sb.AppendLine("> ### ⚠⚠ AND THE RULE IS LANDMASS-BLIND — the last column is the part that should bother you.");
|
||
sb.AppendLine("> `MinOutletSeparationPx` is a plain Euclidean distance test. It has no idea which land a");
|
||
sb.AppendLine("> coastline belongs to, so on **this deliberately fragmented archipelago (→ D-063)** it can");
|
||
sb.AppendLine("> suppress an ISLAND's only river because a mainland river's mouth sits within 400 px **across");
|
||
sb.AppendLine("> open water** — which is not a delta by any definition. The last column counts exactly that.");
|
||
sb.AppendLine(">");
|
||
sb.AppendLine("> **The rule was NOT changed here.** It belongs to `DrainageAnalysis`, and moving it would move");
|
||
sb.AppendLine("> the candidate set this gate asks the developer to judge. It is measured so the count is chosen");
|
||
sb.AppendLine("> knowing the cost. **→ rivers/03 should decide whether separation becomes component-aware**");
|
||
sb.AppendLine("> (the region layer already exposes `RegionLabels.Id` per cell, so the fix is one lookup) —");
|
||
sb.AppendLine("> and if it does, the sea side of this ranking grows and the split at each N shifts.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠⚠ This analysis is SCALE-DEPENDENT, and that is a finding, not a footnote.");
|
||
sb.AppendLine($"> `EndorheicMinAreaPx` ({def.EndorheicMinAreaPx:N0}) and `MinOutletSeparationPx` ({def.MinOutletSeparationPx}) are ABSOLUTE pixel counts tuned at");
|
||
sb.AppendLine("> 8192. Measured at 1024 during this task's smoke: **zero** depressions qualify as terminal basins");
|
||
sb.AppendLine("> (100 % sea-reaching — the endorheic half of the ranking cannot be exercised at all), and the");
|
||
sb.AppendLine("> separation radius is 39 % of the map width, suppressing 15,043 of 15,048 sea outlets down to four");
|
||
sb.AppendLine("> candidates. **A small-map run of this tool measures the params, not the terrain**, so every number");
|
||
sb.AppendLine("> below is from 8192 and the tool prints a loud refusal-to-compare at any other size.");
|
||
sb.AppendLine("> *(Flagged for rivers/03: if routing ever needs another size, these two want to become");
|
||
sb.AppendLine("> scale-free fractions of map area / width, exactly as `MinLandComponentFrac` already is.)*");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## What was run");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Distribution on **{seeds.Length} seeds** at {mapSize} (`{string.Join(", ", seeds)}`); maps on **{renderSe.Length}**");
|
||
sb.AppendLine($"(`{string.Join(", ", renderSe)}`). Curve calibrated at {calibSize} on the family-off pinned pool.");
|
||
sb.AppendLine($"Terrain: {TerrainShapeV1.Describe()} + erosion ON — all from the bare defaults (rivers/01).");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Diagnostic floor** `{floorPx:N0}` px — the significance floor for the DISTRIBUTION, deliberately far");
|
||
sb.AppendLine("below any plausible count so the curve's shape is visible. **It is not a promotion threshold.**");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`, all 3 by");
|
||
sb.AppendLine("default) purely so the analysis traces a real upland stem for every promotable candidate.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} decide which");
|
||
sb.AppendLine("depressions BECOME terminal basins — they define the routing surface itself, not how much of it is");
|
||
sb.AppendLine($"reported. Also unchanged: `MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}, `TributaryMinAccPx` {def.TributaryMinAccPx:N0}.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Files");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| File | What it is |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine("| `<seed>/distribution.png` | drainage area (log) vs unified rank, with N marks and the analysis's own thresholds |");
|
||
sb.AppendLine("| `<seed>/candidates_all.png` | every candidate on the terrain, marker AREA ∝ drainage, colour by terminus |");
|
||
foreach (int nn in ladder) sb.AppendLine($"| `<seed>/promoted_N{nn:D2}.png` | the unified top {nn}: real upland stems, width ∝ drainage, terminus markers |");
|
||
sb.AppendLine("| `<seed>/grayscale.png` | the raw eroded render field, no palette — the field behind every colour map |");
|
||
if (skipRaw)
|
||
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written.** rivers/01 proved this exact field byte-identical to `chat2/11_erosion`, which is the anchor of record — re-dumping 256 MB per seed of a field that already exists elsewhere is waste, not evidence. Regenerate with `ISLA_SKIP_RAW=0`. |");
|
||
else
|
||
sb.AppendLine("| `<seed>/height.f32` | the eroded render field this analysis ran on |");
|
||
sb.AppendLine("| `candidates_<seed>.csv` | the full ranked list for every distribution seed |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⚠ What is NOT here");
|
||
sb.AppendLine();
|
||
sb.AppendLine("- **No lowland routing.** The plates draw the REAL upland stems (erosion-carved, max-accumulation).");
|
||
sb.AppendLine(" `Giant.ProvisionalRoute` — the steepest-descent placeholder, the visible \"comb\" — is deliberately");
|
||
sb.AppendLine(" **not drawn**; replacing it is rivers/03's job, and drawing it would make a count look like a");
|
||
sb.AppendLine(" finished network. Below each terminus the real course is still un-routed.");
|
||
sb.AppendLine("- **No water, no carving, no crater.** Nothing here modifies terrain; the analysis is pure.");
|
||
sb.AppendLine("- **No chosen count.** That is the developer's call, and it is the point of the gate.");
|
||
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/02_promotion.report.md`");
|
||
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⭐ THE COMPOSITION INDEX (rivers/02b) — it foregrounds the ONE question the developer stated,
|
||
/// and puts the numbers that predict the answer above the plates that confirm it.
|
||
/// </summary>
|
||
private static void WriteCompositionIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
|
||
List<SeedResult> rows, int compN, int compK, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
|
||
{
|
||
var sb = new StringBuilder();
|
||
int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
|
||
string pure = $"N{compN}_pure.png", quota = $"N{compN}_quota_K{compK}.png";
|
||
|
||
sb.AppendLine($"# Batch 02b — composition: N={compN} pure ranking vs N={compN} with a gameplay sea-river floor of K={compK}");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⛔ THIS IS A TASTE GATE. Nothing is locked — no count, no K, no default in `TerrainGenConfig` or");
|
||
sb.AppendLine("`DrainageAnalysis.Params`.** Two compositions of the same total are presented; the developer picks.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## 👉 The pick");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Open these two **side by side** for seed `{primary}`:");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"- **`{primary}/{pure}`** — the top {compN} by drainage area, terminus irrelevant. rivers/02's mechanism, unaltered.");
|
||
sb.AppendLine($"- **`{primary}/{quota}`** — the same {compN}, re-mixed: the **{compK} largest sea-reaching forced in**, plus the {compN - compK} largest inland.");
|
||
sb.AppendLine();
|
||
var others = new List<string>();
|
||
foreach (int x in renderSe) if (x != primary) others.Add($"`{x}`");
|
||
sb.AppendLine($"Then confirm the read generalizes on the other {others.Count}: " + string.Join(", ", others) + ".");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||
sb.AppendLine($"> **Do the {compK} forced sea rivers hold up as REAL rivers — just smaller — or do they read as sad thin");
|
||
sb.AppendLine("> threads beside the big inland ones?**");
|
||
sb.AppendLine(">");
|
||
sb.AppendLine($"> - **Real → the quota is in at K={compK}.** Graduate as *\"unified ranking + a gameplay-motivated sea-river floor of K\"*.");
|
||
sb.AppendLine($"> - **Thin → drop to K=2** (a one-value re-run: `ISLA_PROMOTE_QUOTA_K=2`) **or accept inland-dominant** and keep the pure ranking.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Why this override exists, and what it costs");
|
||
sb.AppendLine();
|
||
sb.AppendLine("rivers/02 established two things. The count is a **design choice** — the drainage-area distribution is a");
|
||
sb.AppendLine("power law with no natural break (the knee wanders rank 3→19 at ~1.4×). And this terrain's honest");
|
||
sb.AppendLine($"top-of-distribution is **inland-dominant**: 6 of 8 seeds have zero sea-reaching rivers in their top 8.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"The floor of K={compK} is a **conscious, motivated override of the pure ranking**, for gameplay — the southern");
|
||
sb.AppendLine("shipwreck, boats, coastal freshwater. **The unified ranking stays the mechanism**; the quota is layered");
|
||
sb.AppendLine("over it, and it deliberately reopens rivers/02's \"no sea quota\" call *on purpose*, now that the honest");
|
||
sb.AppendLine("distribution has been seen. It is not a correction of that call — it is a decision to overrule it for a");
|
||
sb.AppendLine("reason that is about the game rather than the terrain.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**What it costs is visible and bounded:** the displaced-basins column below names exactly which inland");
|
||
sb.AppendLine("drainages step aside, and the ratio columns say how much smaller the rivers taking their place are.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## ⭐⭐ The thread question, quantified — read this BEFORE opening the plates");
|
||
sb.AppendLine();
|
||
sb.AppendLine("The ratio of a forced sea river to the inland rivers it sits beside predicts \"thread\" before the eye");
|
||
sb.AppendLine($"is involved. Yardsticks: the **smallest inland kept** (the {compN - compK}th, the weakest thing the quota did NOT displace) and");
|
||
sb.AppendLine("the **largest inland** on the plate. A forced river near 1.0× the smallest is simply another river of the");
|
||
sb.AppendLine("set; one near 0.1× the largest is a thread next to it whatever the plate looks like.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| Seed | forced sea river | drainage px | unified rank | ÷ smallest inland kept | ÷ largest inland | stem px | ⚠ where it reaches the sea |");
|
||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||
int atFloorRows = 0;
|
||
foreach (var r in rows)
|
||
{
|
||
var c = r.Comp;
|
||
long lo = SmallestInland(c), hi = LargestInland(c);
|
||
for (int i = 0; i < c.ForcedSea.Count; i++)
|
||
{
|
||
var f = c.ForcedSea[i];
|
||
string bold = i == c.ForcedSea.Count - 1 ? "**" : "";
|
||
bool floor = DrainageRenderer.StemWidthAtFloor(f.DrainagePx);
|
||
if (floor) atFloorRows++;
|
||
sb.AppendLine($"| `{r.Seed}` | {Ordinal(i)} | {bold}{f.DrainagePx:N0}{bold} | #{f.Rank} | " +
|
||
$"{bold}{(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}×{bold} | {(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}× | " +
|
||
$"{DrainageRenderer.StemWidthFixed(f.DrainagePx)}{(floor ? " ⚠floor" : "")} | {Compass(f.TermX, f.TermY, mapSize)} ({f.TermX},{f.TermY}) |");
|
||
}
|
||
sb.AppendLine($"| `{r.Seed}` | *(yardsticks)* | smallest inland kept **{lo:N0}** · largest inland **{hi:N0}** | | | | | |");
|
||
}
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### ⚠⚠ THE FLOOR GUARANTEES SEA PRESENCE, NOT *WHERE* — read the last column.");
|
||
sb.AppendLine("> \"The K largest sea-reaching\" has **no spatial term in it**. The gameplay motive names the **southern**");
|
||
sb.AppendLine("> shipwreck specifically, and nothing in this rule promises a river anywhere near it: the K outlets can");
|
||
sb.AppendLine("> all land on one coast. Measured above per seed — check the coast spread before reading the count as");
|
||
sb.AppendLine("> \"coastal presence solved\". If the shipwreck needs a river, that is a placement constraint and a");
|
||
sb.AppendLine("> separate decision from K. *(Not designed in here: this gate was scoped to pure-vs-quota only.)*");
|
||
sb.AppendLine();
|
||
if (atFloorRows > 0)
|
||
{
|
||
sb.AppendLine($"> ⚠ `⚠floor` marks a stem drawn at the **{DrainageRenderer.StemWidthMinPx} px legibility floor** — a 1 px line at {mapSize} is invisible at any");
|
||
sb.AppendLine("> zoom a person actually reads a plate at, so such a river is **no thinner than drawn, possibly thinner**.");
|
||
sb.AppendLine("> It is flagged because the floor sits exactly where the \"thread\" verdict lives.");
|
||
}
|
||
else
|
||
sb.AppendLine($"> ✅ **No river on any plate hit the {DrainageRenderer.StemWidthMinPx} px legibility floor** (thinnest drawn stem is above it), so every width on");
|
||
if (atFloorRows == 0)
|
||
sb.AppendLine("> these plates is the fixed law's honest output — nothing was widened to stay visible.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## The composition delta — what the floor swapped");
|
||
sb.AppendLine();
|
||
sb.AppendLine("`#nS` / `#nE` is the candidate's rank in the **full** unified distribution (S = sea, E = endorheic).");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"| Seed | candidates (sea / endo) | PURE sea/inland | QUOTA sea/inland | ⭐ inland basins DISPLACED | sea rivers PROMOTED |");
|
||
sb.AppendLine("|---|---|---|---|---|---|");
|
||
foreach (var r in rows)
|
||
{
|
||
var c = r.Comp;
|
||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||
sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} ({c.SeaAvailable} / {c.EndoAvailable}) | {pureSea} / {c.Pure.Count - pureSea} | " +
|
||
$"{c.ForcedSea.Count} / {c.KeptInland.Count} | {Compact(c.Displaced)} | {Compact(c.Added)} |");
|
||
}
|
||
sb.AppendLine();
|
||
foreach (var r in rows)
|
||
{
|
||
var c = r.Comp;
|
||
sb.AppendLine($"**`{r.Seed}`** — every river, both compositions:");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"- PURE : {Compact(c.Pure)}");
|
||
sb.AppendLine($"- QUOTA: {Compact(c.Quota)}");
|
||
if (c.SeaShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.SeaAvailable} SEA CANDIDATES exist above the floor — fewer than K={compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}. Not back-filled from the inland list: that would hide the fact that the terrain cannot supply K.");
|
||
if (c.EndoShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES exist above the floor — fewer than N−K={compN - compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}.");
|
||
sb.AppendLine();
|
||
}
|
||
sb.AppendLine("## ⭐ The fixed shared width scale — the one thing these plates must get right");
|
||
sb.AppendLine();
|
||
sb.AppendLine("> ### The question is \"is this river thin?\", so a plate that rescales itself cannot answer it.");
|
||
sb.AppendLine($"> rivers/02's `promoted_N*.png` normalise stem width to the widest river **on their own plate**. Under");
|
||
sb.AppendLine("> that law a forced sea river drawn thin would be reporting the plate's contents, not the river's size.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"So both plates, on all {renderSe.Length} seeds, use **one absolute constant**:");
|
||
sb.AppendLine();
|
||
sb.AppendLine("```");
|
||
sb.AppendLine($"stem width px = clamp( round( sqrt(drainage px) × {DrainageRenderer.StemWidthPerSqrtPx:F6} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
|
||
sb.AppendLine($" = clamp( round( sqrt(drainage px) / {1f / DrainageRenderer.StemWidthPerSqrtPx:F0} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
|
||
sb.AppendLine("```");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**The constant is `{DrainageRenderer.StemWidthPerSqrtPx:F6}` px of stem width per √(drainage px)** — never per-plate,");
|
||
sb.AppendLine("never per-seed. Width ∝ √area, so the *visual weight* of a stem tracks its drainage rather than");
|
||
sb.AppendLine("exaggerating it quadratically. It was chosen once from the measured population: the largest candidate");
|
||
sb.AppendLine("on any of the eight gallery seeds is **4,474,342 px** (seed `17320508`), √ = 2,115, so the biggest");
|
||
sb.AppendLine($"drainage the terrain produces draws at ~{(int)Math.Round(Math.Sqrt(4474342.0) * DrainageRenderer.StemWidthPerSqrtPx)} px — inside the {DrainageRenderer.StemWidthMaxPx} px ceiling, with nothing clipped anywhere.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Per-river labels read `<drainage> R<rank>` — e.g. `272K R29` beside `2.33M R1`. The rank is in the");
|
||
sb.AppendLine("**full** candidate distribution, not in the plate, so it says how far down the list the floor reached.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## What was run, and what was NOT");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Chain + analysis re-run at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all of them rendered.");
|
||
sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON, bare defaults (rivers/01).");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**⚠ NOT re-run, because a re-selection cannot change them:**");
|
||
sb.AppendLine();
|
||
sb.AppendLine("- the **8-seed distribution sweep** — rivers/02's is the distribution of record;");
|
||
sb.AppendLine("- **`candidates_all.png`** and **`distribution.png`** — same candidate set, same curve;");
|
||
sb.AppendLine("- **`DrainageAnalysis`** — not rebuilt, not edited. The quota is a re-selection over candidates it had");
|
||
sb.AppendLine(" already enumerated. The metric-comparability identity `Σ sea Acc + Σ BasinInflow + unrouted ==");
|
||
sb.AppendLine(" LandCells` was re-asserted per seed anyway, and held.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`) purely so the analysis");
|
||
sb.AppendLine("traces a real upland stem for every river on either plate. ⚠ The union is bound, not the top N: a forced");
|
||
sb.AppendLine("sea river can sit far down the unified ranking, so \"top N\" would leave it with no course to draw.");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they decide which depressions");
|
||
sb.AppendLine("BECOME terminal basins, i.e. they define the routing surface itself. Also unchanged:");
|
||
sb.AppendLine($"`MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. The terminus is classified by `RegionLabeling.OceanMask`");
|
||
sb.AppendLine("(`Dir == D_SEA`) — **there is no bare `h < sea` test anywhere in this tool.**");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Files");
|
||
sb.AppendLine();
|
||
sb.AppendLine("| File | What it is |");
|
||
sb.AppendLine("|---|---|");
|
||
sb.AppendLine($"| `<seed>/{pure}` | the pure unified top {compN} — fixed width scale, per-river labels |");
|
||
sb.AppendLine($"| `<seed>/{quota}` | the same {compN} with the K={compK} sea floor — **same width scale**, so thin means smaller |");
|
||
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||
sb.AppendLine("| `composition_<seed>.csv` | both compositions row by row: rank, drainage, forced?, stem px, in-the-other-composition? |");
|
||
if (skipRaw)
|
||
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
|
||
else
|
||
sb.AppendLine("| `<seed>/height.f32` | the eroded render field this analysis ran on |");
|
||
sb.AppendLine();
|
||
sb.AppendLine("**Not here:** `Giant.ProvisionalRoute` (the \"comb\") is **never drawn** — rivers/03 replaces it, and drawing");
|
||
sb.AppendLine("it would make a composition judgment look like a finished network. No lowland routing, no water, nothing carved.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## When the developer picks");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Graduate as ONE unit — **\"unified ranking + a gameplay-motivated sea-river floor of K\"**. Candidate decision:");
|
||
sb.AppendLine();
|
||
sb.AppendLine($"> *\"the river count for the reshaped terrain is {compN}, composed as the top inland drainages plus a gameplay");
|
||
sb.AppendLine("> floor of K sea-reaching rivers, superseding the M3 count of 3.\"* (K filled in on confirmation.)");
|
||
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/02b_composition_pure_vs_quota.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)
|
||
{
|
||
var rawPool = new LandHistogram(sea);
|
||
var pass1 = new Dictionary<int, Pass1Result>();
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||
pass1[s] = p1;
|
||
rawPool.Accumulate(p1.Height, calibSize);
|
||
}
|
||
var knots = new CurveKnots(2, "v2_balanced",
|
||
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
|
||
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
|
||
float ceilingRaw = knots.K2;
|
||
var rawAbove = new LandHistogram(sea);
|
||
var outAbove = new LandHistogram(sea);
|
||
foreach (int s in CalibrationSeeds)
|
||
{
|
||
var scfg = new TerrainGenConfig
|
||
{
|
||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||
}.WithFamilyOff();
|
||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||
}
|
||
var pcts = ClimbCalibration.DefaultPercentiles;
|
||
var rawQ = new float[pcts.Length]; var outQ = new float[pcts.Length];
|
||
for (int i = 0; i < pcts.Length; i++) { rawQ[i] = rawAbove.Quantile(pcts[i]); outQ[i] = outAbove.Quantile(pcts[i]); }
|
||
return (knots, ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
|
||
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
|
||
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f));
|
||
}
|
||
|
||
// ---- env / io -----------------------------------------------------------------------------
|
||
|
||
private static void WriteText(string path, string text)
|
||
{
|
||
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
|
||
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
|
||
f.StoreString(text);
|
||
}
|
||
|
||
private static string EnvStr(string k, string fallback)
|
||
{
|
||
string v = System.Environment.GetEnvironmentVariable(k);
|
||
return string.IsNullOrWhiteSpace(v) ? fallback : v;
|
||
}
|
||
private static int EnvInt(string k, int fallback) => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
|
||
private static int[] EnvSeeds(string k, int[] fallback)
|
||
{
|
||
string v = EnvStr(k, null);
|
||
if (v == null) return fallback;
|
||
var outp = new List<int>();
|
||
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
|
||
return outp.Count > 0 ? outp.ToArray() : fallback;
|
||
}
|
||
}
|
||
}
|