Compare commits
8 commits
89e0f85c9f
...
ccdd6a1996
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdd6a1996 | |||
| df286ffb3c | |||
| 09d23127e7 | |||
| b5ceca0419 | |||
| 4e4be6a83e | |||
| 559306ca73 | |||
| ea301f7a8c | |||
| 03fe75b378 |
37 changed files with 7658 additions and 322 deletions
478
Core/Scripts/BasinGraph.cs
Normal file
478
Core/Scripts/BasinGraph.cs
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Core
|
||||||
|
{
|
||||||
|
/// <summary>Where a terminal basin's spill drains next.</summary>
|
||||||
|
public enum DownstreamKind : byte
|
||||||
|
{
|
||||||
|
/// <summary>The spill walk found no strictly-lower neighbour before reaching anything — a genuinely closed sink (or an exact float flat).</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>The spill drains into <c>RegionLabeling.OceanMask</c> — the sea, on the CLASSIFY field.</summary>
|
||||||
|
Ocean = 1,
|
||||||
|
/// <summary>The spill drains into another terminal basin's cells (<see cref="BasinNode.DownstreamId"/>).</summary>
|
||||||
|
Basin = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ ONE NODE OF THE BASIN GRAPH (rivers/04) — a terminal basin of <see cref="DrainageAnalysis"/>,
|
||||||
|
/// enriched with the three things the analysis left latent: its SPILL, its LAKE-IDENTITY, and its
|
||||||
|
/// DOWNSTREAM EDGE. Pure data. Nothing here is a height write or a water fill.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ D-046 — WHICH SURFACE EACH FIELD IS READ ON ═══
|
||||||
|
///
|
||||||
|
/// RENDER / FLOW surface (<c>Plan.FullFilled</c>, the priority-flood of the eroded render height)
|
||||||
|
/// <see cref="SpillCell"/>, <see cref="SpillHeightRaw"/>, <see cref="FloorHeightRaw"/>,
|
||||||
|
/// <see cref="SpillClimbM"/>, <see cref="DownstreamKind"/> walk — everything about WHERE WATER GOES.
|
||||||
|
/// This is the surface <c>RiverRouting.RouteTo</c> routes on, so a spill height and a rim climb are
|
||||||
|
/// the same kind of number the router already measures.
|
||||||
|
///
|
||||||
|
/// CLASSIFY / WATER surface (<c>isClassifyWater</c> = classify < sea; <c>OceanMask</c>)
|
||||||
|
/// <see cref="LakeCells"/>, <see cref="IsLake"/>, <see cref="OceanCells"/>, and the OCEAN terminus
|
||||||
|
/// of the downstream walk — everything about WHAT IS VISIBLY WATER. This is the surface the
|
||||||
|
/// router's terminus tests already use.
|
||||||
|
///
|
||||||
|
/// ⛔ No field compares a classify height to a render height. The two surfaces meet only as
|
||||||
|
/// MEMBERSHIP (is this basin cell classify-water? is this walk cell ocean?), which is exactly the
|
||||||
|
/// split the routing already lives by (route on render, `OceanMask` on classify). No new seam.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BasinNode
|
||||||
|
{
|
||||||
|
/// <summary>The terminal-basin id, as <c>Plan.BasinId</c> carries it (sparse: pits that filled through gave up their ids).</summary>
|
||||||
|
public int Id;
|
||||||
|
|
||||||
|
/// <summary>Cells with <c>BasinId == Id</c>.</summary>
|
||||||
|
public long AreaPx;
|
||||||
|
|
||||||
|
/// <summary><c>Plan.BasinInflow[Id]</c> — cells whose flow terminates here (the promotion metric).</summary>
|
||||||
|
public long InflowPx;
|
||||||
|
|
||||||
|
/// <summary>The basin's deepest cell on the RENDER height (first in scan order on ties), and its height.</summary>
|
||||||
|
public int FloorCell;
|
||||||
|
public float FloorHeightRaw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The basin's ENTRY cell: its minimum on <c>FullFilled</c>. The priority-flood raises the first cell
|
||||||
|
/// it steps into from the spill to exactly one ulp above the spill, so this is spill + 1 ulp.
|
||||||
|
/// </summary>
|
||||||
|
public int EntryCell;
|
||||||
|
public float EntryFullFilledRaw;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE SPILL — the lowest cell on the basin's 8-neighbour boundary, read on <c>FullFilled</c>
|
||||||
|
/// (== the render height there — asserted, see <see cref="SpillOnTerrain"/>). This is the rim cell
|
||||||
|
/// water would overtop. Ties (same height) resolve to the lowest cell index; <see cref="SpillTies"/>
|
||||||
|
/// says how many boundary cells sit at exactly this height.
|
||||||
|
/// </summary>
|
||||||
|
public int SpillCell;
|
||||||
|
public float SpillHeightRaw;
|
||||||
|
public int SpillTies;
|
||||||
|
|
||||||
|
/// <summary>⭐ Cross-check (a) vs (b): <c>BitDecrement(EntryFullFilledRaw) == SpillHeightRaw</c>. The uniform-fill-level reading and the rim-walk reading must agree exactly.</summary>
|
||||||
|
public bool SpillCrossCheckOk;
|
||||||
|
/// <summary>⭐ The spill cell is real terrain: <c>FullFilled[spill] == render[spill]</c> (it was never raised by the flood).</summary>
|
||||||
|
public bool SpillOnTerrain;
|
||||||
|
|
||||||
|
/// <summary>Spill height above the sea scalar, metres (render surface; may be negative for a rim below the datum).</summary>
|
||||||
|
public float SpillAboveSeaM;
|
||||||
|
/// <summary>Floor → spill, metres, unclamped — the basin's depth to its overflow (≈ <c>DrainageAnalysis</c>'s <c>basinDepthM</c>).</summary>
|
||||||
|
public float DepthToSpillM;
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE CLIMB THE CAP IS JUDGED AGAINST: <c>ElevM(spill) − ElevM(floor)</c> with elevation clamped at
|
||||||
|
/// sea exactly as <c>RiverRouting.RouteTo</c> clamps it — so a below-datum lagoon bed climbs from sea
|
||||||
|
/// level, not from its bed. Same number the router's <c>RimClimbM</c> is. ⚠ The clamp is an ELEVATION
|
||||||
|
/// rule on the render surface, not a water test — nothing here reads "render < sea" as water.
|
||||||
|
/// </summary>
|
||||||
|
public float SpillClimbM;
|
||||||
|
|
||||||
|
/// <summary>Cells with <c>BasinId == Id</c> that are classify-water and NOT ocean. Read on CLASSIFY.</summary>
|
||||||
|
public long LakeCells;
|
||||||
|
/// <summary>Of those, cells belonging to a SIGNIFICANT body (the router's ≥ floor mask) — for cross-reference with the routing's lake mask.</summary>
|
||||||
|
public long LakeCellsSignificant;
|
||||||
|
/// <summary>⚠ Cells with <c>BasinId == Id</c> that are OCEAN on classify — a render depression under the sea. The D-046 seam, made visible rather than hidden.</summary>
|
||||||
|
public long OceanCells;
|
||||||
|
/// <summary>⭐ <c>LakeCells >= floor</c> — a significant heightmap lake sits in this basin. This is the graph's lake/dry label.</summary>
|
||||||
|
public bool IsLake;
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠⚠ EVERY cell of this basin is OCEAN on classify — a render depression on the SEABED. The priority-flood runs on
|
||||||
|
/// the whole render surface, so a deep-enough, large-enough pit under the sea qualifies as a "terminal basin" exactly
|
||||||
|
/// like a land one; hydrologically it is inert (its cells are D_NONE, its inflow is 0). Kept in the layer, EXCLUDED
|
||||||
|
/// from every lake/dry, spill and cap statistic, and counted loudly — this is the D-046 seam, not a lake.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSeabed;
|
||||||
|
/// <summary>Some but not all cells are ocean on classify — a basin straddling the shoreline seam. Treated as land (it has land cells and inflow) and counted.</summary>
|
||||||
|
public bool IsCoastal;
|
||||||
|
/// <summary>A basin with at least one land cell — the ones the graph is about.</summary>
|
||||||
|
public bool IsLand => !IsSeabed;
|
||||||
|
/// <summary><c>LakeCells > 0</c> — exactly <c>DrainageAnalysis</c>'s <c>basinHasLake</c> (any size), the routing sort. Kept so the two labels can be compared.</summary>
|
||||||
|
public bool HasAnyLake;
|
||||||
|
|
||||||
|
/// <summary>⭐⭐ THE EDGE — where the spill drains next.</summary>
|
||||||
|
public DownstreamKind Downstream;
|
||||||
|
/// <summary>The downstream basin id when <see cref="Downstream"/> is <see cref="DownstreamKind.Basin"/>; 0 otherwise.</summary>
|
||||||
|
public int DownstreamId;
|
||||||
|
/// <summary>The cell the spill walk ended on: the first ocean cell, the first cell of the next basin, or where it stuck.</summary>
|
||||||
|
public int DownstreamEntryCell;
|
||||||
|
/// <summary>The spill walk itself, spill → entry, 1-px cells — the reference's provisional-route descent on <c>FullFilled</c>.</summary>
|
||||||
|
public List<int> SpillPath = new();
|
||||||
|
/// <summary>⭐ Cross-check: following <c>Plan.Dir</c> (the analysis's own D8 field) from the first cell past the spill reaches the same node.</summary>
|
||||||
|
public bool DirWalkAgrees = true;
|
||||||
|
public DownstreamKind DirWalkKind;
|
||||||
|
public int DirWalkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE BASIN GRAPH — the water-bodies layer the flow-through routing model traverses (rivers/04).
|
||||||
|
///
|
||||||
|
/// ═══ WHAT IT IS ═══
|
||||||
|
///
|
||||||
|
/// <see cref="DrainageAnalysis"/> already found the sinks (<c>BasinId</c>) and already computed the
|
||||||
|
/// overflow surface (<c>FullFilled</c>). This layer reads those outputs and records, per terminal basin,
|
||||||
|
/// its spill, whether a significant heightmap lake sits in it, and where its spill drains to. The
|
||||||
|
/// result is a DAG: an edge always leads to a strictly lower spill, so no chain can cycle.
|
||||||
|
///
|
||||||
|
/// ═══ ⛔ THE RED LINE ═══
|
||||||
|
///
|
||||||
|
/// **Reads heights, writes none. Creates no water. <c>DrainageAnalysis</c> is consumed, not edited.**
|
||||||
|
/// The caller asserts both height digests unchanged around <see cref="Build"/>.
|
||||||
|
///
|
||||||
|
/// ═══ ⭐ WHY THE SPILL IS EXACT (the Part-0 argument, kept where the code is) ═══
|
||||||
|
///
|
||||||
|
/// The routing fill is a Barnes priority-flood with a one-ulp pit epsilon. A depression is entered
|
||||||
|
/// from the lowest rim cell S popped off the heap (height L, never raised); the first cells inside
|
||||||
|
/// are raised to <c>BitIncrement(L)</c> and every deeper cell to one ulp above ITS parent. So:
|
||||||
|
/// • a terminal basin's cells (<c>FullFilled > original</c>, 8-connected) are ONE flood chain from
|
||||||
|
/// ONE spill, and their minimum on <c>FullFilled</c> is exactly L + 1 ulp;
|
||||||
|
/// • every boundary cell (8-adjacent, not in the basin) was NOT raised, so <c>FullFilled == original</c>
|
||||||
|
/// there, and its height is ≥ L (a lower one would have been a lower way in);
|
||||||
|
/// • the boundary minimum IS S, at exactly L.
|
||||||
|
/// Both readings are computed and compared per basin (<see cref="BasinNode.SpillCrossCheckOk"/>), and
|
||||||
|
/// the "spill sits on real terrain" fact is asserted too (<see cref="BasinNode.SpillOnTerrain"/>).
|
||||||
|
///
|
||||||
|
/// ═══ ⭐ WHY THE DOWNSTREAM WALK IS ON FullFilled, NOT Plan.Dir ═══
|
||||||
|
///
|
||||||
|
/// <c>Plan.Dir</c> is D8 on <c>Filled</c> — the surface with terminal basins REVERTED to real heights.
|
||||||
|
/// The spill cell is the saddle; on <c>Filled</c> its steepest neighbour may be back INTO its own basin
|
||||||
|
/// (the reverted floor is lower than the rim), which would name the basin its own downstream. On
|
||||||
|
/// <c>FullFilled</c> the basin stands at L + ulps above its spill, so the descent from S cannot re-enter
|
||||||
|
/// it — that is exactly why the reference walked its provisional route on the full fill. <c>Dir</c> is
|
||||||
|
/// used as the CROSS-CHECK from the first cell past the spill, where re-entry is impossible.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BasinGraph
|
||||||
|
{
|
||||||
|
// Neighbour order FIXED, identical to DrainageAnalysis — the deterministic tiebreak.
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
|
||||||
|
public int MapSize;
|
||||||
|
public float SeaLevel;
|
||||||
|
/// <summary>The significance floor a basin's in-basin classify water must reach to make it a LAKE basin. A knob (<c>ISLA_LAKE_MIN_PX</c>).</summary>
|
||||||
|
public int LakeMinPx;
|
||||||
|
|
||||||
|
// ---- provenance, recorded on the layer ----
|
||||||
|
public const string SpillDatum =
|
||||||
|
"spill = minimum of Plan.FullFilled over the basin's 8-neighbour boundary (== eroded RENDER height there); " +
|
||||||
|
"cross-checked against BitDecrement(min FullFilled inside the basin); ties → lowest cell index";
|
||||||
|
public const string LakeDatum =
|
||||||
|
"lake = cells with BasinId == id AND classify < sea AND NOT OceanMask (CLASSIFY surface), total >= LakeMinPx";
|
||||||
|
public const string DownstreamMethod =
|
||||||
|
"edge = steepest descent on Plan.FullFilled from the spill cell (the reference's provisional-route walk), " +
|
||||||
|
"until OceanMask (classify) or another BasinId; cross-checked by following Plan.Dir from the first cell past the spill";
|
||||||
|
|
||||||
|
public List<BasinNode> Nodes = new();
|
||||||
|
private Dictionary<int, BasinNode> _byId = new();
|
||||||
|
public BasinNode Of(int id) => _byId.TryGetValue(id, out var b) ? b : null;
|
||||||
|
|
||||||
|
// ---- invariant tallies ----
|
||||||
|
public int SpillCrossCheckFailures, SpillNotOnTerrain, DirWalkDisagreements;
|
||||||
|
/// <summary>Tallies over LAND basins only (seabed basins excluded — see <see cref="BasinNode.IsSeabed"/>).</summary>
|
||||||
|
public int ToOcean, ToBasin, Closed, LakeBasins, DryBasins;
|
||||||
|
/// <summary>⚠ The seam counts: basins entirely under the classify sea, and basins straddling the shoreline.</summary>
|
||||||
|
public int Seabed, Coastal;
|
||||||
|
/// <summary>The land basins, in id order — what every statistic and the plate's graph are over.</summary>
|
||||||
|
public List<BasinNode> LandNodes = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ ONE SIGNIFICANT CLASSIFY-WATER BODY, and which basin (if any) owns it. The reconciliation the whole
|
||||||
|
/// layer exists for, measured per lake rather than assumed: heightmap lakes and terminal basins coincide
|
||||||
|
/// only by terrain coincidence, so this says, per significant body, how much of it sits inside a terminal
|
||||||
|
/// basin and which one — or that it floats free of the hydrology entirely.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LakeBody
|
||||||
|
{
|
||||||
|
public int Index; // 1-based, scan order
|
||||||
|
public long SizePx;
|
||||||
|
public long CellsInBasins; // cells with BasinId != 0
|
||||||
|
public int DominantBasinId; // the basin holding most of its cells (0 = none)
|
||||||
|
public long DominantCells;
|
||||||
|
public int BasinsTouched; // distinct basins it overlaps
|
||||||
|
public bool Owned => DominantBasinId != 0 && DominantCells * 2 >= SizePx; // ≥ half inside one basin
|
||||||
|
public bool Free => CellsInBasins == 0;
|
||||||
|
}
|
||||||
|
/// <summary>Every significant body (8-connected, ≥ floor), scan order.</summary>
|
||||||
|
public List<LakeBody> LakeBodies = new();
|
||||||
|
public int LakeBodiesOwned, LakeBodiesFree, LakeBodiesSplit;
|
||||||
|
/// <summary>Non-ocean classify-water cells outside every terminal basin — heightmap water the hydrology never pooled into.</summary>
|
||||||
|
public long ClassifyWaterCellsOutsideBasins, ClassifyWaterCellsTotal;
|
||||||
|
|
||||||
|
public static BasinGraph Build(DrainageAnalysis.Plan plan, float[,] render, int n,
|
||||||
|
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, int lakeMinPx)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var g = new BasinGraph { MapSize = n, SeaLevel = sea, LakeMinPx = lakeMinPx };
|
||||||
|
int[] basinId = plan.BasinId;
|
||||||
|
float[] ff = plan.FullFilled;
|
||||||
|
|
||||||
|
int maxId = 0;
|
||||||
|
for (int i = 0; i < total; i++) if (basinId[i] > maxId) maxId = basinId[i];
|
||||||
|
|
||||||
|
// ---- pass 1: per-basin scalars, scan order ----
|
||||||
|
var area = new long[maxId + 1];
|
||||||
|
var floorCell = new int[maxId + 1]; var floorH = new float[maxId + 1];
|
||||||
|
var entryCell = new int[maxId + 1]; var entryFF = new float[maxId + 1];
|
||||||
|
var lake = new long[maxId + 1]; var lakeSig = new long[maxId + 1]; var ocean = new long[maxId + 1];
|
||||||
|
for (int id = 0; id <= maxId; id++) { floorCell[id] = -1; floorH[id] = float.MaxValue; entryCell[id] = -1; entryFF[id] = float.MaxValue; }
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = basinId[i];
|
||||||
|
if (id == 0) continue;
|
||||||
|
area[id]++;
|
||||||
|
float h = render[i / n, i % n];
|
||||||
|
if (h < floorH[id]) { floorH[id] = h; floorCell[id] = i; }
|
||||||
|
if (ff[i] < entryFF[id]) { entryFF[id] = ff[i]; entryCell[id] = i; }
|
||||||
|
if (isOcean[i]) ocean[id]++;
|
||||||
|
else if (isClassifyWater[i])
|
||||||
|
{
|
||||||
|
lake[id]++;
|
||||||
|
if (isSignificantWater != null && isSignificantWater[i]) lakeSig[id]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pass 2: boundary minimum on FullFilled (the rim walk) ----
|
||||||
|
var bMin = new float[maxId + 1]; var bCell = new int[maxId + 1];
|
||||||
|
for (int id = 0; id <= maxId; id++) { bMin[id] = float.MaxValue; bCell[id] = -1; }
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = basinId[i];
|
||||||
|
if (id == 0) continue;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (basinId[ni] == id) continue;
|
||||||
|
float v = ff[ni];
|
||||||
|
if (v < bMin[id] || (v == bMin[id] && ni < bCell[id])) { bMin[id] = v; bCell[id] = ni; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- pass 3: how many DISTINCT boundary cells tie at the spill height ----
|
||||||
|
var ties = new HashSet<int>[maxId + 1];
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = basinId[i];
|
||||||
|
if (id == 0) continue;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (basinId[ni] == id || ff[ni] != bMin[id]) continue;
|
||||||
|
(ties[id] ??= new HashSet<int>()).Add(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float ElevM(float h) => MathF.Max(0f, WorldScale.MetresFromRaw(h - sea));
|
||||||
|
|
||||||
|
// ---- per basin: the node ----
|
||||||
|
for (int id = 1; id <= maxId; id++)
|
||||||
|
{
|
||||||
|
if (area[id] == 0) continue;
|
||||||
|
var b = new BasinNode
|
||||||
|
{
|
||||||
|
Id = id, AreaPx = area[id],
|
||||||
|
InflowPx = id < plan.BasinInflow.Length ? plan.BasinInflow[id] : 0,
|
||||||
|
FloorCell = floorCell[id], FloorHeightRaw = floorH[id],
|
||||||
|
EntryCell = entryCell[id], EntryFullFilledRaw = entryFF[id],
|
||||||
|
SpillCell = bCell[id], SpillHeightRaw = bMin[id],
|
||||||
|
SpillTies = ties[id]?.Count ?? 0,
|
||||||
|
LakeCells = lake[id], LakeCellsSignificant = lakeSig[id], OceanCells = ocean[id],
|
||||||
|
};
|
||||||
|
b.SpillCrossCheckOk = bCell[id] >= 0 && MathF.BitDecrement(entryFF[id]) == bMin[id];
|
||||||
|
b.SpillOnTerrain = bCell[id] >= 0 && render[bCell[id] / n, bCell[id] % n] == bMin[id];
|
||||||
|
b.SpillAboveSeaM = WorldScale.MetresFromRaw(b.SpillHeightRaw - sea);
|
||||||
|
b.DepthToSpillM = WorldScale.MetresFromRaw(b.SpillHeightRaw - b.FloorHeightRaw);
|
||||||
|
b.SpillClimbM = ElevM(b.SpillHeightRaw) - ElevM(b.FloorHeightRaw);
|
||||||
|
b.IsLake = b.LakeCells >= lakeMinPx;
|
||||||
|
b.HasAnyLake = b.LakeCells > 0;
|
||||||
|
b.IsSeabed = b.OceanCells == b.AreaPx;
|
||||||
|
b.IsCoastal = b.OceanCells > 0 && !b.IsSeabed;
|
||||||
|
if (!b.SpillCrossCheckOk) g.SpillCrossCheckFailures++;
|
||||||
|
if (!b.SpillOnTerrain) g.SpillNotOnTerrain++;
|
||||||
|
if (b.IsSeabed) g.Seabed++;
|
||||||
|
if (b.IsCoastal) g.Coastal++;
|
||||||
|
|
||||||
|
// ⭐⭐ THE DOWNSTREAM WALK — the reference's provisional-route descent, started at the spill.
|
||||||
|
if (bCell[id] >= 0)
|
||||||
|
{
|
||||||
|
int c = bCell[id];
|
||||||
|
b.Downstream = DownstreamKind.None;
|
||||||
|
for (int guard = 0; guard < 4 * n; guard++)
|
||||||
|
{
|
||||||
|
b.SpillPath.Add(c);
|
||||||
|
if (isOcean[c]) { b.Downstream = DownstreamKind.Ocean; break; }
|
||||||
|
int bid = basinId[c];
|
||||||
|
if (bid != 0 && bid != id) { b.Downstream = DownstreamKind.Basin; b.DownstreamId = bid; break; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float best = float.MaxValue; int bestN = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (ff[ni] < best) { best = ff[ni]; bestN = ni; }
|
||||||
|
}
|
||||||
|
if (bestN < 0 || ff[bestN] >= ff[c]) break; // stuck — a closed sink (or an exact flat)
|
||||||
|
c = bestN;
|
||||||
|
}
|
||||||
|
b.DownstreamEntryCell = b.SpillPath[^1];
|
||||||
|
|
||||||
|
// ⭐ Cross-check on the analysis's own D8 field, from the first cell PAST the spill.
|
||||||
|
if (b.SpillPath.Count >= 2)
|
||||||
|
{
|
||||||
|
int c2 = b.SpillPath[1];
|
||||||
|
var (dk, did) = WalkDir(plan, n, isOcean, c2);
|
||||||
|
b.DirWalkKind = dk; b.DirWalkId = did;
|
||||||
|
b.DirWalkAgrees = dk == b.Downstream && did == b.DownstreamId;
|
||||||
|
}
|
||||||
|
else { b.DirWalkKind = b.Downstream; b.DirWalkId = b.DownstreamId; b.DirWalkAgrees = true; }
|
||||||
|
if (!b.DirWalkAgrees) g.DirWalkDisagreements++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b.IsLand)
|
||||||
|
{
|
||||||
|
switch (b.Downstream)
|
||||||
|
{
|
||||||
|
case DownstreamKind.Ocean: g.ToOcean++; break;
|
||||||
|
case DownstreamKind.Basin: g.ToBasin++; break;
|
||||||
|
default: g.Closed++; break;
|
||||||
|
}
|
||||||
|
if (b.IsLake) g.LakeBasins++; else g.DryBasins++;
|
||||||
|
g.LandNodes.Add(b);
|
||||||
|
}
|
||||||
|
g.Nodes.Add(b);
|
||||||
|
g._byId[id] = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the reconciliation, per significant body: which basin owns it? ----
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
if (isClassifyWater[i] && !isOcean[i]) { g.ClassifyWaterCellsTotal++; if (basinId[i] == 0) g.ClassifyWaterCellsOutsideBasins++; }
|
||||||
|
if (isSignificantWater != null)
|
||||||
|
{
|
||||||
|
var seen = new bool[total];
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
var perBasin = new Dictionary<int, long>();
|
||||||
|
for (int s = 0; s < total; s++)
|
||||||
|
{
|
||||||
|
if (seen[s] || !isSignificantWater[s]) continue;
|
||||||
|
var body = new LakeBody { Index = g.LakeBodies.Count + 1 };
|
||||||
|
perBasin.Clear();
|
||||||
|
seen[s] = true; stack.Push(s);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int c = stack.Pop();
|
||||||
|
body.SizePx++;
|
||||||
|
int bid = basinId[c];
|
||||||
|
if (bid != 0) { body.CellsInBasins++; perBasin.TryGetValue(bid, out long cur); perBasin[bid] = cur + 1; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (seen[ni] || !isSignificantWater[ni]) continue;
|
||||||
|
seen[ni] = true; stack.Push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.BasinsTouched = perBasin.Count;
|
||||||
|
foreach (var kv in perBasin)
|
||||||
|
if (kv.Value > body.DominantCells || (kv.Value == body.DominantCells && kv.Key < body.DominantBasinId))
|
||||||
|
{ body.DominantCells = kv.Value; body.DominantBasinId = kv.Key; }
|
||||||
|
if (body.Free) g.LakeBodiesFree++; else if (body.Owned) g.LakeBodiesOwned++; else g.LakeBodiesSplit++;
|
||||||
|
g.LakeBodies.Add(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Follow <c>Plan.Dir</c> from a cell to where its flow ends: the sea, a terminal basin, or nowhere.</summary>
|
||||||
|
private static (DownstreamKind kind, int id) WalkDir(DrainageAnalysis.Plan plan, int n, bool[] isOcean, int start)
|
||||||
|
{
|
||||||
|
int c = start;
|
||||||
|
for (int guard = 0; guard < 8 * n; guard++)
|
||||||
|
{
|
||||||
|
if (isOcean[c]) return (DownstreamKind.Ocean, 0);
|
||||||
|
sbyte d = plan.Dir[c];
|
||||||
|
if (d == DrainageAnalysis.D_SEA) return (DownstreamKind.Ocean, 0);
|
||||||
|
if (d == DrainageAnalysis.D_NONE) return plan.BasinId[c] != 0 ? (DownstreamKind.Basin, plan.BasinId[c]) : (DownstreamKind.None, 0);
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
c = (cx + DX[d]) * n + (cy + DY[d]);
|
||||||
|
}
|
||||||
|
return (DownstreamKind.None, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE CAP PREVIEW — which basins have an UNBROKEN spill-chain to the ocean when every link's
|
||||||
|
/// <see cref="BasinNode.SpillClimbM"/> must be ≤ <paramref name="capM"/>. A preview of what the
|
||||||
|
/// flow-through model will trade at a given cap; it decides nothing.
|
||||||
|
/// </summary>
|
||||||
|
public bool[] ConnectedAtCap(float capM, out int connected)
|
||||||
|
{
|
||||||
|
var state = new Dictionary<int, byte>(); // 1 = yes, 2 = no, 3 = visiting
|
||||||
|
bool Reach(int id)
|
||||||
|
{
|
||||||
|
if (state.TryGetValue(id, out byte s)) return s == 1;
|
||||||
|
var b = Of(id);
|
||||||
|
if (b == null) { state[id] = 2; return false; }
|
||||||
|
state[id] = 3;
|
||||||
|
bool ok = false;
|
||||||
|
if (b.SpillClimbM <= capM)
|
||||||
|
{
|
||||||
|
if (b.Downstream == DownstreamKind.Ocean) ok = true;
|
||||||
|
else if (b.Downstream == DownstreamKind.Basin)
|
||||||
|
{
|
||||||
|
// The graph is a DAG (an edge always lands on a strictly lower spill); the
|
||||||
|
// visiting guard is belt-and-braces, never expected to fire.
|
||||||
|
bool visiting = state.TryGetValue(b.DownstreamId, out byte ds) && ds == 3;
|
||||||
|
ok = !visiting && Reach(b.DownstreamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state[id] = ok ? (byte)1 : (byte)2;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
var outp = new bool[Nodes.Count];
|
||||||
|
connected = 0;
|
||||||
|
for (int i = 0; i < Nodes.Count; i++)
|
||||||
|
{
|
||||||
|
outp[i] = Reach(Nodes[i].Id);
|
||||||
|
if (outp[i]) connected++;
|
||||||
|
}
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Chain length (edges) from a basin to the ocean, or -1 if the chain ends in a closed sink.</summary>
|
||||||
|
public int HopsToOcean(int id)
|
||||||
|
{
|
||||||
|
int hops = 0; var seen = new HashSet<int>();
|
||||||
|
var b = Of(id);
|
||||||
|
while (b != null && seen.Add(b.Id))
|
||||||
|
{
|
||||||
|
if (b.Downstream == DownstreamKind.Ocean) return hops + 1;
|
||||||
|
if (b.Downstream != DownstreamKind.Basin) return -1;
|
||||||
|
b = Of(b.DownstreamId); hops++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -183,6 +183,72 @@ namespace IslaApocalypse.Core
|
||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ SIGNIFICANT WATER (rivers/03) — the interim substitute for the reference's water-bodies
|
||||||
|
/// table, built with this layer's own connected-component machinery.
|
||||||
|
///
|
||||||
|
/// ═══ WHY THIS EXISTS ═══
|
||||||
|
///
|
||||||
|
/// The reference builds `isSignificantWater` from `_waterBodies` — cells of any body with
|
||||||
|
/// `PixelCount >= RiverLakeMinTargetPx` — and a lake-ender routes to THAT rather than to any wet
|
||||||
|
/// pixel. **v2 has no water-bodies table yet** (a known port gap, `00_ground` §D3 /
|
||||||
|
/// carry-forward §5), so this labels 8-connected components of classify water directly and keeps
|
||||||
|
/// the ones at least <paramref name="minPx"/> cells. Same semantics, same threshold, no table.
|
||||||
|
///
|
||||||
|
/// ⚠ The size filter is the whole point and it is not a detail: routing a lake-ender to the
|
||||||
|
/// NEAREST wet pixel put one into a three-cell puddle a few hundred px short of the obvious
|
||||||
|
/// lagoon — the reference's own task-23 gate finding. "Nearest water" is satisfied by a puddle.
|
||||||
|
///
|
||||||
|
/// ⚠ OCEAN IS EXCLUDED. A lake-ender that could reach the ocean is not a lake-ender; including
|
||||||
|
/// ocean here would let one "terminate" at the coast and quietly become a sea river without ever
|
||||||
|
/// passing the routed test.
|
||||||
|
///
|
||||||
|
/// Pure: reads the mask, writes nothing, creates no water. Same 8-connectivity and same fixed
|
||||||
|
/// neighbour order as <see cref="Label"/>, so component identity is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
public static bool[] SignificantWaterMask(bool[] isClassifyWater, bool[] isOcean, int mapSize,
|
||||||
|
int minPx, out int bodiesKept, out int bodiesTotal, out long cellsKept, out long largestPx)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
var seen = new bool[n * n];
|
||||||
|
var mask = new bool[n * n];
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
var component = new List<int>();
|
||||||
|
bodiesKept = 0; bodiesTotal = 0; cellsKept = 0; largestPx = 0;
|
||||||
|
|
||||||
|
for (int s = 0; s < n * n; s++)
|
||||||
|
{
|
||||||
|
if (seen[s] || !isClassifyWater[s] || isOcean[s]) continue;
|
||||||
|
component.Clear();
|
||||||
|
seen[s] = true;
|
||||||
|
stack.Push(s);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int cur = stack.Pop();
|
||||||
|
component.Add(cur);
|
||||||
|
int cx = cur / n, cy = cur % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (seen[ni] || !isClassifyWater[ni] || isOcean[ni]) continue;
|
||||||
|
seen[ni] = true;
|
||||||
|
stack.Push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bodiesTotal++;
|
||||||
|
if (component.Count > largestPx) largestPx = component.Count;
|
||||||
|
if (component.Count >= minPx)
|
||||||
|
{
|
||||||
|
bodiesKept++;
|
||||||
|
cellsKept += component.Count;
|
||||||
|
foreach (int c in component) mask[c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
|
/// Size statistics over the islands (non-mainland components): count, min / median / mean /
|
||||||
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
|
/// max cells, and a log-spaced histogram — the instrument that turns "nice pieces vs shattered
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ namespace IslaApocalypse.Core
|
||||||
/// ISLA_CONFIG_PATH the generation config file default: user://config.json
|
/// ISLA_CONFIG_PATH the generation config file default: user://config.json
|
||||||
/// ISLA_BLUEPRINT_PATH the blueprint read/written default: user://blueprints
|
/// ISLA_BLUEPRINT_PATH the blueprint read/written default: user://blueprints
|
||||||
/// ISLA_OUTPUT_DIR generation output (maps, batches) default: user://output
|
/// ISLA_OUTPUT_DIR generation output (maps, batches) default: user://output
|
||||||
|
/// ISLA_CHAT the batch chat namespace default: the tool's authoring chat
|
||||||
///
|
///
|
||||||
/// ⚠ user:// RESOLUTION. Defaults sit under the project's own user data directory, which this
|
/// ⚠ user:// RESOLUTION. Defaults sit under the project's own user data directory, which this
|
||||||
/// project pins away from the old prototype's — see project.godot's user:// isolation block.
|
/// project pins away from the old prototype's — see project.godot's user:// isolation block.
|
||||||
|
|
@ -60,6 +61,61 @@ namespace IslaApocalypse.Core
|
||||||
/// <summary>Whether <see cref="Configure"/> has been called. Tooling should assert this before a run.</summary>
|
/// <summary>Whether <see cref="Configure"/> has been called. Tooling should assert this before a run.</summary>
|
||||||
public static bool IsConfigured => _userDataDir != null;
|
public static bool IsConfigured => _userDataDir != null;
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ THE CHAT NAMESPACE (rivers/01) ═══════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// ═══ WHY BATCHES ARE NAMESPACED BY CHAT ═══
|
||||||
|
//
|
||||||
|
// The batch prefix is the AUTHORING TASK NUMBER (see <see cref="BatchRoot"/>), and task
|
||||||
|
// numbers restart at 00 in every new build chat. So a flat batches/ directory COLLIDES the
|
||||||
|
// moment a second chat exists: chat 1's `02_pass1_port` and chat 2's `02_curve_continuous`
|
||||||
|
// are both "batch 02", and nothing in either name says which chat made it. Measured on the
|
||||||
|
// real pile at rivers/01: 25 batches, FOUR colliding prefixes (02, 03, 04, 06), 13 folders
|
||||||
|
// belonging to chat 1 and 12 to chat 2 — separable only by SLUG, never by number.
|
||||||
|
//
|
||||||
|
// > ### ⚠ The slug is WHO IS RUNNING, not who authored.
|
||||||
|
// > A tool carries its authoring chat as its default so that re-running it reproduces its own
|
||||||
|
// > batch in place. A different chat re-running it for its own purposes sets ISLA_CHAT and
|
||||||
|
// > writes under its own namespace — which is also what stops an acceptance run from
|
||||||
|
// > OVERWRITING THE VERY ANCHOR IT IS CHECKING AGAINST.
|
||||||
|
//
|
||||||
|
// ⚠ REQUIRED, exactly like <see cref="Configure"/>: with no slug set, <see cref="BatchRoot"/>
|
||||||
|
// throws rather than quietly writing to the un-namespaced root and re-creating the collision
|
||||||
|
// this exists to end.
|
||||||
|
|
||||||
|
public const string ChatVar = "ISLA_CHAT";
|
||||||
|
|
||||||
|
private static string _chatSlug;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the chat namespace batches are written under. Called once at startup by every batch
|
||||||
|
/// tool, with its authoring chat as the fallback: <c>ConfigureChat(EnvStr(ChatVar, "chat2"))</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slug">
|
||||||
|
/// A short domain slug — `chat1`, `chat2`, `rivers`. ⚠ It becomes a single path SEGMENT, so
|
||||||
|
/// separators are refused rather than silently creating a nested tree nobody asked for.
|
||||||
|
/// </param>
|
||||||
|
public static void ConfigureChat(string slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
throw new ArgumentException("A chat slug is required — batches are namespaced by chat.", nameof(slug));
|
||||||
|
string t = slug.Trim();
|
||||||
|
if (t.IndexOf('/') >= 0 || t.IndexOf('\\') >= 0 || t.IndexOf(Path.DirectorySeparatorChar) >= 0
|
||||||
|
|| t == "." || t == "..")
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Chat slug '{t}' is not a single path segment. The slug is ONE folder under batches/ — " +
|
||||||
|
"pass \"rivers\", not \"a/b\" or \"..\".", nameof(slug));
|
||||||
|
_chatSlug = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The chat namespace. ⚠ Throws if <see cref="ConfigureChat"/> has not been called.</summary>
|
||||||
|
public static string ChatSlug => _chatSlug ?? throw new InvalidOperationException(
|
||||||
|
"CHAT SLUG NOT SET. Batches are namespaced by chat (batches/<chat>/NN_slug/); a tool must call " +
|
||||||
|
"ToolingPaths.ConfigureChat(...) before composing a batch path. Writing to the un-namespaced root " +
|
||||||
|
$"is what collided task numbers across chats in the first place. (Override with {ChatVar}.) — rivers/01.");
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="ConfigureChat"/> has been called.</summary>
|
||||||
|
public static bool IsChatConfigured => _chatSlug != null;
|
||||||
|
|
||||||
/// <summary>The generation config file. Override: ISLA_CONFIG_PATH.</summary>
|
/// <summary>The generation config file. Override: ISLA_CONFIG_PATH.</summary>
|
||||||
public static string ConfigPath =>
|
public static string ConfigPath =>
|
||||||
Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json");
|
Override(ConfigPathVar) ?? Path.Combine(UserDataDir, "config.json");
|
||||||
|
|
@ -78,6 +134,13 @@ namespace IslaApocalypse.Core
|
||||||
/// INDEX.md and a persistent scratch/. A/B comparisons are browsed by a human, and a flat
|
/// INDEX.md and a persistent scratch/. A/B comparisons are browsed by a human, and a flat
|
||||||
/// directory of same-named PNGs is not browsable.
|
/// directory of same-named PNGs is not browsable.
|
||||||
///
|
///
|
||||||
|
/// ⚠⚠ THIS IS THE ROOT, NOT A BATCH, AND THE DISTINCTION IS LOAD-BEARING. Batch WRITES go
|
||||||
|
/// through <see cref="BatchRoot"/>, which inserts the <see cref="ChatSlug"/> segment. Anchor
|
||||||
|
/// READS compose against THIS, so an anchor's source string must carry its own explicit
|
||||||
|
/// `chatN/` prefix (e.g. `"chat1/02_pass1_port"`). Changing only `BatchRoot` would namespace
|
||||||
|
/// every write and silently orphan every historical read — the exact trap rivers/01 had to
|
||||||
|
/// walk through, and why <c>ShapingOracle.LoadAnchor</c> now throws on a missing anchor.
|
||||||
|
///
|
||||||
/// ⚠ PROTECTED FROM DELETION. → <see cref="FileSafety"/>.
|
/// ⚠ PROTECTED FROM DELETION. → <see cref="FileSafety"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BatchesRoot => Path.Combine(OutputDir, "batches");
|
public static string BatchesRoot => Path.Combine(OutputDir, "batches");
|
||||||
|
|
@ -90,7 +153,7 @@ namespace IslaApocalypse.Core
|
||||||
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
|
public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ⭐ A BATCH ROOT: <c>batches/<task>_<descriptor>/</c>.
|
/// ⭐ A BATCH ROOT: <c>batches/<chat>/<task>_<descriptor>/</c>.
|
||||||
///
|
///
|
||||||
/// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══
|
/// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══
|
||||||
///
|
///
|
||||||
|
|
@ -107,9 +170,40 @@ namespace IslaApocalypse.Core
|
||||||
///
|
///
|
||||||
/// The descriptor must NOT carry its own numeric prefix; that is the mistake this method
|
/// The descriptor must NOT carry its own numeric prefix; that is the mistake this method
|
||||||
/// exists to prevent, so it is refused rather than silently accepted.
|
/// exists to prevent, so it is refused rather than silently accepted.
|
||||||
|
///
|
||||||
|
/// ═══ ⭐ THE <chat> SEGMENT (rivers/01) ═══
|
||||||
|
///
|
||||||
|
/// Prepended from <see cref="ChatSlug"/>, because the task-number prefix restarts at 00 in
|
||||||
|
/// every chat — see the note on <see cref="ConfigureChat"/>. It is a SEPARATE segment and is
|
||||||
|
/// never folded into the descriptor: the prefix guard below fires on a descriptor starting
|
||||||
|
/// with digits, so passing `"chat2/12_drainage"` as a descriptor would be a different kind
|
||||||
|
/// of wrong.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BatchRoot(int taskNumber, string descriptor)
|
public static string BatchRoot(int taskNumber, string descriptor) => BatchRoot(taskNumber, "", descriptor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ The same, for a LETTERED SUB-TASK: <c>batches/<chat>/<task><suffix>_<descriptor>/</c>,
|
||||||
|
/// e.g. <c>02b_composition</c> (rivers/02b).
|
||||||
|
///
|
||||||
|
/// ═══ WHY A SUFFIX RATHER THAN A NEW TASK NUMBER ═══
|
||||||
|
///
|
||||||
|
/// The prefix is the AUTHORING TASK's identity, and a task numbered "02b" — a follow-up that
|
||||||
|
/// re-renders 02's material under one changed choice — has exactly that identity. Giving it a
|
||||||
|
/// fresh number (03) would claim it is the next task in the sequence and collide with the one
|
||||||
|
/// that actually is; folding the letter into the descriptor (<c>"02b_composition"</c>) would
|
||||||
|
/// smuggle a prefix past the guard below, which is the drift that guard exists to stop.
|
||||||
|
///
|
||||||
|
/// ⚠ Letters only, and lowercase — a suffix that could be read as part of a number would
|
||||||
|
/// reintroduce the ambiguity. Refused rather than sanitised.
|
||||||
|
/// </summary>
|
||||||
|
public static string BatchRoot(int taskNumber, string suffix, string descriptor)
|
||||||
{
|
{
|
||||||
|
string sfx = (suffix ?? "").Trim();
|
||||||
|
foreach (char c in sfx)
|
||||||
|
if (c < 'a' || c > 'z')
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Task suffix '{sfx}' must be lowercase letters only (e.g. \"b\" for task 02b). A suffix that " +
|
||||||
|
"could be read as part of the task number is exactly the ambiguity the prefix rule removes.", nameof(suffix));
|
||||||
if (taskNumber < 0)
|
if (taskNumber < 0)
|
||||||
throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
|
throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
|
||||||
"A batch is named for the task that authored it; there is no negative task.");
|
"A batch is named for the task that authored it; there is no negative task.");
|
||||||
|
|
@ -127,7 +221,7 @@ namespace IslaApocalypse.Core
|
||||||
$"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
|
$"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
|
||||||
"the prefix is composed here so it cannot drift.", nameof(descriptor));
|
"the prefix is composed here so it cannot drift.", nameof(descriptor));
|
||||||
|
|
||||||
return Path.Combine(BatchesRoot, $"{taskNumber:D2}_{d}");
|
return Path.Combine(BatchesRoot, ChatSlug, $"{taskNumber:D2}{sfx}_{d}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -137,6 +231,13 @@ namespace IslaApocalypse.Core
|
||||||
public static string BatchDir(int taskNumber, string descriptor, long seed, string variant)
|
public static string BatchDir(int taskNumber, string descriptor, long seed, string variant)
|
||||||
=> Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}");
|
=> Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a HISTORICAL batch by its namespaced name, e.g. <c>"chat1/02_pass1_port"</c> — the
|
||||||
|
/// form every `ISLA_*_SOURCE` anchor default takes since rivers/01. Kept beside
|
||||||
|
/// <see cref="BatchRoot"/> so a READ and a WRITE are visibly two different operations.
|
||||||
|
/// </summary>
|
||||||
|
public static string BatchSource(string namespacedName) => Path.Combine(BatchesRoot, namespacedName);
|
||||||
|
|
||||||
private static string Override(string variable)
|
private static string Override(string variable)
|
||||||
{
|
{
|
||||||
string v = Environment.GetEnvironmentVariable(variable);
|
string v = Environment.GetEnvironmentVariable(variable);
|
||||||
|
|
@ -149,7 +250,8 @@ namespace IslaApocalypse.Core
|
||||||
$"config : {ConfigPath}{Marker(ConfigPathVar)}\n" +
|
$"config : {ConfigPath}{Marker(ConfigPathVar)}\n" +
|
||||||
$"blueprints: {BlueprintPath}{Marker(BlueprintPathVar)}\n" +
|
$"blueprints: {BlueprintPath}{Marker(BlueprintPathVar)}\n" +
|
||||||
$"output : {OutputDir}{Marker(OutputDirVar)}\n" +
|
$"output : {OutputDir}{Marker(OutputDirVar)}\n" +
|
||||||
$"batches : {BatchesRoot}";
|
$"batches : {BatchesRoot}\n" +
|
||||||
|
$"chat : {(IsChatConfigured ? ChatSlug : "⚠ NOT SET")} → writes land under batches/{(IsChatConfigured ? ChatSlug : "<chat>")}/NN_slug/";
|
||||||
|
|
||||||
private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : "";
|
private static string Marker(string variable) => Override(variable) != null ? $" [{variable}]" : "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,15 @@ Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises.
|
||||||
### 4. Batch layout
|
### 4. Batch layout
|
||||||
|
|
||||||
```
|
```
|
||||||
batches/<task>_<descriptor>/<seed>_<variant>/
|
batches/<chat>/<task>_<descriptor>/<seed>_<variant>/
|
||||||
batches/<task>_<descriptor>/INDEX.md
|
batches/<chat>/<task>_<descriptor>/INDEX.md
|
||||||
batches/<task>_<descriptor>/scratch/ ← persistent; never cleaned
|
batches/<chat>/<task>_<descriptor>/scratch/ ← persistent; never cleaned
|
||||||
|
|
||||||
|
⚠ <chat> is required (rivers/01): task numbers restart per chat, so a flat batches/
|
||||||
|
collided across chat1 and chat2. Set by ToolingPaths.ConfigureChat(), overridable
|
||||||
|
with ISLA_CHAT. Historical anchor READS carry the prefix in their own source string
|
||||||
|
(e.g. "chat1/02_pass1_port"), because they compose against BatchesRoot, not BatchRoot.
|
||||||
|
→ Tools/batches/README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
> ### ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER.
|
> ### ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER.
|
||||||
|
|
|
||||||
6
Tools/Scenes/BasinGraphTool.tscn
Normal file
6
Tools/Scenes/BasinGraphTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://basingraph04"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/BasinGraphTool.cs" id="1_bgt04"]
|
||||||
|
|
||||||
|
[node name="BasinGraphTool" type="Node"]
|
||||||
|
script = ExtResource("1_bgt04")
|
||||||
6
Tools/Scenes/FlowThroughTool.tscn
Normal file
6
Tools/Scenes/FlowThroughTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://flowthrough05"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/FlowThroughTool.cs" id="1_ftt05"]
|
||||||
|
|
||||||
|
[node name="FlowThroughTool" type="Node"]
|
||||||
|
script = ExtResource("1_ftt05")
|
||||||
6
Tools/Scenes/RiverPromotionTool.tscn
Normal file
6
Tools/Scenes/RiverPromotionTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://riverpromotion02"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RiverPromotionTool.cs" id="1_rpt02"]
|
||||||
|
|
||||||
|
[node name="RiverPromotionTool" type="Node"]
|
||||||
|
script = ExtResource("1_rpt02")
|
||||||
6
Tools/Scenes/RiverRoutingTool.tscn
Normal file
6
Tools/Scenes/RiverRoutingTool.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene format=3 uid="uid://riverrouting03"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://Tools/Scripts/RiverRoutingTool.cs" id="1_rrt03"]
|
||||||
|
|
||||||
|
[node name="RiverRoutingTool" type="Node"]
|
||||||
|
script = ExtResource("1_rrt03")
|
||||||
151
Tools/Scripts/BasinGraphRenderer.cs
Normal file
151
Tools/Scripts/BasinGraphRenderer.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE BASIN-GRAPH PLATE (rivers/04) — the taste gate on the foundation. Presentation only; reads
|
||||||
|
/// the graph and the height, writes pixels. Nothing here touches data.
|
||||||
|
///
|
||||||
|
/// What the eye is meant to check, per the task:
|
||||||
|
/// • is each SPILL (yellow ring, labelled) at the true low rim where water would actually overtop?
|
||||||
|
/// • which basins hold REAL LAKES (blue tint, the lake cells brighter) vs DRY sinks (amber tint)?
|
||||||
|
/// • does "who drains to whom" (the arrows: cyan → ocean, white → another basin, red = closed)
|
||||||
|
/// look like a physically sane network?
|
||||||
|
///
|
||||||
|
/// Label at each spill: <c>"23M/7"</c> = spill 23 m above the sea datum, 7 m climb from the basin
|
||||||
|
/// floor (the number the cap is judged against). Both on the RENDER surface.
|
||||||
|
/// </summary>
|
||||||
|
public static class BasinGraphRenderer
|
||||||
|
{
|
||||||
|
private static readonly Color LakeTint = new(0.250f, 0.520f, 1.000f);
|
||||||
|
private static readonly Color LakeWater = new(0.180f, 0.420f, 0.980f);
|
||||||
|
private static readonly Color DryTint = new(0.980f, 0.660f, 0.250f);
|
||||||
|
private static readonly Color OutlineL = new(0.100f, 0.250f, 0.650f);
|
||||||
|
private static readonly Color OutlineD = new(0.600f, 0.330f, 0.060f);
|
||||||
|
private static readonly Color Spill = new(1.000f, 0.930f, 0.350f);
|
||||||
|
private static readonly Color EdgeOcean = new(0.250f, 0.900f, 1.000f);
|
||||||
|
private static readonly Color EdgeBasin = new(1.000f, 1.000f, 1.000f);
|
||||||
|
private static readonly Color EdgeNone = new(1.000f, 0.250f, 0.250f);
|
||||||
|
private static readonly Color Floor = new(0.050f, 0.050f, 0.050f);
|
||||||
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
||||||
|
private static readonly Color Shadow = new(0.000f, 0.000f, 0.000f);
|
||||||
|
private static readonly Color SeabedOutline = new(0.180f, 0.300f, 0.520f);
|
||||||
|
|
||||||
|
public static Image Plate(BasinGraph g, DrainageAnalysis.Plan plan, Image img, int n,
|
||||||
|
bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
|
||||||
|
{
|
||||||
|
int[] basinId = plan.BasinId;
|
||||||
|
int total = n * n;
|
||||||
|
|
||||||
|
// 1. tint every basin cell by lake/dry; lake cells inside a basin drawn as water.
|
||||||
|
var lakeOf = new Dictionary<int, bool>();
|
||||||
|
var seabed = new HashSet<int>();
|
||||||
|
foreach (var b in g.Nodes) { lakeOf[b.Id] = b.IsLake; if (b.IsSeabed) seabed.Add(b.Id); }
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = basinId[i];
|
||||||
|
if (id == 0 || seabed.Contains(id)) continue;
|
||||||
|
int x = i / n, y = i % n;
|
||||||
|
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
|
||||||
|
if (isClassifyWater[i] && !isOcean[i])
|
||||||
|
{
|
||||||
|
img.SetPixel(x, y, isLakeBasin ? LakeWater : img.GetPixel(x, y).Lerp(LakeWater, 0.55f));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(isLakeBasin ? LakeTint : DryTint, 0.38f));
|
||||||
|
}
|
||||||
|
// 2. outline: a basin cell with a 4-neighbour of a different id.
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = basinId[i];
|
||||||
|
if (id == 0) continue;
|
||||||
|
int x = i / n, y = i % n;
|
||||||
|
bool edge = (x > 0 && basinId[i - n] != id) || (x < n - 1 && basinId[i + n] != id)
|
||||||
|
|| (y > 0 && basinId[i - 1] != id) || (y < n - 1 && basinId[i + 1] != id);
|
||||||
|
if (!edge) continue;
|
||||||
|
if (seabed.Contains(id)) { img.SetPixel(x, y, SeabedOutline); continue; } // the seam: outline only
|
||||||
|
bool isLakeBasin = lakeOf.TryGetValue(id, out bool l) && l;
|
||||||
|
img.SetPixel(x, y, isLakeBasin ? OutlineL : OutlineD);
|
||||||
|
}
|
||||||
|
|
||||||
|
int thin = n >= 4096 ? 3 : 2, ring = n >= 4096 ? 16 : 9, ringW = n >= 4096 ? 4 : 3;
|
||||||
|
int floorR = n >= 4096 ? 6 : 3, head = n >= 4096 ? 22 : 12;
|
||||||
|
int scale = n >= 4096 ? 3 : 2;
|
||||||
|
|
||||||
|
// 3. the edges — the spill walk, arrowhead at the downstream end.
|
||||||
|
foreach (var b in g.LandNodes)
|
||||||
|
{
|
||||||
|
Color c = b.Downstream switch
|
||||||
|
{
|
||||||
|
DownstreamKind.Ocean => EdgeOcean,
|
||||||
|
DownstreamKind.Basin => EdgeBasin,
|
||||||
|
_ => EdgeNone,
|
||||||
|
};
|
||||||
|
var path = b.SpillPath;
|
||||||
|
if (path.Count >= 2)
|
||||||
|
{
|
||||||
|
for (int i = 1; i < path.Count; i++)
|
||||||
|
DrainageRenderer.Line(img, path[i - 1] / n, path[i - 1] % n, path[i] / n, path[i] % n, n, c, thin);
|
||||||
|
Arrowhead(img, path, n, c, head, thin);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A spill that is itself the terminus (the rim cell is ocean) — or stuck on the spot.
|
||||||
|
DrainageRenderer.Disc(img, b.SpillCell / n, b.SpillCell % n, ring / 2, n, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. spills, floors, labels.
|
||||||
|
foreach (var b in g.LandNodes)
|
||||||
|
{
|
||||||
|
int sx = b.SpillCell / n, sy = b.SpillCell % n;
|
||||||
|
DrainageRenderer.Ring(img, sx, sy, ring, n, Spill, ringW);
|
||||||
|
DrainageRenderer.Disc(img, b.FloorCell / n, b.FloorCell % n, floorR, n, Floor);
|
||||||
|
string lbl = $"{b.SpillAboveSeaM:F0}M/{b.SpillClimbM:F0}"; // TinyFont has no '+' or '^': "spill m above sea / climb m from floor"
|
||||||
|
Label(img, lbl, sx + ring + 4, sy - TinyFont.Height(scale) / 2, scale, n);
|
||||||
|
Label(img, $"#{b.Id}", b.FloorCell / n + floorR + 3, b.FloorCell % n - TinyFont.Height(scale) / 2, scale, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. the legend.
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, third, 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, "BLUE TINT = LAKE BASIN (SIGNIFICANT CLASSIFY WATER INSIDE) AMBER TINT = DRY SINK BLACK DOT = BASIN FLOOR (#ID)", 12, 12 + lh * 3, s, Ink);
|
||||||
|
TinyFont.Draw(img, "YELLOW RING = SPILL CELL. LABEL 12M/4 = SPILL 12 M ABOVE SEA / 4 M CLIMB FROM THE BASIN FLOOR TO OVERTOP (RENDER SURFACE)", 12, 12 + lh * 4, s, Ink);
|
||||||
|
TinyFont.Draw(img, "ARROW = WHERE THE SPILL DRAINS: CYAN TO OCEAN, WHITE INTO ANOTHER BASIN, RED = CLOSED. DATA LAYER ONLY - NOTHING FILLED, NOTHING CARVED", 12, 12 + lh * 5, s, Ink);
|
||||||
|
TinyFont.Draw(img, "FAINT BLUE OUTLINE, NO MARKS = SEABED PIT (A RENDER DEPRESSION UNDER THE CLASSIFY SEA - THE D-046 SEAM, INERT, EXCLUDED FROM THE GRAPH)", 12, 12 + lh * 6, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Arrowhead(Image img, List<int> path, int n, Color c, int len, int thick)
|
||||||
|
{
|
||||||
|
int end = path[^1];
|
||||||
|
int from = path[Math.Max(0, path.Count - 1 - 24)];
|
||||||
|
float ex = end / n, ey = end % n, fx = from / n, fy = from % n;
|
||||||
|
float dx = ex - fx, dy = ey - fy;
|
||||||
|
float L = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (L < 1f) return;
|
||||||
|
dx /= L; dy /= L;
|
||||||
|
// two barbs, 30° either side of the reversed direction
|
||||||
|
const float a = 0.5236f;
|
||||||
|
float cs = MathF.Cos(a), sn = MathF.Sin(a);
|
||||||
|
float bx1 = -dx * cs - (-dy) * sn, by1 = -dx * sn + (-dy) * cs;
|
||||||
|
float bx2 = -dx * cs + (-dy) * sn, by2 = -(-dx) * sn + (-dy) * cs;
|
||||||
|
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx1 * len), (int)(ey + by1 * len), n, c, thick);
|
||||||
|
DrainageRenderer.Line(img, (int)ex, (int)ey, (int)(ex + bx2 * len), (int)(ey + by2 * len), n, c, thick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ink over a one-px black shadow, clamped inside the image so a rim label near the edge is still readable.</summary>
|
||||||
|
private static void Label(Image img, string text, int x, int y, int scale, int n)
|
||||||
|
{
|
||||||
|
int w = TinyFont.Width(text, scale), h = TinyFont.Height(scale);
|
||||||
|
x = Math.Clamp(x, 0, Math.Max(0, n - w - 1));
|
||||||
|
y = Math.Clamp(y, 0, Math.Max(0, n - h - 1));
|
||||||
|
TinyFont.Draw(img, text, x + 1, y + 1, scale, Shadow);
|
||||||
|
TinyFont.Draw(img, text, x, y, scale, Ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
599
Tools/Scripts/BasinGraphTool.cs
Normal file
599
Tools/Scripts/BasinGraphTool.cs
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE LAKE-BASIN LAYER (rivers/04) — build the basin graph and show it, so the developer can
|
||||||
|
/// trust the spills before anything is routed on them.
|
||||||
|
///
|
||||||
|
/// ═══ WHAT THIS TASK IS FOR ═══
|
||||||
|
///
|
||||||
|
/// The flow-through routing model (the next task) needs lakes to be NODES in the drainage graph,
|
||||||
|
/// not free-floating heightmap classifications the router bumps into. This builds that layer from
|
||||||
|
/// what `DrainageAnalysis` already computed — the sinks (`BasinId`) and the overflow surface
|
||||||
|
/// (`FullFilled`) — enriching each terminal basin with its SPILL, its LAKE-IDENTITY and its
|
||||||
|
/// DOWNSTREAM EDGE. → <see cref="BasinGraph"/>.
|
||||||
|
///
|
||||||
|
/// ═══ ⛔ THE RED LINE — A DATA LAYER, NOT A TERRAIN WRITE ═══
|
||||||
|
///
|
||||||
|
/// **No height is written. No water is filled or created. `DrainageAnalysis` is reused, not
|
||||||
|
/// rewritten.** Spills and lake-identity are computed and recorded, never stamped. Both height
|
||||||
|
/// fields are digested before the layer is built and after the plate is drawn, and the tool REFUSES
|
||||||
|
/// to continue if either changed — the flood-guard discipline every task since erosion has kept.
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/BasinGraphTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
|
||||||
|
/// ISLA_LAKE_MIN_PX the significance floor for a basin's in-basin classify water (default 20000 — a KNOB)
|
||||||
|
/// ISLA_CAP_PREVIEW_M comma list of rim caps to preview connectivity at (default "15,30,60")
|
||||||
|
/// </summary>
|
||||||
|
public partial class BasinGraphTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
private const int DefaultMapSize = 8192;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
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 BasinGraph Graph;
|
||||||
|
public int TerminalBasinCount;
|
||||||
|
public long LandCells, EndorheicCells;
|
||||||
|
public ulong RenderDigest, ClassifyDigest;
|
||||||
|
public int WaterBodiesKept, WaterBodiesTotal; public long WaterCellsKept, LargestWaterPx;
|
||||||
|
public float SpillMinM, SpillP25M, SpillMedM, SpillP75M, SpillMaxM;
|
||||||
|
public float ClimbMinM, ClimbP25M, ClimbMedM, ClimbP75M, ClimbMaxM;
|
||||||
|
public int[] ClimbBands; // <=5, 5-15, 15-30, 30-60, >60
|
||||||
|
public Dictionary<float, (int all, int lake, int dry, int direct)> Cap = new();
|
||||||
|
public int LakeAnyOnly; // HasAnyLake && !IsLake — where this layer's label differs from the routing sort
|
||||||
|
public int MaxHops;
|
||||||
|
public float GraphSeconds, RenderSeconds; public ulong Ms;
|
||||||
|
public float GMin, GMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 4);
|
||||||
|
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "lake_basin_layer");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
|
||||||
|
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
|
||||||
|
|
||||||
|
TerrainShapeV1.Assert("BasinGraph");
|
||||||
|
TerrainShapeV1.AssertErosionDefaultOn("BasinGraph");
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
// ⚠ The ENUMERATION gates (EndorheicMinDepthM / EndorheicMinAreaPx) are the defaults — they
|
||||||
|
// define which depressions ARE terminal basins, i.e. the nodes of this graph. Reporting caps
|
||||||
|
// are irrelevant here: the layer reads BasinId / FullFilled / Dir, not the promoted lists.
|
||||||
|
var dp = new DrainageAnalysis.Params { SeaLevel = sea };
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" THE LAKE-BASIN LAYER (rivers/04) — the basin graph: spill + lake-identity + downstream edge, per terminal basin");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||||
|
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||||||
|
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
|
||||||
|
GD.Print($"spill : {BasinGraph.SpillDatum}");
|
||||||
|
GD.Print($"lake : {BasinGraph.LakeDatum} floor = {lakeMinPx:N0} px (ISLA_LAKE_MIN_PX, a knob)");
|
||||||
|
GD.Print($"edge : {BasinGraph.DownstreamMethod}");
|
||||||
|
GD.Print($"D-046 : spill geometry/height on RENDER (the flow surface, as RouteTo routes); lake presence + OCEAN on CLASSIFY (the water surface, as the terminus tests). No cross-surface comparison anywhere.");
|
||||||
|
GD.Print($"cap view : unbroken spill-chain to the ocean previewed at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m (every link's floor→spill climb ≤ cap; clamped at sea as RouteTo clamps)");
|
||||||
|
GD.Print($"⛔ RED LINE : DATA LAYER ONLY — no height mutated, no water filled, DrainageAnalysis untouched. ASSERTED per seed around build + render.");
|
||||||
|
GD.Print($"batch : {batchRoot}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
if (mapSize != 8192)
|
||||||
|
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the terminal-basin gates are ABSOLUTE PIXEL COUNTS tuned at 8192; a smaller " +
|
||||||
|
"map under-produces terminal basins. This run checks the PLUMBING (spill extraction, graph, render), not the character.");
|
||||||
|
|
||||||
|
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 = "basins",
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 and the water surface — CLASSIFY (→ D-066 / D-046).
|
||||||
|
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;
|
||||||
|
|
||||||
|
// ⭐ THE FLOW SURFACE — the analysis on the eroded RENDER height, exactly as every task since chat2/12.
|
||||||
|
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} %); terminal basins {plan.TerminalBasinCount}; pits filled through {plan.PitsFilledCount:N0}");
|
||||||
|
|
||||||
|
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize,
|
||||||
|
lakeMinPx, out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
|
||||||
|
GD.Print($" significant water: {keptBodies} of {totalBodies} classify-water bodies >= {lakeMinPx:N0} px ({keptCells:N0} cells; largest {largestPx:N0} px)");
|
||||||
|
|
||||||
|
// ═══ ⛔ THE RED-LINE GUARD — both fields digested BEFORE the layer ═══
|
||||||
|
ulong hRenderBefore = Digest(p2.Height, mapSize);
|
||||||
|
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
|
||||||
|
|
||||||
|
ulong tg0 = Time.GetTicksMsec();
|
||||||
|
var g = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
|
||||||
|
float graphSec = (Time.GetTicksMsec() - tg0) / 1000f;
|
||||||
|
|
||||||
|
if (g.Nodes.Count != plan.TerminalBasinCount)
|
||||||
|
throw new InvalidOperationException($"[BasinGraph] node count {g.Nodes.Count} != Plan.TerminalBasinCount {plan.TerminalBasinCount} — the layer did not enumerate the analysis's basins exactly.");
|
||||||
|
|
||||||
|
var r = new SeedResult
|
||||||
|
{
|
||||||
|
Seed = seed, Graph = g, TerminalBasinCount = plan.TerminalBasinCount,
|
||||||
|
LandCells = plan.LandCells, EndorheicCells = plan.EndorheicCells,
|
||||||
|
WaterBodiesKept = keptBodies, WaterBodiesTotal = totalBodies, WaterCellsKept = keptCells, LargestWaterPx = largestPx,
|
||||||
|
GraphSeconds = graphSec,
|
||||||
|
};
|
||||||
|
Summarise(r, caps);
|
||||||
|
|
||||||
|
GD.Print($" ⚠ SEAM: {g.Seabed} of {g.Nodes.Count} terminal basins are SEABED pits (every cell ocean on classify, inflow 0) — excluded from the graph statistics below; {g.Coastal} straddle the shoreline (kept as land).");
|
||||||
|
GD.Print($" ⭐ GRAPH (land): {g.LandNodes.Count} basins — {g.LakeBasins} LAKE / {g.DryBasins} DRY (floor {lakeMinPx:N0} px; {r.LakeAnyOnly} hold only a sub-floor puddle)");
|
||||||
|
GD.Print($" spills → ocean {g.ToOcean} / → another basin {g.ToBasin} / closed {g.Closed}; longest chain {r.MaxHops} hops");
|
||||||
|
GD.Print($" spill height above sea (m): min {r.SpillMinM:F1} p25 {r.SpillP25M:F1} median {r.SpillMedM:F1} p75 {r.SpillP75M:F1} max {r.SpillMaxM:F1}");
|
||||||
|
GD.Print($" floor→spill climb (m): min {r.ClimbMinM:F1} p25 {r.ClimbP25M:F1} median {r.ClimbMedM:F1} p75 {r.ClimbP75M:F1} max {r.ClimbMaxM:F1} bands ≤5/5–15/15–30/30–60/>60: {string.Join("/", r.ClimbBands)}");
|
||||||
|
foreach (float cap in caps)
|
||||||
|
{
|
||||||
|
var c = r.Cap[cap];
|
||||||
|
GD.Print($" cap {cap,3:F0} m: {c.all,3} of {g.LandNodes.Count} land basins chain to the ocean ({c.lake} lake / {c.dry} dry; {c.direct} of them directly)");
|
||||||
|
}
|
||||||
|
GD.Print($" ✅ invariants: spill cross-check (fill-level vs rim-walk) failures {g.SpillCrossCheckFailures}; spill-not-on-terrain {g.SpillNotOnTerrain}; " +
|
||||||
|
$"Dir-walk disagreements {g.DirWalkDisagreements}; seabed {g.Seabed} / coastal {g.Coastal}");
|
||||||
|
if (g.SpillCrossCheckFailures > 0 || g.SpillNotOnTerrain > 0)
|
||||||
|
GD.PrintErr(" ⚠⚠ A SPILL INVARIANT FAILED — the spill datum is not exact on this seed. Reported, not hidden; see the CSV.");
|
||||||
|
if (g.DirWalkDisagreements > 0)
|
||||||
|
GD.PrintErr(" ⚠ The FullFilled walk and the Dir walk disagree on some basin's downstream — listed in the CSV (dir_walk_agrees).");
|
||||||
|
GD.Print($" ⭐ RECONCILIATION: {g.LakeBodies.Count} significant classify bodies — {g.LakeBodiesOwned} owned by a basin (≥ half inside one), " +
|
||||||
|
$"{g.LakeBodiesSplit} split across basins, {g.LakeBodiesFree} FREE (in no terminal basin at all); " +
|
||||||
|
$"{g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} non-ocean classify-water cells lie outside every basin");
|
||||||
|
GD.Print($" graph built in {graphSec:F2}s");
|
||||||
|
|
||||||
|
WriteBasinCsv(batchRoot, r, caps);
|
||||||
|
WriteLakeCsv(batchRoot, r);
|
||||||
|
|
||||||
|
ulong tr0 = Time.GetTicksMsec();
|
||||||
|
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, skipRaw);
|
||||||
|
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000f;
|
||||||
|
|
||||||
|
// ═══ ⛔ …and asserted byte-identical AFTER build + render ═══
|
||||||
|
ulong hRenderAfter = Digest(p2.Height, mapSize);
|
||||||
|
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
|
||||||
|
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"[BasinGraph] RED-LINE VIOLATION: a height field CHANGED across the layer build / render.\n" +
|
||||||
|
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
|
||||||
|
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
|
||||||
|
"This task builds a DATA layer — it must never mutate a height or fill water. Refusing to continue.");
|
||||||
|
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
|
||||||
|
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical across build + render — no height mutated, no water filled.");
|
||||||
|
|
||||||
|
r.Ms = Time.GetTicksMsec() - t0;
|
||||||
|
results.Add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteIndex(batchRoot, mapSize, seeds, results, lakeMinPx, caps, dp, skipRaw);
|
||||||
|
GD.Print("\n==================================================================");
|
||||||
|
GD.Print($" DONE — {batchRoot}");
|
||||||
|
GD.Print(" ⛔ TASTE GATE: the graph is PRESENTED, not routed on. Nothing locked, nothing routed, nothing graduated.");
|
||||||
|
GD.Print(" ⛔ DATA LAYER ONLY: no height mutated, no water filled — asserted per seed.");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GetTree().Quit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Summarise(SeedResult r, float[] caps)
|
||||||
|
{
|
||||||
|
var g = r.Graph;
|
||||||
|
var spill = new List<float>(); var climb = new List<float>();
|
||||||
|
r.ClimbBands = new int[5];
|
||||||
|
foreach (var b in g.LandNodes)
|
||||||
|
{
|
||||||
|
spill.Add(b.SpillAboveSeaM); climb.Add(b.SpillClimbM);
|
||||||
|
r.ClimbBands[b.SpillClimbM <= 5f ? 0 : b.SpillClimbM <= 15f ? 1 : b.SpillClimbM <= 30f ? 2 : b.SpillClimbM <= 60f ? 3 : 4]++;
|
||||||
|
if (b.HasAnyLake && !b.IsLake) r.LakeAnyOnly++;
|
||||||
|
int hops = g.HopsToOcean(b.Id);
|
||||||
|
if (hops > r.MaxHops) r.MaxHops = hops;
|
||||||
|
}
|
||||||
|
spill.Sort(); climb.Sort();
|
||||||
|
(r.SpillMinM, r.SpillP25M, r.SpillMedM, r.SpillP75M, r.SpillMaxM) = Quantiles(spill);
|
||||||
|
(r.ClimbMinM, r.ClimbP25M, r.ClimbMedM, r.ClimbP75M, r.ClimbMaxM) = Quantiles(climb);
|
||||||
|
foreach (float cap in caps)
|
||||||
|
{
|
||||||
|
bool[] ok = g.ConnectedAtCap(cap, out int connected);
|
||||||
|
int lake = 0, dry = 0, direct = 0;
|
||||||
|
connected = 0;
|
||||||
|
for (int i = 0; i < g.Nodes.Count; i++)
|
||||||
|
{
|
||||||
|
if (!ok[i] || !g.Nodes[i].IsLand) continue; // land basins only — a seabed pit "chains" trivially
|
||||||
|
connected++;
|
||||||
|
if (g.Nodes[i].IsLake) lake++; else dry++;
|
||||||
|
if (g.Nodes[i].Downstream == DownstreamKind.Ocean) direct++;
|
||||||
|
}
|
||||||
|
r.Cap[cap] = (connected, lake, dry, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (float, float, float, float, float) Quantiles(List<float> sorted)
|
||||||
|
{
|
||||||
|
if (sorted.Count == 0) return (0, 0, 0, 0, 0);
|
||||||
|
float Q(double q) => sorted[Math.Clamp((int)Math.Round(q * (sorted.Count - 1)), 0, sorted.Count - 1)];
|
||||||
|
return (sorted[0], Q(0.25), Q(0.5), Q(0.75), sorted[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FNV-1a over the raw float bits — "byte-identical", not "numerically close".</summary>
|
||||||
|
private static ulong Digest(float[,] f, int n)
|
||||||
|
{
|
||||||
|
ulong h = 14695981039346656037UL;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
|
||||||
|
for (int b = 0; b < 4; b++)
|
||||||
|
{
|
||||||
|
h ^= (byte)(bits >> (b * 8));
|
||||||
|
h *= 1099511628211UL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Kind(DownstreamKind k) => k switch
|
||||||
|
{
|
||||||
|
DownstreamKind.Ocean => "OCEAN",
|
||||||
|
DownstreamKind.Basin => "BASIN",
|
||||||
|
_ => "NONE",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void WriteBasinCsv(string batchRoot, SeedResult r, float[] caps)
|
||||||
|
{
|
||||||
|
var g = r.Graph; int n = g.MapSize;
|
||||||
|
var capOk = new Dictionary<float, bool[]>();
|
||||||
|
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("id,class,area_px,inflow_px,is_lake,has_any_lake,lake_cells,lake_cells_significant,ocean_cells," +
|
||||||
|
"floor_x,floor_y,floor_raw,spill_x,spill_y,spill_raw,spill_above_sea_m,depth_to_spill_m,spill_climb_m,spill_ties," +
|
||||||
|
"spill_crosscheck_ok,spill_on_terrain,downstream,downstream_id,downstream_x,downstream_y,spill_path_cells,dir_walk_agrees,dir_walk_kind,dir_walk_id,hops_to_ocean");
|
||||||
|
foreach (float cap in caps) sb.Append($",chain_ok_cap{cap:F0}");
|
||||||
|
sb.AppendLine();
|
||||||
|
for (int i = 0; i < g.Nodes.Count; i++)
|
||||||
|
{
|
||||||
|
var b = g.Nodes[i];
|
||||||
|
sb.Append($"{b.Id},{(b.IsSeabed ? "seabed" : b.IsCoastal ? "coastal" : "inland")},{b.AreaPx},{b.InflowPx},{(b.IsLake ? "yes" : "no")},{(b.HasAnyLake ? "yes" : "no")},{b.LakeCells},{b.LakeCellsSignificant},{b.OceanCells}," +
|
||||||
|
$"{b.FloorCell / n},{b.FloorCell % n},{b.FloorHeightRaw:R},{b.SpillCell / n},{b.SpillCell % n},{b.SpillHeightRaw:R}," +
|
||||||
|
$"{b.SpillAboveSeaM:F2},{b.DepthToSpillM:F2},{b.SpillClimbM:F2},{b.SpillTies}," +
|
||||||
|
$"{(b.SpillCrossCheckOk ? "yes" : "NO")},{(b.SpillOnTerrain ? "yes" : "NO")},{Kind(b.Downstream)},{b.DownstreamId}," +
|
||||||
|
$"{b.DownstreamEntryCell / n},{b.DownstreamEntryCell % n},{b.SpillPath.Count},{(b.DirWalkAgrees ? "yes" : "NO")},{Kind(b.DirWalkKind)},{b.DirWalkId},{g.HopsToOcean(b.Id)}");
|
||||||
|
foreach (float cap in caps) sb.Append($",{(capOk[cap][i] ? "yes" : "no")}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, $"basins_{r.Seed}.csv"), sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteLakeCsv(string batchRoot, SeedResult r)
|
||||||
|
{
|
||||||
|
var g = r.Graph;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("body,size_px,cells_in_basins,basins_touched,dominant_basin_id,dominant_cells,dominant_basin_is_lake,status");
|
||||||
|
foreach (var l in g.LakeBodies)
|
||||||
|
{
|
||||||
|
var b = l.DominantBasinId != 0 ? g.Of(l.DominantBasinId) : null;
|
||||||
|
sb.AppendLine($"{l.Index},{l.SizePx},{l.CellsInBasins},{l.BasinsTouched},{l.DominantBasinId},{l.DominantCells}," +
|
||||||
|
$"{(b == null ? "" : b.IsLake ? "yes" : "no")},{(l.Free ? "FREE" : l.Owned ? "owned" : "split")}");
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, $"lakes_{r.Seed}.csv"), sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean,
|
||||||
|
bool[] isClassifyWater, Pass2Result p2, int n, float sea, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
var g = r.Graph;
|
||||||
|
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||||||
|
string caps = "";
|
||||||
|
foreach (var kv in r.Cap) caps += $"{kv.Key:F0}M:{kv.Value.all} ";
|
||||||
|
BasinGraphRenderer.Plate(g, plan, baseImg, n, isOcean, isClassifyWater,
|
||||||
|
$"SEED {r.Seed} - THE BASIN GRAPH: {g.LandNodes.Count} LAND BASINS, {g.LakeBasins} LAKE / {g.DryBasins} DRY (FLOOR {g.LakeMinPx} PX) [+{g.Seabed} SEABED PITS, OUTLINED ONLY]",
|
||||||
|
$"SPILLS: {g.ToOcean} TO OCEAN / {g.ToBasin} INTO ANOTHER BASIN / {g.Closed} CLOSED. LONGEST CHAIN {r.MaxHops} HOPS. BASINS CHAINING TO THE OCEAN AT CAP {caps.Trim()}",
|
||||||
|
$"SPILL HEIGHT ABOVE SEA: MEDIAN {r.SpillMedM:F0} M (RANGE {r.SpillMinM:F0}..{r.SpillMaxM:F0}). FLOOR-TO-SPILL CLIMB: MEDIAN {r.ClimbMedM:F0} M (RANGE {r.ClimbMinM:F0}..{r.ClimbMaxM:F0}). TASTE GATE - NOTHING ROUTED, NOTHING LOCKED")
|
||||||
|
.SavePng(Path.Combine(dir, $"basin_graph_{r.Seed}.png"));
|
||||||
|
|
||||||
|
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||||||
|
r.GMin = gmin; r.GMax = gmax;
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List<SeedResult> rows,
|
||||||
|
int lakeMinPx, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int primary = seeds.Length > 0 ? seeds[0] : 0;
|
||||||
|
string capHdr = string.Join(" | ", Array.ConvertAll(caps, c => $"chain→ocean @ {c:F0} m"));
|
||||||
|
|
||||||
|
sb.AppendLine("# Batch 04 — the lake-basin layer: the basin graph (spill + lake-identity + downstream edge)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**⛔ TASTE GATE ON THE FOUNDATION. Nothing is routed, nothing is locked, nothing is graduated.** This is the");
|
||||||
|
sb.AppendLine("data layer the flow-through routing model (piece 2) will traverse; piece 2 is authored only once the spills");
|
||||||
|
sb.AppendLine("and the graph read right.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**⛔ DATA LAYER ONLY. No height was mutated, no water was filled or created, `DrainageAnalysis` was not");
|
||||||
|
sb.AppendLine("edited** — asserted per seed by an FNV digest of both height fields taken before the layer was built and");
|
||||||
|
sb.AppendLine("after the plate was drawn.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## 👉 The pick");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Open **`{primary}/basin_graph_{primary}.png`**. Then the other three: " +
|
||||||
|
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||||||
|
sb.AppendLine("> **Do the spills (yellow rings) sit where water would actually overflow — the true low rim? Are the");
|
||||||
|
sb.AppendLine("> lake/dry labels right (blue = a significant classify lake sits in the basin, amber = dry sink)? And does");
|
||||||
|
sb.AppendLine("> \"who drains to whom\" (cyan arrow → ocean, white arrow → another basin, red = closed) look like a real");
|
||||||
|
sb.AppendLine("> drainage network?** If the spills are wrong, piece 2 must not be built on this.");
|
||||||
|
sb.AppendLine(">");
|
||||||
|
sb.AppendLine("> Each spill is labelled `23M/7`: **23 m above the sea datum**, and **7 m of climb from the basin floor**");
|
||||||
|
sb.AppendLine("> to overtop — the second number is what a rim cap is judged against. Both read on the RENDER surface.");
|
||||||
|
sb.AppendLine("> The black dot is the basin floor, with its `#id` (the id `BasinId` carries — sparse, as the analysis leaves it).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ The graph, per seed");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"| Seed | terminal basins | ⚠ seabed (excluded) | coastal | **land basins** | **lake** | dry | (sub-floor puddle only) | spill → ocean | → basin | closed | longest chain | {capHdr} |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var g = r.Graph;
|
||||||
|
sb.Append($"| `{r.Seed}` | {g.Nodes.Count} | {g.Seabed} | {g.Coastal} | **{g.LandNodes.Count}** | **{g.LakeBasins}** | {g.DryBasins} | {r.LakeAnyOnly} | {g.ToOcean} | {g.ToBasin} | {g.Closed} | {r.MaxHops} hops |");
|
||||||
|
foreach (float cap in caps) { var c = r.Cap[cap]; sb.Append($" **{c.all}** ({c.lake} lake / {c.dry} dry) |"); }
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> ### ⚠⚠ SEABED PITS — half the analysis's \"terminal basins\" are not on land");
|
||||||
|
sb.AppendLine("> The priority-flood runs on the whole RENDER surface, ocean floor included, so a deep-enough, large-enough");
|
||||||
|
sb.AppendLine("> depression UNDER the classify sea qualifies as a terminal basin exactly like a land one. Every cell of such");
|
||||||
|
sb.AppendLine("> a basin is `OceanMask`, its cells are `D_NONE`, its inflow is 0 — it is hydrologically inert, and it is the");
|
||||||
|
sb.AppendLine("> D-046 seam (render vs classify) made visible. They are kept in the layer and the CSV (`class = seabed`),");
|
||||||
|
sb.AppendLine("> drawn as a faint outline only, and **excluded from every statistic on this page.** The `land basins` column is");
|
||||||
|
sb.AppendLine("> the graph; `coastal` basins (some ocean cells, some land) are counted as land and flagged.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> **Reading the cap columns.** A basin \"chains to the ocean at cap C\" when every link from it to the sea — its");
|
||||||
|
sb.AppendLine("> own spill and every downstream basin's spill — climbs ≤ C m from that basin's floor (elevation clamped at sea,");
|
||||||
|
sb.AppendLine("> exactly as `RouteTo` clamps). This previews what the flow-through model will trade at a given cap; it decides");
|
||||||
|
sb.AppendLine("> nothing. Piece 2 routes; this only says how many basins *could* connect.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Spill-height distributions (metres, render surface)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | spill above sea: min / p25 / median / p75 / max | floor→spill climb: min / p25 / median / p75 / max | climb bands ≤5 / 5–15 / 15–30 / 30–60 / >60 |");
|
||||||
|
sb.AppendLine("|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | {r.SpillMinM:F1} / {r.SpillP25M:F1} / {r.SpillMedM:F1} / {r.SpillP75M:F1} / {r.SpillMaxM:F1} | " +
|
||||||
|
$"{r.ClimbMinM:F1} / {r.ClimbP25M:F1} / {r.ClimbMedM:F1} / {r.ClimbP75M:F1} / {r.ClimbMaxM:F1} | {string.Join(" / ", r.ClimbBands)} |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ✅ The invariants, per seed — the spill datum is exact, or it says so");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | spill cross-check failures (fill-level vs rim-walk) | spill not on terrain | Dir-walk disagreements | seabed / coastal | render digest | classify digest |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var g = r.Graph;
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | {(g.SpillCrossCheckFailures == 0 ? "**0** ✅" : $"**{g.SpillCrossCheckFailures}** ⚠⚠")} | " +
|
||||||
|
$"{(g.SpillNotOnTerrain == 0 ? "**0** ✅" : $"**{g.SpillNotOnTerrain}** ⚠⚠")} | " +
|
||||||
|
$"{(g.DirWalkDisagreements == 0 ? "**0** ✅" : $"**{g.DirWalkDisagreements}** ⚠")} | {g.Seabed} / {g.Coastal} | `{r.RenderDigest:X16}` | `{r.ClassifyDigest:X16}` |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("*Cross-check: the basin's minimum on `FullFilled` is the spill + one ulp (the flood's own epsilon), so");
|
||||||
|
sb.AppendLine("`BitDecrement(min inside) == min over the rim` must hold exactly. \"On terrain\": `FullFilled == render` at the spill");
|
||||||
|
sb.AppendLine("cell — the rim was never raised by the flood. \"Dir-walk\": following `Plan.Dir` from the first cell past the spill");
|
||||||
|
sb.AppendLine("reaches the same node as the `FullFilled` descent. A basin holding OCEAN cells is a render depression under");
|
||||||
|
sb.AppendLine("classify-sea — the D-046 seam, counted rather than hidden.*");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
sb.AppendLine("## ⭐ The reconciliation — does each significant heightmap lake sit in a terminal basin?");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | significant bodies (≥ floor) | **owned** (≥ half inside one basin) | split across basins | **FREE** (in no basin) | non-ocean classify-water cells outside every basin |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var g = r.Graph;
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | {g.LakeBodies.Count} | **{g.LakeBodiesOwned}** | {g.LakeBodiesSplit} | **{g.LakeBodiesFree}** | {g.ClassifyWaterCellsOutsideBasins:N0} of {g.ClassifyWaterCellsTotal:N0} ({100.0 * g.ClassifyWaterCellsOutsideBasins / Math.Max(1, g.ClassifyWaterCellsTotal):F1} %) |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("*A FREE body is a classify lake that is not a depression ≥ 2 m / 10,000 px on the RENDER surface (or filled through as a");
|
||||||
|
sb.AppendLine("pit) — heightmap water the hydrology never pooled into. On the plate these are the dark-teal patches with no tint. They are");
|
||||||
|
sb.AppendLine("exactly the \"free-floating heightmap lakes\" this layer exists to reconcile; per body detail in `lakes_<seed>.csv`.*");
|
||||||
|
sb.AppendLine();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var g = r.Graph; int n = g.MapSize;
|
||||||
|
sb.AppendLine($"### `{r.Seed}` — every LAND basin, largest first ({g.Seabed} seabed pits omitted; see the CSV)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("| id | area px | inflow px | lake? | lake cells | spill (x,y) | spill +m | climb m | → | hops |");
|
||||||
|
foreach (float cap in caps) sb.Append($" @{cap:F0} |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
|
||||||
|
var order = new List<BasinNode>(g.LandNodes);
|
||||||
|
order.Sort((a, b) => b.AreaPx.CompareTo(a.AreaPx));
|
||||||
|
var capOk = new Dictionary<float, bool[]>();
|
||||||
|
foreach (float cap in caps) capOk[cap] = g.ConnectedAtCap(cap, out _);
|
||||||
|
foreach (var b in order)
|
||||||
|
{
|
||||||
|
int idx = g.Nodes.IndexOf(b);
|
||||||
|
string to = b.Downstream switch
|
||||||
|
{
|
||||||
|
DownstreamKind.Ocean => "**OCEAN**",
|
||||||
|
DownstreamKind.Basin => $"#{b.DownstreamId}",
|
||||||
|
_ => "⚠ closed",
|
||||||
|
};
|
||||||
|
int hops = g.HopsToOcean(b.Id);
|
||||||
|
sb.Append($"| #{b.Id}{(b.IsCoastal ? " ⚠coastal" : "")} | {b.AreaPx:N0} | {b.InflowPx:N0} | {(b.IsLake ? "**lake**" : b.HasAnyLake ? "puddle" : "dry")} | {b.LakeCells:N0} | " +
|
||||||
|
$"({b.SpillCell / n},{b.SpillCell % n}) | {b.SpillAboveSeaM:F1} | {b.SpillClimbM:F1} | {to} | {(hops < 0 ? "—" : hops.ToString())} |");
|
||||||
|
foreach (float cap in caps) sb.Append(capOk[cap][idx] ? " ✅ |" : " — |");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"*Land {r.LandCells:N0}, endorheic {r.EndorheicCells:N0} ({100.0 * r.EndorheicCells / Math.Max(1, r.LandCells):F1} %) · " +
|
||||||
|
$"significant water {r.WaterBodiesKept} of {r.WaterBodiesTotal} bodies ≥ {lakeMinPx:N0} px ({r.WaterCellsKept:N0} cells, largest {r.LargestWaterPx:N0} px) · " +
|
||||||
|
$"graph {r.GraphSeconds:F2}s, plate {r.RenderSeconds:F1}s, seed total {r.Ms / 1000.0:F0}s · grayscale range {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("## The two \"lakes\" this layer reconciles — and the datum each is read on (D-046)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Quantity | Surface | Why |");
|
||||||
|
sb.AppendLine("|---|---|---|");
|
||||||
|
sb.AppendLine("| spill cell, spill height, floor, climb | **RENDER** (`Plan.FullFilled`, the flood of the eroded render height) | where water GOES — the surface `RouteTo` routes on, so a climb here is the same number as the router's `RimClimbM` |");
|
||||||
|
sb.AppendLine("| downstream walk | **RENDER** (`FullFilled` descent) | the reference's provisional-route machinery, started at the spill |");
|
||||||
|
sb.AppendLine("| lake presence (`IsLake`, lake cells) | **CLASSIFY** (`classify < sea` and not `OceanMask`) | what is VISIBLY water — the surface the terminus tests use |");
|
||||||
|
sb.AppendLine("| the OCEAN terminus of a walk | **CLASSIFY** (`OceanMask`) | as routing: route on render, ocean on classify |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**No field compares a classify height to a render height.** The surfaces meet only as membership tests");
|
||||||
|
sb.AppendLine("(is this basin cell classify-water? is this walk cell ocean?) — the split the routing already lives by. No new seam.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## What was run");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Chain + drainage analysis + the layer at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all rendered.");
|
||||||
|
sb.AppendLine($"`ISLA_LAKE_MIN_PX={lakeMinPx:N0}` (the significance floor — a knob); cap preview at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis` (reused — the layer reads `BasinId`, `FullFilled`, `Dir`, `BasinInflow`); " +
|
||||||
|
$"`EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} (they define which depressions ARE the nodes).");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## Files");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| File | What it is |");
|
||||||
|
sb.AppendLine("|---|---|");
|
||||||
|
sb.AppendLine("| `<seed>/basin_graph_<seed>.png` | the graph over the faint terrain: basins tinted lake/dry, spill rings labelled, arrows to the downstream node |");
|
||||||
|
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||||||
|
sb.AppendLine("| `basins_<seed>.csv` | every `BasinNode`: class (inland/coastal/seabed), area, inflow, lake cells, floor, spill (cell + raw + metres), climb, downstream kind/id/entry, invariants, hops, chain-ok per cap |");
|
||||||
|
sb.AppendLine("| `lakes_<seed>.csv` | every significant classify body: size, cells inside basins, dominant basin, owned / split / FREE |");
|
||||||
|
if (skipRaw)
|
||||||
|
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`. |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("→ `XX_Human/output/rivers/04_lake_basin_layer.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;
|
||||||
|
}
|
||||||
|
private static float[] EnvFloats(string k, float[] fallback)
|
||||||
|
{
|
||||||
|
string v = EnvStr(k, null);
|
||||||
|
if (v == null) return fallback;
|
||||||
|
var outp = new List<float>();
|
||||||
|
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
|
||||||
|
return outp.Count > 0 ? outp.ToArray() : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,6 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_SPECK_FRAC the speck-revert threshold, fraction of map area (default 2.5e-7 ≈ 4 cells at 4096)
|
/// ISLA_SPECK_FRAC the speck-revert threshold, fraction of map area (default 2.5e-7 ≈ 4 cells at 4096)
|
||||||
/// ISLA_PROBE=1 · ISLA_PROBE_FREQS · ISLA_PROBE_AMPS the probe sweep
|
/// ISLA_PROBE=1 · ISLA_PROBE_FREQS · ISLA_PROBE_AMPS the probe sweep
|
||||||
/// ISLA_FRAG_BITES=1 bites-only noise ([0,1]) instead of zero-mean ([-1,1])
|
/// ISLA_FRAG_BITES=1 bites-only noise ([0,1]) instead of zero-mean ([-1,1])
|
||||||
/// ISLA_SKIP_8K=1 (no 8192 check this batch — the 08 dump at 4096 is the baseline)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class CoastalFragmentTool : Node
|
public partial class CoastalFragmentTool : Node
|
||||||
{
|
{
|
||||||
|
|
@ -86,6 +85,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 9);
|
int task = EnvInt("ISLA_TASK", 9);
|
||||||
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
|
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
|
||||||
|
|
@ -101,9 +104,7 @@ namespace IslaApocalypse.Tools
|
||||||
float[] probeFreqs = EnvFloats("ISLA_PROBE_FREQS", ProbeFreqs);
|
float[] probeFreqs = EnvFloats("ISLA_PROBE_FREQS", ProbeFreqs);
|
||||||
float[] probeAmps = EnvFloats("ISLA_PROBE_AMPS", ProbeAmps);
|
float[] probeAmps = EnvFloats("ISLA_PROBE_AMPS", ProbeAmps);
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
|
|
||||||
string t08Level = EnvStr("ISLA_T08_LEVEL", "stretch_3"); // the 08 rung with stretch 2
|
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
|
@ -132,16 +133,26 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
|
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
|
||||||
{
|
{
|
||||||
return new TerrainGenConfig
|
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
|
||||||
|
// it was authored and judged before the shape family existed, and its regression checks
|
||||||
|
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
|
||||||
|
// bare defaults family-ON, so without this pin every config here would silently acquire
|
||||||
|
// stretch + fragmentation and every anchor check would fail for a configuration reason.
|
||||||
|
// → TerrainGenConfig.WithFamilyOff().
|
||||||
|
var c = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = size, Seed = seed, VariantLabel = label,
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
CoastShelf = false, Offshore = new OffshoreSettings(),
|
RegionLabeling = true,
|
||||||
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = speckFrac,
|
}.WithFamilyOff();
|
||||||
SouthStretch = st,
|
// …then this tool's swept axes, AFTER the pin. (These were already explicit before
|
||||||
FragmentAmp = amp, FragmentFreqPerMapWidth = fq, FragmentBitesOnly = bitesOnly,
|
// rivers/01; the pin makes the tool's independence from the defaults total rather than
|
||||||
};
|
// field-by-field, so a future default can never leak in through a field nobody listed.)
|
||||||
|
c.SpeckRevert = revert; c.MinLandComponentFrac = speckFrac;
|
||||||
|
c.SouthStretch = st;
|
||||||
|
c.FragmentAmp = amp; c.FragmentFreqPerMapWidth = fq; c.FragmentBitesOnly = bitesOnly;
|
||||||
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ PROBE ═══
|
// ═══ PROBE ═══
|
||||||
|
|
@ -186,21 +197,17 @@ namespace IslaApocalypse.Tools
|
||||||
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
|
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
|
||||||
Pass1Result p1 = Topography.Generate(offCfg);
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{seeds[0]}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{seeds[0]}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)",
|
||||||
|
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
// ⭐ a8 — frag OFF at the fixed stretch, revert OFF == the task-08 stretch-2 field (its dump at the plate size).
|
// ⚑ RETIRED at rivers/01 — a8, the frag-OFF baseline == `08_southern_stretch_explore`.
|
||||||
foreach (int seed in seeds)
|
// An EXPLORATION ladder, and off-shape: the 08 batch's dump is at stretch 3, the locked
|
||||||
{
|
// shape is stretch 2. Nothing should be pinned to a rung of a ladder that was climbed to
|
||||||
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_{t08Level}", "height.f32");
|
// find a value, then superseded by the value it found.
|
||||||
if (File.Exists(t08Dump) && mapSize == 4096)
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
{
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
var c8 = Cfg(mapSize, seed, "t08", 0f, freq, stretch, false);
|
|
||||||
Pass2Result q8 = Shaping.Shape(Topography.Generate(c8), c8);
|
|
||||||
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch {stretch:G3}, revert OFF == task-08 {t08Level} dump (the baseline) [{seed}]", q8.Height, HeightField.Load(t08Dump, mapSize), mapSize, t08Dump));
|
|
||||||
}
|
|
||||||
else GD.Print($" a8 [{seed}]: ⚠ skipped — {(mapSize != 4096 ? "map size is not 4096" : $"no 08 dump at {t08Dump}")}");
|
|
||||||
}
|
|
||||||
foreach (var c in hard) GD.Print(" " + c);
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +318,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -324,11 +331,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
|
/// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
|
||||||
/// ISLA_SHOWPIECE "0" to skip the big render
|
/// ISLA_SHOWPIECE "0" to skip the big render
|
||||||
/// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
|
/// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
|
||||||
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port")
|
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
|
||||||
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class CurveBaselineTool : Node
|
public partial class CurveBaselineTool : Node
|
||||||
|
|
@ -86,6 +86,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 1);
|
int task = EnvInt("ISLA_TASK", 1);
|
||||||
string descr = EnvStr("ISLA_BATCH", "curve_baseline");
|
string descr = EnvStr("ISLA_BATCH", "curve_baseline");
|
||||||
|
|
@ -94,7 +98,7 @@ namespace IslaApocalypse.Tools
|
||||||
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
||||||
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
||||||
bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
|
bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
|
||||||
// ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
|
// ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
|
||||||
|
|
@ -131,7 +135,7 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
foreach (int seed in seeds)
|
foreach (int seed in seeds)
|
||||||
{
|
{
|
||||||
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed };
|
var cfg = TerrainGenConfig.CalibrationPool(mapSize, seed);
|
||||||
Pass1Result p1 = Topography.Generate(cfg);
|
Pass1Result p1 = Topography.Generate(cfg);
|
||||||
pass1[seed] = p1;
|
pass1[seed] = p1;
|
||||||
rawPool.Accumulate(p1.Height, mapSize);
|
rawPool.Accumulate(p1.Height, mapSize);
|
||||||
|
|
@ -289,8 +293,16 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
// ---- configs --------------------------------------------------------
|
// ---- configs --------------------------------------------------------
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
|
||||||
|
/// and judged before the shape family existed, on the family-off distribution the knots are
|
||||||
|
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
|
||||||
|
/// keeps this tool measuring the thing it was written to measure.
|
||||||
|
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
|
||||||
|
/// </remarks>
|
||||||
private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
|
private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
|
||||||
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false };
|
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false }
|
||||||
|
.WithFamilyOff();
|
||||||
|
|
||||||
private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
|
private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
|
||||||
new TerrainGenConfig
|
new TerrainGenConfig
|
||||||
|
|
@ -301,7 +313,7 @@ namespace IslaApocalypse.Tools
|
||||||
// bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration;
|
// bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration;
|
||||||
// a control batch must not move with a default. (chat2/02.)
|
// a control batch must not move with a default. (chat2/02.)
|
||||||
CurveMode = CurveModeKind.Staircase,
|
CurveMode = CurveModeKind.Staircase,
|
||||||
};
|
}.WithFamilyOff(); // ⭐ rivers/01 — see OffConfig: the staircase control is pre-family too
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt
|
/// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_SEEDS variant seeds, comma-separated (default: the 2 pinned below)
|
/// ISLA_SEEDS variant seeds, comma-separated (default: the 2 pinned below)
|
||||||
/// ISLA_SHOWPIECE_SIZE the big confirmation render (default 8192)
|
/// ISLA_SHOWPIECE_SIZE the big confirmation render (default 8192)
|
||||||
/// ISLA_SHOWPIECE "0" to skip it
|
/// ISLA_SHOWPIECE "0" to skip it
|
||||||
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "02_pass1_port")
|
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
|
||||||
/// ISLA_T01_SOURCE batch holding task-01 .f32 (default "01_curve_baseline")
|
|
||||||
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
|
||||||
/// ISLA_CEILING_M probe override: lowland ceiling, metres (default 30)
|
/// ISLA_CEILING_M probe override: lowland ceiling, metres (default 30)
|
||||||
/// ISLA_FEATHER probe override: climb feather, 0..1 (default 0.4)
|
/// ISLA_FEATHER probe override: climb feather, 0..1 (default 0.4)
|
||||||
|
|
@ -91,6 +90,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 2);
|
int task = EnvInt("ISLA_TASK", 2);
|
||||||
string descr = EnvStr("ISLA_BATCH", "curve_continuous");
|
string descr = EnvStr("ISLA_BATCH", "curve_continuous");
|
||||||
|
|
@ -98,8 +101,7 @@ namespace IslaApocalypse.Tools
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
||||||
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t01Source = EnvStr("ISLA_T01_SOURCE", "01_curve_baseline");
|
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr); // composed, never free-form
|
string batchRoot = ToolingPaths.BatchRoot(task, descr); // composed, never free-form
|
||||||
|
|
@ -128,7 +130,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int seed in CalibrationSeeds)
|
foreach (int seed in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed));
|
||||||
pass1[seed] = p1;
|
pass1[seed] = p1;
|
||||||
rawPool.Accumulate(p1.Height, mapSize);
|
rawPool.Accumulate(p1.Height, mapSize);
|
||||||
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
|
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
|
||||||
|
|
@ -187,15 +189,20 @@ namespace IslaApocalypse.Tools
|
||||||
{
|
{
|
||||||
Pass1Result pp1 = pass1[primary];
|
Pass1Result pp1 = pass1[primary];
|
||||||
|
|
||||||
// (a1) curve off == Phase 1's own dump.
|
// (a1) curve off == Phase 1's own dump. ⭐ KEPT at rivers/01: the family-off pass-1 guard,
|
||||||
|
// the last link between today's generator and the Phase-1 port. The config is pinned
|
||||||
|
// family-off so it still means what it says. ⚠ A missing dump now THROWS.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
|
||||||
offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
|
offs[primary].Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, mapSize), mapSize, p1Dump));
|
||||||
|
|
||||||
// (a2) staircase == task 01's own dump — the control is the control.
|
// ⚑ RETIRED at rivers/01 — a2, the staircase == `01_curve_baseline` control.
|
||||||
string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
|
// The staircase curve is SUPERSEDED by the continuous grade (→ D-062). A control that
|
||||||
hard.Add(ShapingOracle.DumpRegression("a2", "staircase mode == task-01 curve_on .f32 dump",
|
// reproduces a curve nothing ships is scaffolding, and holding it green cost a
|
||||||
results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
|
// 4-variant batch run to prove a mode no design doc describes any more.
|
||||||
|
// The dump is NOT deleted (file-safety; it is regenerable and it is the record of what
|
||||||
|
// was judged); its `INDEX.md` is marked superseded. The check is gone so nothing can
|
||||||
|
// pass against a superseded baseline. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
|
|
||||||
// (b) classify == raw, every seed × every variant.
|
// (b) classify == raw, every seed × every variant.
|
||||||
long bFail = 0;
|
long bFail = 0;
|
||||||
|
|
@ -340,7 +347,11 @@ namespace IslaApocalypse.Tools
|
||||||
LowlandCeilingM = EnvFloat("ISLA_CEILING_M", 30f),
|
LowlandCeilingM = EnvFloat("ISLA_CEILING_M", 30f),
|
||||||
ClimbFeather = EnvFloat("ISLA_FEATHER", 0.4f),
|
ClimbFeather = EnvFloat("ISLA_FEATHER", 0.4f),
|
||||||
SummitDrama = EnvFloat("ISLA_DRAMA", 2.5f),
|
SummitDrama = EnvFloat("ISLA_DRAMA", 2.5f),
|
||||||
};
|
}
|
||||||
|
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/02 is CURVE development, measured on
|
||||||
|
// the family-off distribution the knots are percentiles of; the re-baseline flipped the bare
|
||||||
|
// defaults family-ON. → TerrainGenConfig.WithFamilyOff().
|
||||||
|
.WithFamilyOff();
|
||||||
|
|
||||||
// ---- output -----------------------------------------------------------
|
// ---- output -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,13 @@ namespace IslaApocalypse.Tools
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The candidates over a faint terrain.</summary>
|
/// <summary>
|
||||||
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
|
/// The faint grey terrain every overlay map is drawn on — ocean flat dark blue, enclosed
|
||||||
|
/// (non-ocean) water dark teal, land a shallow sqrt ramp. Factored out at rivers/02 so the
|
||||||
|
/// promotion maps sit on the SAME base as the chat2/12 candidates map and can be compared
|
||||||
|
/// without the eye correcting for two different backgrounds.
|
||||||
|
/// </summary>
|
||||||
|
public static Image TerrainBase(bool[] isOcean, float[,] render, int n, float sea, float hMax)
|
||||||
{
|
{
|
||||||
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
||||||
float span = MathF.Max(1e-6f, hMax - sea);
|
float span = MathF.Max(1e-6f, hMax - sea);
|
||||||
|
|
@ -62,6 +67,13 @@ namespace IslaApocalypse.Tools
|
||||||
float g = 0.30f + 0.45f * MathF.Sqrt(t);
|
float g = 0.30f + 0.45f * MathF.Sqrt(t);
|
||||||
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
|
img.SetPixel(x, y, new Color(g, g, g * 0.96f));
|
||||||
}
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The candidates over a faint terrain.</summary>
|
||||||
|
public static Image Candidates(DrainageAnalysis.Plan plan, bool[] isOcean, float[,] render, int n, float sea, float hMax, string title)
|
||||||
|
{
|
||||||
|
var img = TerrainBase(isOcean, render, n, sea, hMax);
|
||||||
|
|
||||||
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
|
int thick = n >= 4096 ? 5 : 3, thin = n >= 4096 ? 3 : 2, mark = n >= 4096 ? 18 : 10;
|
||||||
foreach (var g in plan.Giants)
|
foreach (var g in plan.Giants)
|
||||||
|
|
@ -90,13 +102,694 @@ namespace IslaApocalypse.Tools
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
|
// ═══ ⭐ THE PROMOTION MAPS (rivers/02) — the count decision, on the map ═══════════════════
|
||||||
|
//
|
||||||
|
// Two views, same base, same colour law:
|
||||||
|
// SEA-REACHING cyan (as chat2/12's trunks)
|
||||||
|
// ENDORHEIC orange (as chat2/12's giants)
|
||||||
|
// so a reader carrying chat2/12 in their eye reads these without relearning anything.
|
||||||
|
//
|
||||||
|
// ⚠⚠ NEITHER MAP DRAWS `Giant.ProvisionalRoute`. That steepest-descent placeholder — the
|
||||||
|
// visible "comb" of parallel threads on the flats — is rivers/03's job to replace, and drawing
|
||||||
|
// it here would make a count look like a river network it is not. What IS drawn is the REAL
|
||||||
|
// upland stem: the max-accumulation course traced through erosion-carved valleys.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE DIAGNOSTIC MAP — every candidate above the floor, marker AREA ∝ drainage area,
|
||||||
|
/// colour by terminus. Answers "where are the big drainages, and is the spread north/south?"
|
||||||
|
/// before any count is chosen.
|
||||||
|
///
|
||||||
|
/// ⚠ Marker radius scales as √area so the MARKER'S AREA is proportional to the drainage area —
|
||||||
|
/// scaling the radius linearly would exaggerate the big ones quadratically and make a knee look
|
||||||
|
/// like a cliff.
|
||||||
|
/// </summary>
|
||||||
|
public static Image PromotionCandidates(List<RiverCandidate> ranked, Image img, int n, string title, int[] ladder)
|
||||||
|
{
|
||||||
|
if (ranked.Count == 0) return img;
|
||||||
|
|
||||||
|
long maxArea = 1;
|
||||||
|
foreach (var c in ranked) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
|
||||||
|
float rMax = n >= 4096 ? 46f : 22f, rMin = n >= 4096 ? 6f : 3f;
|
||||||
|
int ringW = n >= 4096 ? 4 : 2;
|
||||||
|
|
||||||
|
// Draw smallest-first so a big marker never hides behind a small one.
|
||||||
|
for (int i = ranked.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var c = ranked[i];
|
||||||
|
float f = MathF.Sqrt((float)c.DrainagePx / maxArea); // area ∝ drainage
|
||||||
|
int r = (int)MathF.Round(rMin + (rMax - rMin) * f);
|
||||||
|
Color col = c.IsSea ? Trunk : Giant;
|
||||||
|
Disc(img, c.X, c.Y, r, n, col);
|
||||||
|
Ring(img, c.X, c.Y, r + ringW + 1, n, Ink, ringW); // ink halo: legible on any ground
|
||||||
|
}
|
||||||
|
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
int nSea = 0; foreach (var c in ranked) if (c.IsSea) nSea++;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"ALL {ranked.Count} CANDIDATES ABOVE THE FLOOR - MARKER AREA IS PROPORTIONAL TO DRAINAGE AREA", 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN: SEA-REACHING ({nSea}) ORANGE: ENDORHEIC ({ranked.Count - nSea}) - AN INLAND TERMINUS IS A PASS, NOT A FALLBACK", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"NOTHING IS PROMOTED HERE - THIS IS THE DISTRIBUTION THE COUNT ({Join(ladder)}) IS CHOSEN FROM", 12, 12 + lh * 3, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE A/B PLATE — the unified top-N promoted, real upland stems, width ∝ drainage area,
|
||||||
|
/// terminus markers coloured by type. One plate per N; the developer picks by comparing them.
|
||||||
|
/// </summary>
|
||||||
|
public static Image PromotedRivers(List<RiverCandidate> promoted, Image img, int n,
|
||||||
|
int nPromoted, long floorPx, string title)
|
||||||
|
{
|
||||||
|
if (promoted.Count == 0) return img;
|
||||||
|
|
||||||
|
long maxArea = 1;
|
||||||
|
foreach (var c in promoted) if (c.DrainagePx > maxArea) maxArea = c.DrainagePx;
|
||||||
|
float wMax = n >= 4096 ? 11f : 6f, wMin = n >= 4096 ? 3f : 2f;
|
||||||
|
int mark = n >= 4096 ? 18 : 10;
|
||||||
|
|
||||||
|
// Smallest first, so the biggest rivers finish on top.
|
||||||
|
for (int i = promoted.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var c = promoted[i];
|
||||||
|
if (c.Course == null || c.Course.Count < 2) continue;
|
||||||
|
float f = MathF.Sqrt((float)c.DrainagePx / maxArea);
|
||||||
|
int w = (int)MathF.Round(wMin + (wMax - wMin) * f);
|
||||||
|
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, w);
|
||||||
|
}
|
||||||
|
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT at the basin's deepest cell —
|
||||||
|
// on a flat basin floor those differ, and marking the deepest cell draws the stem visibly
|
||||||
|
// detached from its own endpoint. → RiverCandidate.TermX.
|
||||||
|
foreach (var c in promoted)
|
||||||
|
{
|
||||||
|
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||||
|
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
int nSea = 0; foreach (var c in promoted) if (c.IsSea) nSea++;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"UNIFIED TOP {nPromoted} BY DRAINAGE AREA - THE SEA/ENDORHEIC SPLIT FELL OUT, IT WAS NOT QUOTA'D", 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}) ORANGE DISC: ENDORHEIC TERMINUS ({promoted.Count - nSea}) STEM WIDTH IS PROPORTIONAL TO DRAINAGE", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"REAL UPLAND STEMS ONLY - NO LOWLAND ROUTING, NO WATER, NOTHING CARVED (FLOOR {floorPx:N0} PX)", 12, 12 + lh * 3, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ THE COMPOSITION PLATE (rivers/02b) — pure ranking vs a gameplay sea-river floor ═══
|
||||||
|
//
|
||||||
|
// rivers/02 established that the count is a DESIGN choice (the distribution is a power law) and
|
||||||
|
// that this terrain's honest top-of-distribution is INLAND-DOMINANT. rivers/02b keeps the total
|
||||||
|
// fixed and asks one question the developer stated: **do 3 FORCED sea rivers read as real
|
||||||
|
// rivers, just smaller — or as sad thin threads beside the big inland ones?**
|
||||||
|
//
|
||||||
|
// ⚠⚠ THAT QUESTION CANNOT BE ASKED ON A PER-PLATE-NORMALISED PLATE, and `PromotedRivers` above
|
||||||
|
// normalises to the widest river ON ITS OWN PLATE. Under that law the quota plate would rescale
|
||||||
|
// itself around whatever it happens to contain, so a forced sea river drawn "thin" would be
|
||||||
|
// reporting the plate's contents, not the river's size — and drawn beside a plate that rescaled
|
||||||
|
// differently, the comparison is meaningless. THE ONE THING THIS PLATE MUST NOT DO.
|
||||||
|
//
|
||||||
|
// So the composition plates use ONE ABSOLUTE width→drainage constant, below, shared by both
|
||||||
|
// compositions and all four seeds. A thin river is thin because it IS smaller. The constant is
|
||||||
|
// printed on every plate and reported in the INDEX, so a reader can check the claim.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE FIXED WIDTH→DRAINAGE CONSTANT — stem width in px per √(drainage px).
|
||||||
|
///
|
||||||
|
/// <c>1/180</c>. Chosen once, from the measured population rather than per plate: the largest
|
||||||
|
/// candidate on ANY of the eight gallery seeds is 4,474,342 px (seed `17320508`), whose √ is
|
||||||
|
/// 2,115 — so <c>2115/180 ≈ 11.8</c> lands the biggest drainage the terrain produces just under
|
||||||
|
/// the 16 px ceiling, with no clipping anywhere in the population and headroom left over.
|
||||||
|
///
|
||||||
|
/// ⭐ THE LAW IS SCALE-FREE WITH NO MAP-SIZE TERM IN IT, and that is not an oversight. Drainage
|
||||||
|
/// area scales as n², so √(drainage) scales as n — meaning <c>k·√area</c> already draws a stem
|
||||||
|
/// at the same FRACTION of the map at any size. Multiplying by n/8192 on top would make width
|
||||||
|
/// scale as n² and collapse every river onto the floor on a smaller smoke.
|
||||||
|
/// *(rivers/02 established this analysis is only valid at 8192 regardless — the params are
|
||||||
|
/// absolute pixel counts — so a smaller render is a pipeline check, never a comparison.)*
|
||||||
|
/// </summary>
|
||||||
|
public const float StemWidthPerSqrtPx = 1f / 180f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legibility clamp on the fixed law. ⚠ The MINIMUM is a deliberate, reported distortion: a
|
||||||
|
/// 1 px line at 8192 is invisible at any zoom a person actually looks at a plate with, so the
|
||||||
|
/// smallest rivers are drawn at 2 px rather than truthfully thinner. Any river AT the floor is
|
||||||
|
/// therefore "at least this thin, possibly thinner" — which matters here, because the floor is
|
||||||
|
/// exactly where the "thread" verdict lives. <see cref="StemWidthAtFloor"/> reports whether any
|
||||||
|
/// drawn river hit it, so the plate never quietly flatters a thread.
|
||||||
|
/// </summary>
|
||||||
|
public const int StemWidthMinPx = 2;
|
||||||
|
public const int StemWidthMaxPx = 16;
|
||||||
|
|
||||||
|
/// <summary>The fixed law, evaluated. NEVER normalised against the plate's own contents.</summary>
|
||||||
|
public static int StemWidthFixed(long drainagePx)
|
||||||
|
{
|
||||||
|
int w = (int)MathF.Round(MathF.Sqrt(MathF.Max(0f, drainagePx)) * StemWidthPerSqrtPx);
|
||||||
|
return Math.Clamp(w, StemWidthMinPx, StemWidthMaxPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when this river is drawn at the legibility floor, i.e. no thinner than shown.</summary>
|
||||||
|
public static bool StemWidthAtFloor(long drainagePx) => StemWidthFixed(drainagePx) <= StemWidthMinPx;
|
||||||
|
|
||||||
|
/// <summary>The law as printed on the plate and in the INDEX — the constant is auditable, not implied.</summary>
|
||||||
|
public static string StemWidthLaw() =>
|
||||||
|
$"W PX = CLAMP(ROUND(SQRT(DRAINAGE PX) X {StemWidthPerSqrtPx:F6}), {StemWidthMinPx}, {StemWidthMaxPx})";
|
||||||
|
|
||||||
|
/// <summary>Compact drainage label: 2.33M / 736K / 4210 — the font has no lowercase.</summary>
|
||||||
|
public static string DrainageLabel(long px) =>
|
||||||
|
px >= 1_000_000 ? $"{px / 1e6:F2}M" : px >= 1_000 ? $"{(long)Math.Round(px / 1000.0)}K" : px.ToString();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ ONE COMPOSITION OF N RIVERS, on the shared faint base, at the FIXED width scale, with
|
||||||
|
/// per-river size labels.
|
||||||
|
///
|
||||||
|
/// Identical in style to <see cref="PromotedRivers"/> — same colours (cyan sea / orange
|
||||||
|
/// endorheic), same real upland stems, same terminus markers, `Giant.ProvisionalRoute` still
|
||||||
|
/// never drawn — and differs in exactly the two ways rivers/02b needs:
|
||||||
|
///
|
||||||
|
/// 1. THE FIXED WIDTH SCALE above, instead of per-plate normalisation.
|
||||||
|
/// 2. PER-RIVER LABELS: drainage area and rank in the FULL candidate distribution, so
|
||||||
|
/// "real river vs thin thread" has numbers behind the eyeball. A forced sea river reading
|
||||||
|
/// `272K R29` beside an inland `2.33M R1` tells the story before the eye does.
|
||||||
|
///
|
||||||
|
/// ⚠ Labels are placed with greedy collision avoidance against already-placed labels, on a dark
|
||||||
|
/// backing box so they are legible over both bright terrain and dark ocean. A label that cannot
|
||||||
|
/// be placed clear of the others is DROPPED rather than drawn illegibly on top of one — and the
|
||||||
|
/// legend says how many were dropped, so a missing number is never silent.
|
||||||
|
/// </summary>
|
||||||
|
public static Image RiverComposition(List<RiverCandidate> promoted, Image img, int n,
|
||||||
|
string title, string compositionLine, int candidateCount, long floorPx, bool labelAll)
|
||||||
|
{
|
||||||
|
if (promoted.Count == 0) return img;
|
||||||
|
|
||||||
|
int mark = n >= 4096 ? 18 : 10;
|
||||||
|
|
||||||
|
// Smallest first, so the biggest rivers finish on top.
|
||||||
|
var byArea = new List<RiverCandidate>(promoted);
|
||||||
|
byArea.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||||||
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var c = byArea[i];
|
||||||
|
if (c.Course == null || c.Course.Count < 2) continue;
|
||||||
|
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, StemWidthFixed(c.DrainagePx));
|
||||||
|
}
|
||||||
|
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT the basin's deepest cell —
|
||||||
|
// they differ on a flat basin floor. → RiverCandidate.TermX.
|
||||||
|
foreach (var c in byArea)
|
||||||
|
{
|
||||||
|
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||||
|
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the labels ----
|
||||||
|
// Which rivers get one: all of them when the plate can carry it, otherwise the 3 the
|
||||||
|
// judgment turns on — every sea river — plus the largest inland, for scale.
|
||||||
|
var toLabel = new List<RiverCandidate>();
|
||||||
|
if (labelAll) toLabel.AddRange(byArea);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var c in byArea) if (c.IsSea) toLabel.Add(c);
|
||||||
|
foreach (var c in byArea) if (!c.IsSea) { toLabel.Add(c); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
int ls = n >= 4096 ? 4 : 3;
|
||||||
|
var placer = new LabelPlacer(n, ls, headerLines: 7);
|
||||||
|
int dropped = 0;
|
||||||
|
foreach (var c in toLabel)
|
||||||
|
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank}", c.TermX, c.TermY, mark,
|
||||||
|
c.IsSea ? Trunk : Giant)) dropped++;
|
||||||
|
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
int nSea = 0; long seaPx = 0, endoPx = 0;
|
||||||
|
int atFloor = 0;
|
||||||
|
foreach (var c in byArea)
|
||||||
|
{
|
||||||
|
if (c.IsSea) { nSea++; seaPx += c.DrainagePx; } else endoPx += c.DrainagePx;
|
||||||
|
if (StemWidthAtFloor(c.DrainagePx)) atFloor++;
|
||||||
|
}
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, compositionLine, 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}, {seaPx:N0} PX) ORANGE DISC: ENDORHEIC TERMINUS ({byArea.Count - nSea}, {endoPx:N0} PX)", 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"FIXED SHARED WIDTH SCALE: {StemWidthLaw()} - THE SAME CONSTANT ON EVERY PLATE AND EVERY SEED, NEVER PER-PLATE", 12, 12 + lh * 3, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"SO A THIN RIVER IS THIN BECAUSE IT IS SMALLER" + (atFloor > 0 ? $" - {atFloor} RIVER(S) AT THE {StemWidthMinPx} PX LEGIBILITY FLOOR: NO THINNER THAN DRAWN" : ""), 12, 12 + lh * 4, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"LABEL: DRAINAGE AREA THEN R = RANK AMONG ALL {candidateCount} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR" + (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED, NO CLEAR SPACE)" : ""), 12, 12 + lh * 5, s, Ink);
|
||||||
|
TinyFont.Draw(img, "REAL UPLAND STEMS ONLY - GIANT.PROVISIONALROUTE (THE COMB) NOT DRAWN - NO ROUTING, NO WATER, NOTHING CARVED", 12, 12 + lh * 6, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillRect(Image img, Rect2I r, Color c, int n)
|
||||||
|
{
|
||||||
|
for (int x = r.Position.X; x < r.Position.X + r.Size.X; x++)
|
||||||
|
for (int y = r.Position.Y; y < r.Position.Y + r.Size.Y; y++)
|
||||||
|
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Greedy non-overlapping label placement on a dark backing box, so a number is legible over
|
||||||
|
/// both bright terrain and dark ocean. A label that cannot be placed clear of the others is
|
||||||
|
/// DROPPED rather than drawn illegibly on top of one — and every caller reports how many, so a
|
||||||
|
/// missing number is never silent. Shared by the composition and routed-mix plates.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LabelPlacer
|
||||||
|
{
|
||||||
|
private readonly List<Rect2I> _placed = new();
|
||||||
|
private readonly int _n, _scale, _pad;
|
||||||
|
|
||||||
|
public LabelPlacer(int n, int scale, int headerLines)
|
||||||
|
{
|
||||||
|
_n = n; _scale = scale; _pad = 4 * (scale >= 4 ? 2 : 1);
|
||||||
|
// Reserve the legend block so a river label never lands under the header text.
|
||||||
|
_placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(scale) + 6) * headerLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Place(Image img, string txt, int atX, int atY, int mark, Color ink)
|
||||||
|
{
|
||||||
|
int w = TinyFont.Width(txt, _scale), h = TinyFont.Height(_scale);
|
||||||
|
int gap = mark + 10;
|
||||||
|
// right, left, below, above, then pushed further out — first clear slot wins.
|
||||||
|
var tries = new (int x, int y)[]
|
||||||
|
{
|
||||||
|
(atX + gap, atY - h / 2),
|
||||||
|
(atX - gap - w, atY - h / 2),
|
||||||
|
(atX - w / 2, atY + gap),
|
||||||
|
(atX - w / 2, atY - gap - h),
|
||||||
|
(atX + gap * 2 + w / 2, atY - h / 2),
|
||||||
|
(atX - gap * 2 - w - w / 2, atY - h / 2),
|
||||||
|
(atX - w / 2, atY + gap * 2 + h),
|
||||||
|
(atX - w / 2, atY - gap * 2 - h * 2),
|
||||||
|
};
|
||||||
|
foreach (var (tx, ty) in tries)
|
||||||
|
{
|
||||||
|
int bx = Math.Clamp(tx - _pad, 0, Math.Max(0, _n - (w + _pad * 2)));
|
||||||
|
int by = Math.Clamp(ty - _pad, 0, Math.Max(0, _n - (h + _pad * 2)));
|
||||||
|
var box = new Rect2I(bx, by, w + _pad * 2, h + _pad * 2);
|
||||||
|
bool hit = false;
|
||||||
|
foreach (var q in _placed) if (box.Intersects(q)) { hit = true; break; }
|
||||||
|
if (hit) continue;
|
||||||
|
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), _n);
|
||||||
|
TinyFont.Draw(img, txt, bx + _pad, by + _pad, _scale, ink);
|
||||||
|
_placed.Add(box);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ THE ROUTED MIX (rivers/03) — three classes, and where routing added the channel ═══
|
||||||
|
|
||||||
|
/// <summary>Routed giants: the natural upland stem, muted.</summary>
|
||||||
|
private static readonly Color RoutedStem = new(0.250f, 0.620f, 0.330f);
|
||||||
|
/// <summary>⭐ The LOWLAND REACH routing added — bright, so the added channel is unmistakable.</summary>
|
||||||
|
private static readonly Color RoutedReach = new(0.380f, 1.000f, 0.420f);
|
||||||
|
/// <summary>The rim the route climbed over — the point the developer is asked to judge.</summary>
|
||||||
|
private static readonly Color RimMark = new(1.000f, 0.930f, 0.350f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE MIX PLATE — natural ocean trunks, routed-through giants, and inland lake-enders, on
|
||||||
|
/// the shared faint base at rivers/02b's FIXED width scale.
|
||||||
|
///
|
||||||
|
/// The one thing this plate exists to show: **which part of a routed river is terrain and which
|
||||||
|
/// part is routing.** So a routed giant is drawn in two tones of one colour — its erosion-carved
|
||||||
|
/// upland stem muted, the lowland reach the Dijkstra added bright — and the point where that
|
||||||
|
/// reach crosses its rim is ringed. A reader can then see, without reading a table, how far the
|
||||||
|
/// river was carried and how high it had to climb to get there.
|
||||||
|
///
|
||||||
|
/// ⚠⚠ `Giant.ProvisionalRoute` is NOT drawn — the real route is what replaces it.
|
||||||
|
/// </summary>
|
||||||
|
public static Image RoutedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
|
||||||
|
string title, string subtitle, long floorPx)
|
||||||
|
{
|
||||||
|
if (rivers.Count == 0) return img;
|
||||||
|
|
||||||
|
int mark = n >= 4096 ? 18 : 10;
|
||||||
|
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
|
||||||
|
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||||||
|
|
||||||
|
// Smallest first, so the biggest rivers finish on top.
|
||||||
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var r = byArea[i];
|
||||||
|
int w = StemWidthFixed(r.Candidate.DrainagePx);
|
||||||
|
Color stemCol = r.Class switch
|
||||||
|
{
|
||||||
|
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
||||||
|
RiverRouting.RiverClass.RoutedGiant => RoutedStem,
|
||||||
|
_ => Giant,
|
||||||
|
};
|
||||||
|
// The upland stem, as erosion made it (head → terminal), reversed out of the analysis.
|
||||||
|
var stem = new List<(float x, float y)>(r.Candidate.Course);
|
||||||
|
stem.Reverse();
|
||||||
|
Polyline(img, stem, n, stemCol, w);
|
||||||
|
|
||||||
|
// The lowland reach routing added, drawn distinctly on top of its own stem.
|
||||||
|
if (r.Lowland != null && r.Lowland.Smoothed != null && r.Lowland.Smoothed.Count > 1)
|
||||||
|
{
|
||||||
|
Color reachCol = r.Class == RiverRouting.RiverClass.RoutedGiant ? RoutedReach : Giant;
|
||||||
|
Polyline(img, r.Lowland.Smoothed, n, reachCol, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminus markers, and the rim a routed river crossed.
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
var c = r.Candidate;
|
||||||
|
switch (r.Class)
|
||||||
|
{
|
||||||
|
case RiverRouting.RiverClass.OceanTrunk:
|
||||||
|
Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||||
|
break;
|
||||||
|
case RiverRouting.RiverClass.RoutedGiant:
|
||||||
|
if (r.Lowland != null && r.Lowland.Reached)
|
||||||
|
{
|
||||||
|
var t = r.Lowland.Target;
|
||||||
|
Square(img, (int)t.x, (int)t.y, mark, n, RoutedReach);
|
||||||
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
||||||
|
MarkRim(img, r.Lowland, n, mark);
|
||||||
|
}
|
||||||
|
// The basin it came FROM stays marked, so the reader sees what was connected.
|
||||||
|
Ring(img, c.TermX, c.TermY, mark, n, RoutedStem, 4);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Disc(img, c.TermX, c.TermY, mark, n, Giant);
|
||||||
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
||||||
|
if (r.Lowland != null && r.Lowland.Reached)
|
||||||
|
{
|
||||||
|
var t = r.Lowland.Target;
|
||||||
|
Ring(img, (int)t.x, (int)t.y, mark, n, Giant, 4);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- labels ----
|
||||||
|
int ls = n >= 4096 ? 4 : 3;
|
||||||
|
var placer = new LabelPlacer(n, ls, headerLines: 8);
|
||||||
|
int dropped = 0;
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
var c = r.Candidate;
|
||||||
|
string txt = r.Class switch
|
||||||
|
{
|
||||||
|
RiverRouting.RiverClass.OceanTrunk => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} TRUNK",
|
||||||
|
RiverRouting.RiverClass.RoutedGiant => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
|
||||||
|
_ => $"{DrainageLabel(c.DrainagePx)} R{c.Rank} LAKE",
|
||||||
|
};
|
||||||
|
Color ink = r.Class switch
|
||||||
|
{
|
||||||
|
RiverRouting.RiverClass.OceanTrunk => Trunk,
|
||||||
|
RiverRouting.RiverClass.RoutedGiant => RoutedReach,
|
||||||
|
_ => Giant,
|
||||||
|
};
|
||||||
|
if (!placer.Place(img, txt, c.TermX, c.TermY, mark, ink)) dropped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int trunks = 0, routed = 0, lakes = 0;
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
if (r.Class == RiverRouting.RiverClass.OceanTrunk) trunks++;
|
||||||
|
else if (r.Class == RiverRouting.RiverClass.RoutedGiant) routed++;
|
||||||
|
else lakes++;
|
||||||
|
}
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, subtitle, 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) - EROSION ALREADY REACHES THE COAST, NO LOWLAND ROUTE ADDED", 12, 12 + lh * 2, s, Trunk);
|
||||||
|
TinyFont.Draw(img, $"GREEN: ROUTED-THROUGH GIANT ({routed}) - DARK = ITS NATURAL UPLAND STEM, BRIGHT = THE LOWLAND REACH ROUTING ADDED", 12, 12 + lh * 3, s, RoutedReach);
|
||||||
|
TinyFont.Draw(img, $"YELLOW RING ON A GREEN REACH = THE RIM IT CLIMBED OVER (ROUTE HIGH POINT). LABEL RIM = METRES CLIMBED FROM THE BASIN", 12, 12 + lh * 4, s, RimMark);
|
||||||
|
TinyFont.Draw(img, $"ORANGE: INLAND LAKE-ENDER ({lakes}) - DISC = ITS TERMINAL, RING = THE SIGNIFICANT WATER BODY IT JOINS", 12, 12 + lh * 5, s, Giant);
|
||||||
|
TinyFont.Draw(img, $"WIDTH: {StemWidthLaw()} - THE SAME FIXED CONSTANT AS RIVERS/02B, EVERY PLATE AND SEED", 12, 12 + lh * 6, s, Ink);
|
||||||
|
TinyFont.Draw(img, $"COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED, NOTHING CARVED. PROVISIONALROUTE (THE COMB) NOT DRAWN." +
|
||||||
|
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ THE REFINED MIX (rivers/03b) — five classes, and the dendritic tree ═══════════════
|
||||||
|
//
|
||||||
|
// Same base, same colours where they carry over, and the SAME fixed width scale as rivers/02b
|
||||||
|
// and rivers/03, so this plate can be laid beside `03_lowland_routing/<seed>/routed_mix.png` and
|
||||||
|
// read as a before/after rather than as two different pictures.
|
||||||
|
//
|
||||||
|
// `RoutedMix` above is left exactly as rivers/03 produced it — that batch stays reproducible.
|
||||||
|
|
||||||
|
/// <summary>⭐ rivers/03b: a router that stopped at a significant lake instead of skirting it.</summary>
|
||||||
|
private static readonly Color LakeFedStem = new(0.520f, 0.380f, 0.780f);
|
||||||
|
private static readonly Color LakeFedReach = new(0.720f, 0.560f, 1.000f);
|
||||||
|
/// <summary>⚠ rivers/03b: refused by the rim cap — it would have been an uphill river.</summary>
|
||||||
|
private static readonly Color Walled = new(0.950f, 0.330f, 0.330f);
|
||||||
|
/// <summary>Where two courses actually meet.</summary>
|
||||||
|
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
|
||||||
|
|
||||||
|
private static (Color stem, Color reach) ClassColours(RiverRouting.RiverClass c) => c switch
|
||||||
|
{
|
||||||
|
RiverRouting.RiverClass.OceanTrunk => (Trunk, Trunk),
|
||||||
|
RiverRouting.RiverClass.RoutedGiant => (RoutedStem, RoutedReach),
|
||||||
|
RiverRouting.RiverClass.LakeFed => (LakeFedStem, LakeFedReach),
|
||||||
|
RiverRouting.RiverClass.WalledOff => (Walled, Walled),
|
||||||
|
_ => (Giant, Giant),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE RESHAPED MIX — natural trunks, routed-through, lake-fed, natural lake-enders and
|
||||||
|
/// walled-off lake-enders, drawn as a dendritic TREE rather than as independent courses.
|
||||||
|
///
|
||||||
|
/// Each river draws only its OWN reach — truncated at its confluence junction if it joined one —
|
||||||
|
/// so tributaries merge into a single downstream line instead of running as parallel duplicates.
|
||||||
|
/// A white dot marks every junction. Within a river, the natural upland stem is drawn in the
|
||||||
|
/// muted tone and the lowland reach routing added in the bright one, exactly as rivers/03.
|
||||||
|
/// </summary>
|
||||||
|
public static Image RefinedMix(List<RiverRouting.RoutedRiver> rivers, Image img, int n,
|
||||||
|
string title, string subtitle, string capLine)
|
||||||
|
{
|
||||||
|
if (rivers.Count == 0) return img;
|
||||||
|
int mark = n >= 4096 ? 18 : 10;
|
||||||
|
|
||||||
|
var byArea = new List<RiverRouting.RoutedRiver>(rivers);
|
||||||
|
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||||||
|
|
||||||
|
// Smallest first, so the biggest rivers finish on top.
|
||||||
|
for (int i = byArea.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var r = byArea[i];
|
||||||
|
int w = StemWidthFixed(r.Candidate.DrainagePx);
|
||||||
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
||||||
|
var cells = r.CellPath;
|
||||||
|
if (cells == null || cells.Count == 0) continue;
|
||||||
|
|
||||||
|
// Its OWN reach: everything up to the junction, or the whole course if it kept its mouth.
|
||||||
|
int own = r.Joined ? OwnLength(r) : cells.Count;
|
||||||
|
int stemEnd = Math.Min(own, Math.Max(1, r.StemCells));
|
||||||
|
|
||||||
|
Polyline(img, Slice(cells, 0, stemEnd), n, stemCol, w);
|
||||||
|
if (own > stemEnd) Polyline(img, Slice(cells, stemEnd - 1, own), n, reachCol, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminus markers — read through the CONFLUENCE ROOT, because a tributary's mouth is its
|
||||||
|
// trunk's mouth and marking its own truncated end would invent a terminus it does not have.
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
var c = r.Candidate;
|
||||||
|
if (r.Joined)
|
||||||
|
{
|
||||||
|
Disc(img, r.JunctionCell.x, r.JunctionCell.y, Math.Max(4, mark / 2), n, Junction);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
||||||
|
switch (r.Class)
|
||||||
|
{
|
||||||
|
case RiverRouting.RiverClass.OceanTrunk:
|
||||||
|
Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||||
|
break;
|
||||||
|
case RiverRouting.RiverClass.RoutedGiant:
|
||||||
|
if (r.Lowland != null && r.Lowland.Reached)
|
||||||
|
{
|
||||||
|
var t = r.Lowland.Target;
|
||||||
|
Square(img, (int)t.x, (int)t.y, mark, n, reachCol);
|
||||||
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
||||||
|
MarkRim(img, r.Lowland, n, mark);
|
||||||
|
}
|
||||||
|
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
|
||||||
|
break;
|
||||||
|
case RiverRouting.RiverClass.LakeFed:
|
||||||
|
if (r.Lowland != null && r.Lowland.Reached)
|
||||||
|
{
|
||||||
|
var t = r.Lowland.Target;
|
||||||
|
Disc(img, (int)t.x, (int)t.y, mark, n, reachCol);
|
||||||
|
Ring(img, (int)t.x, (int)t.y, mark + 8, n, Ink, 3);
|
||||||
|
}
|
||||||
|
Ring(img, c.TermX, c.TermY, mark, n, stemCol, 4);
|
||||||
|
break;
|
||||||
|
case RiverRouting.RiverClass.WalledOff:
|
||||||
|
// ⚠ It ends at its own terminal. A cross-less ring plus the rim it could not clear.
|
||||||
|
Disc(img, c.TermX, c.TermY, mark, n, Walled);
|
||||||
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Disc(img, c.TermX, c.TermY, mark, n, Giant);
|
||||||
|
Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3);
|
||||||
|
if (r.Lowland != null && r.Lowland.Reached)
|
||||||
|
Ring(img, (int)r.Lowland.Target.x, (int)r.Lowland.Target.y, mark, n, Giant, 4);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- labels ----
|
||||||
|
int ls = n >= 4096 ? 4 : 3;
|
||||||
|
var placer = new LabelPlacer(n, ls, headerLines: 9);
|
||||||
|
int dropped = 0;
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
var c = r.Candidate;
|
||||||
|
var (stemCol, reachCol) = ClassColours(r.Class);
|
||||||
|
string tag = r.Class switch
|
||||||
|
{
|
||||||
|
RiverRouting.RiverClass.OceanTrunk => "TRUNK",
|
||||||
|
RiverRouting.RiverClass.RoutedGiant => $"SEA RIM {(r.Lowland != null ? r.Lowland.RimClimbM : 0f):F0}M",
|
||||||
|
RiverRouting.RiverClass.LakeFed => "LAKE-FED",
|
||||||
|
RiverRouting.RiverClass.WalledOff => $"WALLED {r.CappedRimM:F0}M",
|
||||||
|
_ => "LAKE",
|
||||||
|
};
|
||||||
|
if (r.Joined) tag += $" INTO R{r.ConfluenceParentRank}";
|
||||||
|
int lx = r.Joined ? r.JunctionCell.x : c.TermX;
|
||||||
|
int ly = r.Joined ? r.JunctionCell.y : c.TermY;
|
||||||
|
if (!placer.Place(img, $"{DrainageLabel(c.DrainagePx)} R{c.Rank} {tag}", lx, ly, mark,
|
||||||
|
r.Joined ? Junction : reachCol)) dropped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int trunks = 0, routed = 0, lakeFed = 0, natural = 0, walled = 0, joined = 0;
|
||||||
|
foreach (var r in byArea)
|
||||||
|
{
|
||||||
|
switch (r.Class)
|
||||||
|
{
|
||||||
|
case RiverRouting.RiverClass.OceanTrunk: trunks++; break;
|
||||||
|
case RiverRouting.RiverClass.RoutedGiant: routed++; break;
|
||||||
|
case RiverRouting.RiverClass.LakeFed: lakeFed++; break;
|
||||||
|
case RiverRouting.RiverClass.WalledOff: walled++; break;
|
||||||
|
default: natural++; break;
|
||||||
|
}
|
||||||
|
if (r.Joined) joined++;
|
||||||
|
}
|
||||||
|
int s2 = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s2) + 6;
|
||||||
|
TinyFont.Draw(img, title, 12, 12, s2, Ink);
|
||||||
|
TinyFont.Draw(img, subtitle, 12, 12 + lh, s2, Ink);
|
||||||
|
TinyFont.Draw(img, $"CYAN: NATURAL OCEAN TRUNK ({trunks}) GREEN: ROUTED THROUGH TO THE SEA ({routed}) - DARK = NATURAL STEM, BRIGHT = THE REACH ROUTING ADDED", 12, 12 + lh * 2, s2, Trunk);
|
||||||
|
TinyFont.Draw(img, $"VIOLET: LAKE-FED ({lakeFed}) - A DRY BASIN THAT MET A SIGNIFICANT LAKE BEFORE THE SEA AND STOPS THERE (FIX 3)", 12, 12 + lh * 3, s2, LakeFedReach);
|
||||||
|
TinyFont.Draw(img, $"RED: WALLED OFF ({walled}) - {capLine} (FIX 1)", 12, 12 + lh * 4, s2, Walled);
|
||||||
|
TinyFont.Draw(img, $"ORANGE: NATURAL LAKE-ENDER ({natural}) - ITS BASIN ALREADY HOLDS A LAKE, SO ITS RIVER FEEDS IT", 12, 12 + lh * 5, s2, Giant);
|
||||||
|
TinyFont.Draw(img, $"WHITE DOT: CONFLUENCE ({joined} JOINED) - A TRIBUTARY MERGING INTO A BIGGER RIVER, NOT A PARALLEL DUPLICATE (FIX 2)", 12, 12 + lh * 6, s2, Junction);
|
||||||
|
TinyFont.Draw(img, $"YELLOW RING = THE RIM A ROUTED RIVER CLIMBED OVER. WIDTH: {StemWidthLaw()} - AS RIVERS/02B AND 03", 12, 12 + lh * 7, s2, RimMark);
|
||||||
|
TinyFont.Draw(img, "COURSES ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NOTHING CARVED. PROVISIONALROUTE NOT DRAWN." +
|
||||||
|
(dropped > 0 ? $" ({dropped} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 8, s2, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many leading cells of a joined river's path are its own, up to the junction.</summary>
|
||||||
|
private static int OwnLength(RiverRouting.RoutedRiver r)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < r.CellPath.Count; i++)
|
||||||
|
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
|
||||||
|
return r.CellPath.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(float x, float y)> Slice(List<(int x, int y)> cells, int from, int to)
|
||||||
|
{
|
||||||
|
var outp = new List<(float x, float y)>();
|
||||||
|
for (int i = Math.Max(0, from); i < Math.Min(to, cells.Count); i++) outp.Add((cells[i].x, cells[i].y));
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ring the route's high point — the rim the channel crosses.</summary>
|
||||||
|
private static void MarkRim(Image img, RiverRouting.Route route, int n, int mark)
|
||||||
|
{
|
||||||
|
if (route.Path == null || route.Path.Count < 2 || route.RimClimbM <= 0.01f) return;
|
||||||
|
var p = route.RimPoint;
|
||||||
|
Ring(img, (int)p.x, (int)p.y, mark - 4, n, RimMark, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
|
||||||
|
/// counts marked vertically and the analysis's own thresholds marked horizontally.
|
||||||
|
///
|
||||||
|
/// **Log y is not a presentation choice, it is the only honest one:** drainage areas span three
|
||||||
|
/// or more orders of magnitude, so on a linear axis every candidate but the top two or three
|
||||||
|
/// collapses onto the floor and the knee — the thing this plot exists to show — is invisible.
|
||||||
|
/// </summary>
|
||||||
|
public static Image Distribution(List<RiverCandidate> ranked, int[] ladder,
|
||||||
|
long endorheicMinInflowPx, long stemMinAccPx, long floorPx, string title)
|
||||||
|
{
|
||||||
|
const int W = 1600, H = 1000, L = 150, R = 40, T = 120, B = 90;
|
||||||
|
var img = Image.CreateEmpty(W, H, false, Image.Format.Rgb8);
|
||||||
|
var bg = new Color(0.07f, 0.08f, 0.10f);
|
||||||
|
for (int x = 0; x < W; x++) for (int y = 0; y < H; y++) img.SetPixel(x, y, bg);
|
||||||
|
if (ranked.Count == 0) return img;
|
||||||
|
|
||||||
|
double loMin = Math.Log10(Math.Max(1.0, Math.Min(floorPx, ranked[ranked.Count - 1].DrainagePx)));
|
||||||
|
double hiMax = Math.Log10(Math.Max(10.0, ranked[0].DrainagePx));
|
||||||
|
loMin = Math.Floor(loMin); hiMax = Math.Ceiling(hiMax);
|
||||||
|
int plotW = W - L - R, plotH = H - T - B;
|
||||||
|
int XOf(int rank) => L + (int)((rank - 1) / (double)Math.Max(1, ranked.Count - 1) * plotW);
|
||||||
|
int YOf(double area) => T + plotH - (int)((Math.Log10(Math.Max(1.0, area)) - loMin) / Math.Max(1e-9, hiMax - loMin) * plotH);
|
||||||
|
|
||||||
|
var grid = new Color(0.16f, 0.18f, 0.22f);
|
||||||
|
for (int d = (int)loMin; d <= (int)hiMax; d++) // decade gridlines
|
||||||
|
{
|
||||||
|
int y = YOf(Math.Pow(10, d));
|
||||||
|
for (int x = L; x < L + plotW; x++) if (y >= 0 && y < H) img.SetPixel(x, y, grid);
|
||||||
|
TinyFont.Draw(img, $"1E{d}", 12, Math.Max(0, y - 6), 2, new Color(0.60f, 0.64f, 0.70f));
|
||||||
|
}
|
||||||
|
// the analysis's own thresholds — so the ladder is read RELATIVE to them, not in a vacuum
|
||||||
|
DashH(img, YOf(endorheicMinInflowPx), L, L + plotW, new Color(1f, 0.45f, 0.45f));
|
||||||
|
TinyFont.Draw(img, $"ENDORHEIC MIN INFLOW {endorheicMinInflowPx:N0}", L + 8, YOf(endorheicMinInflowPx) - 22, 2, new Color(1f, 0.45f, 0.45f));
|
||||||
|
DashH(img, YOf(stemMinAccPx), L, L + plotW, new Color(0.55f, 0.85f, 0.55f));
|
||||||
|
TinyFont.Draw(img, $"STEM MIN ACC {stemMinAccPx:N0}", L + 8, YOf(stemMinAccPx) - 22, 2, new Color(0.55f, 0.85f, 0.55f));
|
||||||
|
|
||||||
|
foreach (int nn in ladder) // the ladder counts
|
||||||
|
{
|
||||||
|
if (nn < 1 || nn > ranked.Count) continue;
|
||||||
|
int x = XOf(nn);
|
||||||
|
for (int y = T; y < T + plotH; y += 6)
|
||||||
|
for (int k = 0; k < 3 && y + k < T + plotH; k++) img.SetPixel(x, y + k, new Color(0.95f, 0.90f, 0.35f));
|
||||||
|
TinyFont.Draw(img, $"N={nn}", x + 6, T + 6, 3, new Color(0.95f, 0.90f, 0.35f));
|
||||||
|
TinyFont.Draw(img, $"{ranked[nn - 1].DrainagePx:N0}", x + 6, T + 6 + TinyFont.Height(3) + 4, 2, new Color(0.95f, 0.90f, 0.35f));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < ranked.Count; i++) // the candidates
|
||||||
|
{
|
||||||
|
var c = ranked[i];
|
||||||
|
int x = XOf(i + 1), y = YOf(c.DrainagePx);
|
||||||
|
Color col = c.IsSea ? Trunk : Giant;
|
||||||
|
for (int ox = -3; ox <= 3; ox++)
|
||||||
|
for (int oy = -3; oy <= 3; oy++)
|
||||||
|
{
|
||||||
|
if (ox * ox + oy * oy > 9) continue;
|
||||||
|
int px = x + ox, py = y + oy;
|
||||||
|
if (px >= 0 && py >= 0 && px < W && py < H) img.SetPixel(px, py, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TinyFont.Draw(img, title, 12, 12, 3, Ink);
|
||||||
|
TinyFont.Draw(img, "DRAINAGE AREA (PX, LOG) VS UNIFIED RANK - CYAN SEA-REACHING, ORANGE ENDORHEIC", 12, 12 + TinyFont.Height(3) + 8, 2, Ink);
|
||||||
|
TinyFont.Draw(img, $"{ranked.Count} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR - A KNEE IS A SHARP DROP; A SMOOTH CURVE MEANS THE TERRAIN HAS NO NATURAL COUNT", 12, H - 34, 2, new Color(0.70f, 0.74f, 0.80f));
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Join(int[] v)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
for (int i = 0; i < v.Length; i++) { if (i > 0) sb.Append('/'); sb.Append(v[i]); }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DashH(Image img, int y, int x0, int x1, Color c)
|
||||||
|
{
|
||||||
|
if (y < 0 || y >= img.GetHeight()) return;
|
||||||
|
for (int x = x0; x < x1; x += 14)
|
||||||
|
for (int k = 0; k < 8 && x + k < x1; k++) img.SetPixel(x + k, y, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void Polyline(Image img, List<(float x, float y)> pts, int n, Color c, int thick)
|
||||||
{
|
{
|
||||||
for (int i = 1; i < pts.Count; i++)
|
for (int i = 1; i < pts.Count; i++)
|
||||||
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
|
Line(img, (int)pts[i - 1].x, (int)pts[i - 1].y, (int)pts[i].x, (int)pts[i].y, n, c, thick);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
|
internal static void Line(Image img, int x0, int y0, int x1, int y1, int n, Color c, int thick)
|
||||||
{
|
{
|
||||||
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
int dx = Math.Abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
||||||
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
int dy = -Math.Abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
||||||
|
|
@ -118,7 +811,7 @@ namespace IslaApocalypse.Tools
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Disc(Image img, int cx, int cy, int r, int n, Color c)
|
internal static void Disc(Image img, int cx, int cy, int r, int n, Color c)
|
||||||
{
|
{
|
||||||
for (int ox = -r; ox <= r; ox++)
|
for (int ox = -r; ox <= r; ox++)
|
||||||
for (int oy = -r; oy <= r; oy++)
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
|
@ -129,7 +822,7 @@ namespace IslaApocalypse.Tools
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
|
internal static void Ring(Image img, int cx, int cy, int r, int n, Color c, int w)
|
||||||
{
|
{
|
||||||
for (int ox = -r; ox <= r; ox++)
|
for (int ox = -r; ox <= r; ox++)
|
||||||
for (int oy = -r; oy <= r; oy++)
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
|
@ -141,7 +834,7 @@ namespace IslaApocalypse.Tools
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Square(Image img, int cx, int cy, int r, int n, Color c)
|
internal static void Square(Image img, int cx, int cy, int r, int n, Color c)
|
||||||
{
|
{
|
||||||
for (int ox = -r; ox <= r; ox++)
|
for (int ox = -r; ox <= r; ox++)
|
||||||
for (int oy = -r; oy <= r; oy++)
|
for (int oy = -r; oy <= r; oy++)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 12);
|
int task = EnvInt("ISLA_TASK", 12);
|
||||||
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
|
string descr = EnvStr("ISLA_BATCH", "drainage_analysis");
|
||||||
|
|
@ -62,7 +66,7 @@ namespace IslaApocalypse.Tools
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
bool skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1";
|
bool skipT11 = EnvStr("ISLA_SKIP_T11_CHECK", "0") == "1";
|
||||||
string t11Source = EnvStr("ISLA_T11_SOURCE", "11_erosion");
|
string t11Source = EnvStr("ISLA_T11_SOURCE", "chat2/11_erosion");
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
|
@ -77,7 +81,11 @@ namespace IslaApocalypse.Tools
|
||||||
GD.Print("==================================================================");
|
GD.Print("==================================================================");
|
||||||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||||
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||||||
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON (faithful tune) — the task-11 erosion_on field");
|
// ⚠ rivers/01: the shape AND erosion now come from the defaults, so both are asserted before
|
||||||
|
// anything generates. A drift here would silently re-baseline every river measurement.
|
||||||
|
TerrainShapeV1.Assert("DrainageTool");
|
||||||
|
TerrainShapeV1.AssertErosionDefaultOn("DrainageTool");
|
||||||
|
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default (faithful tune) — the task-11 erosion_on field");
|
||||||
GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px");
|
GD.Print($"params : endorheic depth ≥ {dp.EndorheicMinDepthM} m, area ≥ {dp.EndorheicMinAreaPx}, inflow ≥ {dp.EndorheicMinInflowPx}, max {dp.EndorheicMaxCount} · trunks {dp.TrunkCount} sep {dp.MinOutletSeparationPx} px · giants {dp.GiantCount} · stem ≥ {dp.StemMinAccPx} · tributary ≥ {dp.TributaryMinAccPx} (max {dp.TributaryMaxPerTrunk}) · exit grade {dp.ExitGradeMin} m/px over {dp.ExitWindowPx} px");
|
||||||
GD.Print($"batch : {batchRoot}");
|
GD.Print($"batch : {batchRoot}");
|
||||||
GD.Print("==================================================================");
|
GD.Print("==================================================================");
|
||||||
|
|
@ -88,14 +96,15 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
TerrainGenConfig Cfg(int size, int seed)
|
TerrainGenConfig Cfg(int size, int seed)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: THE SHAPE AND EROSION COME FROM THE BARE DEFAULTS. `TerrainShapeV1.Apply(c)`
|
||||||
|
// and `c.Erosion = true` used to sit here; both are now what `new TerrainGenConfig()`
|
||||||
|
// carries. Only the CURVE (measured this run) is set.
|
||||||
var c = new TerrainGenConfig
|
var c = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = size, Seed = seed, VariantLabel = "drainage",
|
MapSize = size, Seed = seed, VariantLabel = "drainage",
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
};
|
};
|
||||||
TerrainShapeV1.Apply(c);
|
|
||||||
c.Erosion = true; // the faithful tune — the defaults
|
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,15 +123,16 @@ namespace IslaApocalypse.Tools
|
||||||
Pass2Result p2 = ero.Shaped;
|
Pass2Result p2 = ero.Shaped;
|
||||||
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
|
GD.Print($" terrain ready ({p1.ElapsedMs} ms pass 1, erosion {ero.Ms / 1000.0:F1} s)");
|
||||||
|
|
||||||
|
// ⭐ a11 — THE EROSION ACCEPTANCE ANCHOR (rivers/01 keeps this one). Since the re-baseline
|
||||||
|
// the whole chain — shape AND erosion — comes from the bare defaults, so this is the
|
||||||
|
// standing proof that the terrain every river measurement rests on has not moved.
|
||||||
|
// ⚠ A missing dump now THROWS (ShapingOracle.LoadAnchor) instead of skipping silently.
|
||||||
if (!skipT11)
|
if (!skipT11)
|
||||||
{
|
{
|
||||||
string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32");
|
string dump = Path.Combine(ToolingPaths.BatchesRoot, t11Source, $"{seed}_erosion_on", "height.f32");
|
||||||
if (File.Exists(dump) && mapSize == 8192)
|
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field from the BARE DEFAULTS == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]",
|
||||||
{
|
p2.Height, ShapingOracle.LoadAnchor("a11", "ISLA_T11_SOURCE", dump, mapSize), mapSize, dump);
|
||||||
var a11 = ShapingOracle.DumpRegression("a11", $"the eroded render field == the task-11 erosion_on dump (the terrain the developer saw) [{seed}]", p2.Height, HeightField.Load(dump, mapSize), mapSize, dump);
|
hard.Add(a11); GD.Print(" " + a11);
|
||||||
hard.Add(a11); GD.Print(" " + a11);
|
|
||||||
}
|
|
||||||
else GD.Print($" a11 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the 11 batch's 8192" : $"no dump at {dump}")}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
|
// ⭐ THE OCEAN IDENTITY — from the region layer, on the CLASSIFY field.
|
||||||
|
|
@ -284,7 +294,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -297,11 +307,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
|
||||||
|
|
@ -7,33 +7,6 @@ using IslaApocalypse.Core;
|
||||||
|
|
||||||
namespace IslaApocalypse.Tools
|
namespace IslaApocalypse.Tools
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// ⭐ THE LOCKED SHAPE — `terrain-shape-v1` (chat2/10 gallery-confirmed): the continuous curve + the
|
|
||||||
/// frag_4 organic islands. Every later pass (erosion, rivers, …) starts from exactly these values,
|
|
||||||
/// pinned here once so no tool re-types them.
|
|
||||||
/// </summary>
|
|
||||||
public static class TerrainShapeV1
|
|
||||||
{
|
|
||||||
public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f;
|
|
||||||
public const bool BitesOnly = false;
|
|
||||||
public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f;
|
|
||||||
public const bool StretchSinker = true;
|
|
||||||
public const float SpeckFrac = 2.5e-7f;
|
|
||||||
|
|
||||||
/// <summary>Apply the locked shape to a config (curve settings are the caller's — they come from the calibration).</summary>
|
|
||||||
public static void Apply(TerrainGenConfig c)
|
|
||||||
{
|
|
||||||
c.CoastShelf = false; c.Offshore = new OffshoreSettings();
|
|
||||||
c.RegionLabeling = true; c.SpeckRevert = true; c.MinLandComponentFrac = SpeckFrac;
|
|
||||||
c.SouthStretch = Stretch; c.SouthBandStartFrac = BandStart; c.SouthBandFeatherFrac = BandFeather; c.StretchSinker = StretchSinker;
|
|
||||||
c.FragmentAmp = FragmentAmp; c.FragmentFreqPerMapWidth = FragmentFreq;
|
|
||||||
c.FragmentBandCentre = BandCentre; c.FragmentBandHalfWidth = BandHalfWidth; c.FragmentBitesOnly = BitesOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Describe() =>
|
|
||||||
$"terrain-shape-v1: frag amp {FragmentAmp} freq {FragmentFreq} window {BandCentre}±{BandHalfWidth} · stretch {Stretch} (band {BandStart}/{BandFeather}, sinker stretched) · speck revert {SpeckFrac:G2} · offshore OFF · shelf OFF · labeling ON";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
|
/// ⭐ THE EROSION BATCH (chat2/11) — the faithful droplet erosion on the locked shape, judged across
|
||||||
/// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
|
/// seeds, erosion OFF vs ON. 4 seeds from the task-10 gallery × {off, on} = 8 fields at showpiece
|
||||||
|
|
@ -85,6 +58,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 11);
|
int task = EnvInt("ISLA_TASK", 11);
|
||||||
string descr = EnvStr("ISLA_BATCH", "erosion");
|
string descr = EnvStr("ISLA_BATCH", "erosion");
|
||||||
|
|
@ -93,7 +70,7 @@ namespace IslaApocalypse.Tools
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
|
bool skipTag = EnvStr("ISLA_SKIP_TAG_CHECK", "0") == "1";
|
||||||
string t10Source = EnvStr("ISLA_T10_SOURCE", "10_frag4_seed_gallery");
|
string t10Source = EnvStr("ISLA_T10_SOURCE", "chat2/10_frag4_seed_gallery");
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
|
@ -107,6 +84,7 @@ namespace IslaApocalypse.Tools
|
||||||
GD.Print("==================================================================");
|
GD.Print("==================================================================");
|
||||||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||||
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
GD.Print($"seeds : {string.Join(", ", seeds)}");
|
||||||
|
TerrainShapeV1.Assert("ErosionTool"); // ⚠ rivers/01: refuse to render if the defaults drifted off the locked shape
|
||||||
GD.Print($"shape : {TerrainShapeV1.Describe()}");
|
GD.Print($"shape : {TerrainShapeV1.Describe()}");
|
||||||
GD.Print($"batch : {batchRoot}");
|
GD.Print($"batch : {batchRoot}");
|
||||||
GD.Print("==================================================================");
|
GD.Print("==================================================================");
|
||||||
|
|
@ -117,13 +95,17 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
|
TerrainGenConfig Cfg(int size, int seed, string label, bool erosion)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: THE SHAPE COMES FROM THE BARE DEFAULTS. `TerrainShapeV1.Apply(c)` used to
|
||||||
|
// sit here; the locked shape is now what `new TerrainGenConfig()` produces, so stamping
|
||||||
|
// a preset on top would MASK a default drift instead of catching it. Only the CURVE
|
||||||
|
// (measured this run) and the per-variant erosion flag are set.
|
||||||
|
// → TerrainShapeV1.Assert(), called before any generation below.
|
||||||
var c = new TerrainGenConfig
|
var c = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = size, Seed = seed, VariantLabel = label,
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
};
|
};
|
||||||
TerrainShapeV1.Apply(c);
|
|
||||||
c.Erosion = erosion;
|
c.Erosion = erosion;
|
||||||
c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
|
c.ErosionDropletCount = EnvInt("ISLA_ERO_COUNT", c.ErosionDropletCount);
|
||||||
c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
|
c.ErosionDropletLifetime = EnvInt("ISLA_ERO_LIFETIME", c.ErosionDropletLifetime);
|
||||||
|
|
@ -156,12 +138,15 @@ namespace IslaApocalypse.Tools
|
||||||
var cOff = Cfg(mapSize, seed, "erosion_off", false);
|
var cOff = Cfg(mapSize, seed, "erosion_off", false);
|
||||||
Pass1Result p1Off = Topography.Generate(cOff);
|
Pass1Result p1Off = Topography.Generate(cOff);
|
||||||
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
|
Pass2Result p2Off = Shaping.Shape(p1Off, cOff);
|
||||||
|
// ⭐ a10 — THE SHAPE ACCEPTANCE ANCHOR (rivers/01 keeps this one). Since the re-baseline
|
||||||
|
// `p2Off` is generated from the BARE DEFAULTS, so this check is now the standing proof
|
||||||
|
// that the defaults still reproduce `terrain-shape-v1`.
|
||||||
|
// ⚠ A missing dump now THROWS (ShapingOracle.LoadAnchor) instead of skipping silently.
|
||||||
if (!skipTag)
|
if (!skipTag)
|
||||||
{
|
{
|
||||||
string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
|
string dump = Path.Combine(ToolingPaths.BatchesRoot, t10Source, $"{seed}", "height.f32");
|
||||||
if (File.Exists(dump) && mapSize == 8192)
|
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF from the BARE DEFAULTS == terrain-shape-v1 (the task-10 gallery dump) [{seed}]",
|
||||||
hard.Add(ShapingOracle.DumpRegression("a10", $"erosion OFF == terrain-shape-v1 (the task-10 gallery dump) [{seed}]", p2Off.Height, HeightField.Load(dump, mapSize), mapSize, dump));
|
p2Off.Height, ShapingOracle.LoadAnchor("a10", "ISLA_T10_SOURCE", dump, mapSize), mapSize, dump));
|
||||||
else GD.Print($" a10 [{seed}]: ⚠ skipped — {(mapSize != 8192 ? "map size is not the gallery's 8192" : $"no gallery dump at {dump}")}");
|
|
||||||
}
|
}
|
||||||
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
|
Image reliefOff = ReliefRenderer.Render(p2Off.Height, mapSize, look);
|
||||||
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
|
Image shadeOff = ShadeRenderer.Render(p2Off.Height, mapSize, sea, ShadeZ, look.LightAzimuth, look.LightAltitude);
|
||||||
|
|
@ -281,7 +266,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -294,11 +279,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
|
||||||
642
Tools/Scripts/FlowThroughRouting.cs
Normal file
642
Tools/Scripts/FlowThroughRouting.cs
Normal file
|
|
@ -0,0 +1,642 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea.
|
||||||
|
///
|
||||||
|
/// ═══ THE MODEL ═══
|
||||||
|
///
|
||||||
|
/// The reference routes a river to the FIRST terminus it can afford and stops. This replaces that with
|
||||||
|
/// CHAINING THROUGH THE BASIN GRAPH (rivers/04): a promoted river runs down its erosion-carved stem to
|
||||||
|
/// its terminal basin and from there follows the terrain's own overflow structure — into the lake or
|
||||||
|
/// across the dry pan, over the basin's spill, into the next basin, over its spill — until it reaches
|
||||||
|
/// the coast, walls at a real lake, or walls at a dry sink.
|
||||||
|
///
|
||||||
|
/// THE FIELD one island-wide D8 direction per land cell on <c>Plan.FullFilled</c> (the overflow
|
||||||
|
/// surface, rivers/04 §0.2: on it every basin's minimum is its spill, so descent leaves
|
||||||
|
/// each basin over its spill into the next). Cap-independent; computed once per seed.
|
||||||
|
/// THE WALK a river FOLLOWS the field from its terminal. Each basin it enters is checked once:
|
||||||
|
/// floor→spill climb (<c>BasinNode.SpillClimbM</c>, the fill-to-overtop metric, applied
|
||||||
|
/// uniformly to lake and dry basins) ≤ cap → overflow, continue; > cap → walled, stop.
|
||||||
|
/// DISPOSITION reaches <c>OceanMask</c> → KEEP (flow-through to the sea); walls at an <c>IsLake</c>
|
||||||
|
/// basin → KEEP (lake-terminal, feeds visible water); walls at a dry or puddle-only basin
|
||||||
|
/// → DROP the river entirely (a river dead-ending in dry nowhere is worse than no river,
|
||||||
|
/// and nothing is filled). Read through the CONFLUENCE ROOT: a river that joins a kept
|
||||||
|
/// river is kept as its tributary, whatever its own chain would have done.
|
||||||
|
/// CONFLUENCE rivers/03b's, reused verbatim: biggest-first, true cell intersection, never proximity.
|
||||||
|
///
|
||||||
|
/// ═══ ⛔ THE RED LINE ═══
|
||||||
|
///
|
||||||
|
/// **Courses, a direction field, and data. No height written, no bed carved, no water created or
|
||||||
|
/// filled.** The caller digests both height fields around this and refuses on any change.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ D-046 — which surface each step reads ═══
|
||||||
|
///
|
||||||
|
/// RENDER the field (<c>FullFilled</c>), the climbs (<c>SpillClimbM</c>), the real-terrain descent into
|
||||||
|
/// a lake (<c>Plan.Dir</c> on <c>Filled</c>), the lowground fallback (<c>RouteTo</c> on <c>p2.Height</c>).
|
||||||
|
/// CLASSIFY every terminus test: <c>OceanMask</c> for the sea, <c>IsLake</c> (≥ floor) for a lake, and
|
||||||
|
/// "is this cell the basin's own water" for where a river enters a lake. No bare <c>h < sea</c>.
|
||||||
|
///
|
||||||
|
/// ═══ ⭐ HOW A LAKE BASIN IS CROSSED (the one place the field is not simply followed) ═══
|
||||||
|
///
|
||||||
|
/// The field inside a basin is the flood's ulp-staircase — it points from anywhere in the basin straight
|
||||||
|
/// at the spill, IGNORING the lake, because the flood never asked where the low water is. Water entering
|
||||||
|
/// a lake basin does not skirt the lake to the spill; it runs down to the lake, fills it, and leaves at
|
||||||
|
/// the spill. So inside an <c>IsLake</c> basin the course is: the REAL-TERRAIN descent from the entry
|
||||||
|
/// point into the basin's own classify water (<c>Plan.Dir</c>; rivers/03c fix B's lowground route as the
|
||||||
|
/// fallback when the descent pools short of the water), then the LAKE SPAN (water — recorded, not drawn),
|
||||||
|
/// then the OUTLET: from the lake's lowest cell on <c>FullFilled</c> (its point nearest the spill in flood
|
||||||
|
/// terms) along the field over the spill. A dry basin is crossed on the field as a visible line.
|
||||||
|
/// </summary>
|
||||||
|
public static class FlowThroughRouting
|
||||||
|
{
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
public const sbyte D_NONE = -1;
|
||||||
|
|
||||||
|
public enum Terminus : byte
|
||||||
|
{
|
||||||
|
/// <summary>The chain reached <c>OceanMask</c>.</summary>
|
||||||
|
Ocean,
|
||||||
|
/// <summary>Walled at an <c>IsLake</c> basin — a significant lake.</summary>
|
||||||
|
Lake,
|
||||||
|
/// <summary>Walled at a dry (or puddle-only) basin.</summary>
|
||||||
|
DrySink,
|
||||||
|
/// <summary>The walk stuck with no lower neighbour outside any basin — an exact flat. Not expected.</summary>
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One basin the chain entered.</summary>
|
||||||
|
public sealed class Hop
|
||||||
|
{
|
||||||
|
public int BasinId;
|
||||||
|
public bool IsLake;
|
||||||
|
/// <summary>Floor→spill climb, metres, clamped at sea as <c>RouteTo</c> clamps — the cap metric.</summary>
|
||||||
|
public float ClimbM;
|
||||||
|
public bool Walled;
|
||||||
|
public int EntryCell;
|
||||||
|
/// <summary>The first cell outside the basin on the way out (-1 if walled).</summary>
|
||||||
|
public int SpillCell = -1;
|
||||||
|
/// <summary>⭐ The cell the river actually ENTERED the lake at (lake basins only; -1 otherwise).</summary>
|
||||||
|
public int LakeEntryCell = -1;
|
||||||
|
/// <summary>Cross-check: the field's exit from this basin lands where <c>BasinGraph</c>'s edge says.</summary>
|
||||||
|
public bool EdgeAgreesWithGraph = true;
|
||||||
|
/// <summary>The lake-entry descent had to fall back to the lowground route (the terminal pooled short of the water).</summary>
|
||||||
|
public bool UsedLowgroundFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One promoted river, walked, disposed, assembled.</summary>
|
||||||
|
public sealed class FlowRiver
|
||||||
|
{
|
||||||
|
public RiverCandidate Candidate;
|
||||||
|
public List<Hop> Chain = new();
|
||||||
|
/// <summary>This river's OWN terminus, before confluence.</summary>
|
||||||
|
public Terminus Terminus;
|
||||||
|
public int TerminusBasinId;
|
||||||
|
/// <summary>The ocean cell entered, the lake cell entered, or where a dry/closed chain ended.</summary>
|
||||||
|
public int MouthCell = -1;
|
||||||
|
/// <summary>Head → terminus: the upland stem then the lowland chain. Lake spans are straight jumps across water.</summary>
|
||||||
|
public List<(float x, float y)> Course;
|
||||||
|
/// <summary>Lake spans: (entry water cell, outlet water cell) — the parts of the course that are water, not channel.</summary>
|
||||||
|
public List<(int from, int to)> WaterSpans = new();
|
||||||
|
public float StemLenPx, LowlandLenPx;
|
||||||
|
public float TotalLenPx => StemLenPx + LowlandLenPx;
|
||||||
|
public float MaxHopClimbM, TotalClimbM;
|
||||||
|
public int LakesPassed;
|
||||||
|
/// <summary>The rivers/03b confluence wrapper — <c>Joined</c>, <c>ConfluenceParentRank</c>, <c>CellPath</c>, <c>OwnPath</c>, <c>StemCells</c>.</summary>
|
||||||
|
public RiverRouting.RoutedRiver Routed;
|
||||||
|
/// <summary>⭐ The disposition of record — read through the confluence root.</summary>
|
||||||
|
public Terminus RootTerminus;
|
||||||
|
public bool Dropped;
|
||||||
|
public bool Trunk => Candidate.IsSea;
|
||||||
|
public bool ReachesSea => !Dropped && RootTerminus == Terminus.Ocean;
|
||||||
|
public string Why = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cap-independent field: D8 on <c>FullFilled</c>, 0..7 or <see cref="D_NONE"/> (ocean, or no lower neighbour).</summary>
|
||||||
|
public sealed class Field
|
||||||
|
{
|
||||||
|
public sbyte[] Dir;
|
||||||
|
public int N;
|
||||||
|
public int Target(int i)
|
||||||
|
{
|
||||||
|
sbyte d = Dir[i];
|
||||||
|
if (d < 0) return -1;
|
||||||
|
int cx = i / N, cy = i % N;
|
||||||
|
return (cx + DX[d]) * N + (cy + DY[d]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-seed precomputation shared by every cap: each lake basin's water cells and outlet cell; every basin's fill volume.</summary>
|
||||||
|
public sealed class Prep
|
||||||
|
{
|
||||||
|
public Dictionary<int, List<int>> LakeCells = new();
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Per WATER BODY (an 8-connected component of a lake basin's own classify water): its cell with the lowest
|
||||||
|
/// <c>FullFilled</c> — the body's point nearest the spill in flood terms, where its overflow leaves. Per body, not
|
||||||
|
/// per basin: one basin can own several separate lakes (rivers/04 found `1063685222 #699` owning two), and a
|
||||||
|
/// river that enters one must leave from THAT one, not jump across land to another.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<int, int> BodyOut = new();
|
||||||
|
/// <summary>Water cell → its body id (lake basins' own water only).</summary>
|
||||||
|
public Dictionary<int, int> BodyOf = new();
|
||||||
|
/// <summary>Per basin id: Σ (FullFilled − render) × metres, over its cells — the volume to fill it to its spill, in metre·cells.</summary>
|
||||||
|
public Dictionary<int, double> FillVolumeMPx = new();
|
||||||
|
public bool[] Scratch; // one reusable target mask for the lowground fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HeroLake
|
||||||
|
{
|
||||||
|
public int BasinId; public long LakeCells; public double FillVolumeMPx;
|
||||||
|
public float RiverLenPx; public int RiverRank; public int RiversThrough;
|
||||||
|
public double Score;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Result
|
||||||
|
{
|
||||||
|
public float CapM;
|
||||||
|
public List<FlowRiver> Rivers = new();
|
||||||
|
public HashSet<int> WalledIds = new();
|
||||||
|
public int Trunks, FlowThrough, LakeTerminal, DroppedDry, DroppedClosed, Joined, RescuedByConfluence, DroppedByConfluence;
|
||||||
|
public int EdgeAgree, EdgeDisagree, LowgroundFallbacks;
|
||||||
|
/// <summary>The field AT THIS CAP: the ∞ field with every walled basin's cells replaced by D8 on the real terrain, so flow into a walled basin ends there.</summary>
|
||||||
|
public sbyte[] CappedDir;
|
||||||
|
/// <summary>Flow accumulation on the capped field (Kahn), cells; 0 on ocean — the field's own drainage tree, for the data map.</summary>
|
||||||
|
public int[] CappedAcc;
|
||||||
|
public long LandCells, CellsToSea, CellsToWalledLake, CellsToWalledDry, CellsStuck;
|
||||||
|
public List<HeroLake> HeroLakes = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ THE FIELD ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>D8 on <c>FullFilled</c> for every non-ocean cell, the analysis's exact neighbour order and drop/DIST rule. Ocean cells are <see cref="D_NONE"/>.</summary>
|
||||||
|
public static Field BuildField(DrainageAnalysis.Plan plan, int n, bool[] isOcean)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var dir = new sbyte[total];
|
||||||
|
float[] ff = plan.FullFilled;
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (isOcean[i]) { dir[i] = D_NONE; continue; }
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
float best = 0f; int bestK = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
float drop = (ff[i] - ff[nx * n + ny]) / DIST[k];
|
||||||
|
if (drop > best) { best = drop; bestK = k; }
|
||||||
|
}
|
||||||
|
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
|
||||||
|
}
|
||||||
|
return new Field { Dir = dir, N = n };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Prep Prepare(DrainageAnalysis.Plan plan, BasinGraph graph, float[,] height, int n, bool[] isOcean, bool[] isClassifyWater)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var p = new Prep { Scratch = new bool[total] };
|
||||||
|
var isLake = new HashSet<int>();
|
||||||
|
foreach (var b in graph.Nodes) if (b.IsLake) isLake.Add(b.Id);
|
||||||
|
var outFF = new Dictionary<int, float>();
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = plan.BasinId[i];
|
||||||
|
if (id == 0) continue;
|
||||||
|
double d = WorldScale.MetresFromRaw(plan.FullFilled[i] - height[i / n, i % n]);
|
||||||
|
p.FillVolumeMPx.TryGetValue(id, out double v); p.FillVolumeMPx[id] = v + d;
|
||||||
|
if (!isLake.Contains(id) || !isClassifyWater[i] || isOcean[i]) continue;
|
||||||
|
if (!p.LakeCells.TryGetValue(id, out var cells)) { cells = new List<int>(); p.LakeCells[id] = cells; }
|
||||||
|
cells.Add(i);
|
||||||
|
}
|
||||||
|
// Label each lake basin's water into bodies (8-connected, fixed order) and find each body's outlet cell.
|
||||||
|
int nextBody = 1;
|
||||||
|
var stack = new Stack<int>();
|
||||||
|
foreach (var kv in p.LakeCells)
|
||||||
|
{
|
||||||
|
var set = new HashSet<int>(kv.Value);
|
||||||
|
foreach (int seed in kv.Value)
|
||||||
|
{
|
||||||
|
if (p.BodyOf.ContainsKey(seed)) continue;
|
||||||
|
int body = nextBody++;
|
||||||
|
p.BodyOf[seed] = body; stack.Push(seed);
|
||||||
|
int outCell = seed; float outFFv = plan.FullFilled[seed];
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
int c = stack.Pop();
|
||||||
|
if (plan.FullFilled[c] < outFFv || (plan.FullFilled[c] == outFFv && c < outCell)) { outFFv = plan.FullFilled[c]; outCell = c; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (!set.Contains(ni) || p.BodyOf.ContainsKey(ni)) continue;
|
||||||
|
p.BodyOf[ni] = body; stack.Push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.BodyOut[body] = outCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The shortest 8-connected path THROUGH a body's water from one of its cells to another (BFS, fixed order). Water, not channel — recorded, never drawn.</summary>
|
||||||
|
private static List<int> WaterPath(Prep prep, int from, int to, int n)
|
||||||
|
{
|
||||||
|
int body = prep.BodyOf[from];
|
||||||
|
var parent = new Dictionary<int, int> { [from] = -1 };
|
||||||
|
var q = new Queue<int>(); q.Enqueue(from);
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue();
|
||||||
|
if (c == to) break;
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (parent.ContainsKey(ni) || !prep.BodyOf.TryGetValue(ni, out int b) || b != body) continue;
|
||||||
|
parent[ni] = c; q.Enqueue(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var path = new List<int>();
|
||||||
|
if (!parent.ContainsKey(to)) { path.Add(from); path.Add(to); return path; }
|
||||||
|
for (int c = to; c >= 0; c = parent[c]) path.Add(c);
|
||||||
|
path.Reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ THE WALKS ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
public static Result Run(List<RiverCandidate> promoted, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
|
||||||
|
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Action<string> log, bool confluence = true)
|
||||||
|
{
|
||||||
|
var r = new Result { CapM = capM };
|
||||||
|
foreach (var b in graph.LandNodes) if (b.SpillClimbM > capM) r.WalledIds.Add(b.Id);
|
||||||
|
|
||||||
|
foreach (var c in promoted)
|
||||||
|
{
|
||||||
|
var fr = Walk(c, plan, graph, field, prep, height, n, isOcean, isClassifyWater, sea, capM, r);
|
||||||
|
r.Rivers.Add(fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- confluence (rivers/03b, reused) over EVERY river, kept or not — a river that meets a kept river's
|
||||||
|
// channel before its own dead-end is that river's tributary, and its water reaches the sea through it.
|
||||||
|
var wrappers = new List<RiverRouting.RoutedRiver>();
|
||||||
|
foreach (var fr in r.Rivers)
|
||||||
|
{
|
||||||
|
fr.Routed = new RiverRouting.RoutedRiver
|
||||||
|
{
|
||||||
|
Candidate = fr.Candidate, Course = fr.Course,
|
||||||
|
Class = fr.Trunk ? RiverRouting.RiverClass.OceanTrunk
|
||||||
|
: fr.Terminus == Terminus.Ocean ? RiverRouting.RiverClass.RoutedGiant
|
||||||
|
: fr.Terminus == Terminus.Lake ? RiverRouting.RiverClass.LakeEnder
|
||||||
|
: RiverRouting.RiverClass.WalledOff,
|
||||||
|
};
|
||||||
|
wrappers.Add(fr.Routed);
|
||||||
|
}
|
||||||
|
if (confluence) RiverRouting.Confluence(wrappers, log);
|
||||||
|
else foreach (var w in wrappers) w.OwnPath = w.Course;
|
||||||
|
|
||||||
|
var byRank = new Dictionary<int, FlowRiver>();
|
||||||
|
foreach (var fr in r.Rivers) byRank[fr.Candidate.Rank] = fr;
|
||||||
|
foreach (var fr in r.Rivers)
|
||||||
|
{
|
||||||
|
var root = RiverRouting.Root(fr.Routed, wrappers);
|
||||||
|
var rootFr = byRank[root.Candidate.Rank];
|
||||||
|
fr.RootTerminus = rootFr.Terminus;
|
||||||
|
fr.Dropped = fr.RootTerminus == Terminus.DrySink || fr.RootTerminus == Terminus.Closed;
|
||||||
|
bool ownKept = fr.Terminus == Terminus.Ocean || fr.Terminus == Terminus.Lake;
|
||||||
|
if (fr.Routed.Joined)
|
||||||
|
{
|
||||||
|
r.Joined++;
|
||||||
|
if (!ownKept && !fr.Dropped) r.RescuedByConfluence++;
|
||||||
|
if (ownKept && fr.Dropped) r.DroppedByConfluence++;
|
||||||
|
}
|
||||||
|
if (fr.Dropped) { if (fr.RootTerminus == Terminus.Closed) r.DroppedClosed++; else r.DroppedDry++; }
|
||||||
|
else if (fr.Trunk) r.Trunks++;
|
||||||
|
else if (fr.RootTerminus == Terminus.Ocean) r.FlowThrough++;
|
||||||
|
else r.LakeTerminal++;
|
||||||
|
foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) r.LowgroundFallbacks++; }
|
||||||
|
}
|
||||||
|
foreach (var fr in r.Rivers)
|
||||||
|
for (int i = 0; i < fr.Chain.Count; i++)
|
||||||
|
if (fr.Chain[i].SpillCell >= 0) { if (fr.Chain[i].EdgeAgreesWithGraph) r.EdgeAgree++; else r.EdgeDisagree++; }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlowRiver Walk(RiverCandidate c, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, Prep prep,
|
||||||
|
float[,] height, int n, bool[] isOcean, bool[] isClassifyWater, float sea, float capM, Result res)
|
||||||
|
{
|
||||||
|
var fr = new FlowRiver { Candidate = c };
|
||||||
|
// The upland stem, head → terminal (the analysis's course is downstream-first, decimated ×4).
|
||||||
|
fr.Course = new List<(float x, float y)>(c.Course);
|
||||||
|
fr.Course.Reverse();
|
||||||
|
fr.StemLenPx = PolyLen(fr.Course);
|
||||||
|
|
||||||
|
if (c.IsSea)
|
||||||
|
{
|
||||||
|
fr.Terminus = Terminus.Ocean;
|
||||||
|
fr.MouthCell = c.Cell;
|
||||||
|
fr.Why = "natural ocean trunk — erosion already reaches the coast";
|
||||||
|
return fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cur = c.TermX * n + c.TermY;
|
||||||
|
int basin = plan.BasinId[cur] != 0 ? plan.BasinId[cur] : c.BasinId;
|
||||||
|
var visited = new HashSet<int>();
|
||||||
|
var why = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
for (int guard = 0; guard < 256; guard++)
|
||||||
|
{
|
||||||
|
var node = graph.Of(basin);
|
||||||
|
if (node == null || !visited.Add(basin))
|
||||||
|
{
|
||||||
|
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = basin; fr.MouthCell = cur;
|
||||||
|
why.Append(node == null ? $" → basin #{basin} not in the graph (closed)" : $" → basin #{basin} revisited (closed)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var hop = new Hop { BasinId = basin, IsLake = node.IsLake, ClimbM = node.SpillClimbM, Walled = node.SpillClimbM > capM, EntryCell = cur };
|
||||||
|
fr.Chain.Add(hop);
|
||||||
|
if (hop.ClimbM > fr.MaxHopClimbM) fr.MaxHopClimbM = hop.ClimbM;
|
||||||
|
|
||||||
|
if (hop.Walled)
|
||||||
|
{
|
||||||
|
fr.TerminusBasinId = basin;
|
||||||
|
if (node.IsLake)
|
||||||
|
{
|
||||||
|
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
|
||||||
|
hop.UsedLowgroundFallback = fb;
|
||||||
|
if (reach.Count > 1) AppendReach(fr, reach, n);
|
||||||
|
hop.LakeEntryCell = reach[^1];
|
||||||
|
fr.Terminus = Terminus.Lake; fr.MouthCell = reach[^1];
|
||||||
|
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at the lake — LAKE-TERMINAL, kept");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fr.Terminus = Terminus.DrySink; fr.MouthCell = cur;
|
||||||
|
why.Append($" → #{basin} DRY{(node.HasAnyLake ? " (puddle only)" : "")}, rim {hop.ClimbM:F1} m > cap {capM:F0} m: walls at a dry sink — DROPPED");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
fr.TotalClimbM += hop.ClimbM;
|
||||||
|
int from = cur;
|
||||||
|
if (node.IsLake)
|
||||||
|
{
|
||||||
|
var reach = DescendToWater(cur, basin, plan, prep, height, n, isOcean, isClassifyWater, sea, out bool fb);
|
||||||
|
hop.UsedLowgroundFallback = fb;
|
||||||
|
if (reach.Count > 1) AppendReach(fr, reach, n);
|
||||||
|
int w = reach[^1];
|
||||||
|
hop.LakeEntryCell = w;
|
||||||
|
int lakeOut = prep.BodyOf.TryGetValue(w, out int body) && prep.BodyOut.TryGetValue(body, out int bo) ? bo : w;
|
||||||
|
if (lakeOut != w)
|
||||||
|
{
|
||||||
|
// The lake span — through the water of the body the river entered, to that body's outlet.
|
||||||
|
fr.WaterSpans.Add((w, lakeOut));
|
||||||
|
var span = WaterPath(prep, w, lakeOut, n);
|
||||||
|
for (int i = 1; i < span.Count; i++) fr.Course.Add((span[i] / n, span[i] % n));
|
||||||
|
}
|
||||||
|
fr.LakesPassed++;
|
||||||
|
from = lakeOut;
|
||||||
|
why.Append($" → #{basin} LAKE, rim {hop.ClimbM:F1} m ≤ cap: through the lake and over its spill");
|
||||||
|
}
|
||||||
|
else why.Append($" → #{basin} dry, rim {hop.ClimbM:F1} m ≤ cap: across the low ground and over its spill");
|
||||||
|
|
||||||
|
var path = Follow(from, basin, field, plan, isOcean, n, out int status, out int spill);
|
||||||
|
hop.SpillCell = spill;
|
||||||
|
if (path.Count > 1) AppendReach(fr, path, n);
|
||||||
|
int end = path[^1];
|
||||||
|
|
||||||
|
if (status == 1)
|
||||||
|
{
|
||||||
|
fr.Terminus = Terminus.Ocean; fr.MouthCell = end;
|
||||||
|
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Ocean;
|
||||||
|
why.Append(" → the SEA — flow-through, kept");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (status == 0)
|
||||||
|
{
|
||||||
|
fr.Terminus = Terminus.Closed; fr.TerminusBasinId = 0; fr.MouthCell = end;
|
||||||
|
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.None;
|
||||||
|
why.Append(" → stuck on an exact flat outside any basin — CLOSED, dropped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int next = plan.BasinId[end];
|
||||||
|
hop.EdgeAgreesWithGraph = node.Downstream == DownstreamKind.Basin && node.DownstreamId == next;
|
||||||
|
cur = end; basin = next;
|
||||||
|
}
|
||||||
|
fr.Why = $"terminal basin #{fr.Chain[0].BasinId}" + why;
|
||||||
|
return fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Follow the ∞ field from a cell inside <paramref name="basin"/> until it reaches the ocean (status 1),
|
||||||
|
/// enters another terminal basin (status 2), or sticks (status 0). <paramref name="spill"/> is the first
|
||||||
|
/// cell outside the basin on the way.
|
||||||
|
/// </summary>
|
||||||
|
private static List<int> Follow(int start, int basin, Field field, DrainageAnalysis.Plan plan, bool[] isOcean, int n,
|
||||||
|
out int status, out int spill)
|
||||||
|
{
|
||||||
|
var path = new List<int> { start };
|
||||||
|
int c = start; spill = -1; status = 0;
|
||||||
|
for (int guard = 0; guard < 8 * n; guard++)
|
||||||
|
{
|
||||||
|
int b = plan.BasinId[c];
|
||||||
|
if (b != basin && spill < 0) spill = c;
|
||||||
|
if (isOcean[c]) { status = 1; return path; }
|
||||||
|
if (b != basin && b != 0) { status = 2; return path; }
|
||||||
|
int t = field.Target(c);
|
||||||
|
if (t < 0) { status = 0; return path; }
|
||||||
|
c = t; path.Add(c);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The real-terrain descent from a point inside a lake basin to the basin's own classify water:
|
||||||
|
/// <c>Plan.Dir</c> (D8 on <c>Filled</c>) until a water cell; if it pools short (the terminal is a D8
|
||||||
|
/// sink by definition), rivers/03c fix B's lowground route to the basin's own water — same rule, same cost model.
|
||||||
|
/// </summary>
|
||||||
|
private static List<int> DescendToWater(int start, int basin, DrainageAnalysis.Plan plan, Prep prep, float[,] height, int n,
|
||||||
|
bool[] isOcean, bool[] isClassifyWater, float sea, out bool usedFallback)
|
||||||
|
{
|
||||||
|
usedFallback = false;
|
||||||
|
var path = new List<int> { start };
|
||||||
|
int c = start;
|
||||||
|
bool IsOwnWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] == basin;
|
||||||
|
for (int guard = 0; guard < 8 * n; guard++)
|
||||||
|
{
|
||||||
|
if (IsOwnWater(c)) return path;
|
||||||
|
sbyte d = plan.Dir[c];
|
||||||
|
if (d < 0) break;
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int t = (cx + DX[d]) * n + (cy + DY[d]);
|
||||||
|
if (plan.BasinId[t] != basin) break;
|
||||||
|
c = t; path.Add(c);
|
||||||
|
}
|
||||||
|
// Pooled short of the water — route the rest as rivers/03c does for a lake-ender.
|
||||||
|
if (!prep.LakeCells.TryGetValue(basin, out var cells) || cells.Count == 0) return path;
|
||||||
|
usedFallback = true;
|
||||||
|
foreach (int i in cells) prep.Scratch[i] = true;
|
||||||
|
var route = RiverRouting.RouteTo(height, n, prep.Scratch, start / n, start % n, RiverRouting.StyleLowground, sea);
|
||||||
|
foreach (int i in cells) prep.Scratch[i] = false;
|
||||||
|
if (!route.Reached) return path;
|
||||||
|
var outp = new List<int>(route.Path.Count);
|
||||||
|
foreach (var p in route.Path) outp.Add((int)p.x * n + (int)p.y);
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Append a 1-px cell reach to the course, RDP+Chaikin-smoothed as rivers/03 smooths every lowland reach (endpoints pinned).</summary>
|
||||||
|
private static void AppendReach(FlowRiver fr, List<int> cells, int n)
|
||||||
|
{
|
||||||
|
var pts = new List<(float x, float y)>(cells.Count);
|
||||||
|
foreach (int i in cells) pts.Add((i / n, i % n));
|
||||||
|
fr.LowlandLenPx += PolyLen(pts);
|
||||||
|
var sm = RiverRouting.SmoothCourse(pts);
|
||||||
|
int start = fr.Course.Count > 0 && fr.Course[^1].x == sm[0].x && fr.Course[^1].y == sm[0].y ? 1 : 0;
|
||||||
|
for (int i = start; i < sm.Count; i++) fr.Course.Add(sm[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float PolyLen(List<(float x, float y)> pts)
|
||||||
|
{
|
||||||
|
float L = 0f;
|
||||||
|
for (int i = 1; i < pts.Count; i++)
|
||||||
|
{
|
||||||
|
float dx = pts[i].x - pts[i - 1].x, dy = pts[i].y - pts[i - 1].y;
|
||||||
|
L += MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
return L;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ THE FIELD AT THE CAP — the artifact ════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ∞ field with every WALLED basin's cells replaced by D8 on the real terrain (<c>Filled</c>), so
|
||||||
|
/// flow that reaches a walled basin descends to its floor and ends there — "a cell whose downstream
|
||||||
|
/// chain walls off drains to that wall, not past it". Then every land cell's destination, memoised.
|
||||||
|
/// </summary>
|
||||||
|
public static void BuildCappedField(Result r, DrainageAnalysis.Plan plan, BasinGraph graph, Field field, int n, bool[] isOcean)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var dir = (sbyte[])field.Dir.Clone();
|
||||||
|
float[] filled = plan.Filled;
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
int id = plan.BasinId[i];
|
||||||
|
if (id == 0 || !r.WalledIds.Contains(id) || isOcean[i]) continue;
|
||||||
|
int cx = i / n, cy = i % n;
|
||||||
|
float best = 0f; int bestK = -1;
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
float drop = (filled[i] - filled[nx * n + ny]) / DIST[k];
|
||||||
|
if (drop > best) { best = drop; bestK = k; }
|
||||||
|
}
|
||||||
|
dir[i] = bestK < 0 ? D_NONE : (sbyte)bestK;
|
||||||
|
}
|
||||||
|
r.CappedDir = dir;
|
||||||
|
|
||||||
|
// Accumulation on the capped field — Kahn propagation, as the analysis does on its own field.
|
||||||
|
{
|
||||||
|
var acc = new int[total];
|
||||||
|
var indeg = new byte[total];
|
||||||
|
int Tgt(int i) { sbyte d = dir[i]; if (d < 0) return -1; int cx = i / n, cy = i % n; return (cx + DX[d]) * n + (cy + DY[d]); }
|
||||||
|
for (int i = 0; i < total; i++) if (!isOcean[i] && dir[i] >= 0) { int t = Tgt(i); if (!isOcean[t]) indeg[t]++; }
|
||||||
|
var q = new Queue<int>();
|
||||||
|
for (int i = 0; i < total; i++) { if (isOcean[i]) continue; acc[i] = 1; if (indeg[i] == 0) q.Enqueue(i); }
|
||||||
|
while (q.Count > 0)
|
||||||
|
{
|
||||||
|
int c = q.Dequeue();
|
||||||
|
int t = Tgt(c);
|
||||||
|
if (t < 0 || isOcean[t]) continue;
|
||||||
|
acc[t] += acc[c];
|
||||||
|
if (--indeg[t] == 0) q.Enqueue(t);
|
||||||
|
}
|
||||||
|
r.CappedAcc = acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destinations: -1 sea, >0 basin id (a sink), -2 stuck.
|
||||||
|
var lakeIds = new HashSet<int>();
|
||||||
|
foreach (var b in graph.Nodes) if (b.IsLake) lakeIds.Add(b.Id);
|
||||||
|
var dest = new int[total];
|
||||||
|
var path = new List<int>(4096);
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (isOcean[i] || dest[i] != 0) continue;
|
||||||
|
int c = i; path.Clear(); int result;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (dest[c] != 0) { result = dest[c]; break; }
|
||||||
|
path.Add(c);
|
||||||
|
sbyte d = dir[c];
|
||||||
|
if (d < 0) { result = plan.BasinId[c] != 0 ? plan.BasinId[c] : -2; break; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int t = (cx + DX[d]) * n + (cy + DY[d]);
|
||||||
|
if (isOcean[t]) { result = -1; break; }
|
||||||
|
c = t;
|
||||||
|
}
|
||||||
|
foreach (int pc in path) dest[pc] = result;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (isOcean[i]) continue;
|
||||||
|
r.LandCells++;
|
||||||
|
int d = dest[i];
|
||||||
|
if (d == -1) r.CellsToSea++;
|
||||||
|
else if (d > 0) { if (lakeIds.Contains(d)) r.CellsToWalledLake++; else r.CellsToWalledDry++; }
|
||||||
|
else r.CellsStuck++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ THE HERO-LAKE CANDIDATE — data, not a fill ═════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Among lakes that a sea-reaching, un-joined river flows THROUGH, rank by the geometric mean of
|
||||||
|
/// normalised fill volume and normalised attached river length. Recorded intent (procedural, executed
|
||||||
|
/// post-water-render); nothing is filled here.
|
||||||
|
/// </summary>
|
||||||
|
public static void RankHeroLakes(Result r, BasinGraph graph, Prep prep)
|
||||||
|
{
|
||||||
|
var cand = new Dictionary<int, HeroLake>();
|
||||||
|
foreach (var fr in r.Rivers)
|
||||||
|
{
|
||||||
|
if (fr.Dropped || fr.Trunk || fr.Routed.Joined || fr.Terminus != Terminus.Ocean) continue;
|
||||||
|
foreach (var h in fr.Chain)
|
||||||
|
{
|
||||||
|
if (!h.IsLake || h.Walled) continue;
|
||||||
|
if (!cand.TryGetValue(h.BasinId, out var hl))
|
||||||
|
{
|
||||||
|
var node = graph.Of(h.BasinId);
|
||||||
|
hl = new HeroLake { BasinId = h.BasinId, LakeCells = node.LakeCells, FillVolumeMPx = prep.FillVolumeMPx.TryGetValue(h.BasinId, out double v) ? v : 0 };
|
||||||
|
cand[h.BasinId] = hl;
|
||||||
|
}
|
||||||
|
hl.RiversThrough++;
|
||||||
|
if (fr.TotalLenPx > hl.RiverLenPx) { hl.RiverLenPx = fr.TotalLenPx; hl.RiverRank = fr.Candidate.Rank; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double maxV = 1, maxL = 1;
|
||||||
|
foreach (var hl in cand.Values) { if (hl.FillVolumeMPx > maxV) maxV = hl.FillVolumeMPx; if (hl.RiverLenPx > maxL) maxL = hl.RiverLenPx; }
|
||||||
|
foreach (var hl in cand.Values) hl.Score = Math.Sqrt((hl.FillVolumeMPx / maxV) * (hl.RiverLenPx / maxL));
|
||||||
|
r.HeroLakes = new List<HeroLake>(cand.Values);
|
||||||
|
r.HeroLakes.Sort((a, b) => b.Score != a.Score ? b.Score.CompareTo(a.Score) : a.BasinId.CompareTo(b.BasinId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string TerminusName(Terminus t) => t switch
|
||||||
|
{
|
||||||
|
Terminus.Ocean => "sea", Terminus.Lake => "lake", Terminus.DrySink => "dry-sink", _ => "closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ClassName(FlowRiver fr) =>
|
||||||
|
fr.Dropped ? "dropped" : fr.Trunk ? "trunk" : fr.RootTerminus == Terminus.Ocean ? "flow-through" : "lake-terminal";
|
||||||
|
}
|
||||||
|
}
|
||||||
582
Tools/Scripts/FlowThroughTool.cs
Normal file
582
Tools/Scripts/FlowThroughTool.cs
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ FLOW-THROUGH ROUTING (rivers/05) — the model rework, the hydrology map, and the taste gate.
|
||||||
|
///
|
||||||
|
/// river → lake → over the spill → river → … → sea. The promoted set chains through the basin graph
|
||||||
|
/// (rivers/04) on the terrain's own overflow structure, cap-gated at every rim, kept if it reaches the
|
||||||
|
/// sea or a real lake, dropped if it dead-ends dry. Output: the courses, a per-cell flow-direction
|
||||||
|
/// field, and the hydrology map — a first-class reference artifact (→ D-056).
|
||||||
|
///
|
||||||
|
/// ═══ ⛔ THE RED LINE ═══
|
||||||
|
///
|
||||||
|
/// **Routing and data only. No render or classify height written, no bed carved, no water body created
|
||||||
|
/// or filled.** Both height fields are digested before the graph is built and after the last plate is
|
||||||
|
/// drawn; any change refuses the run (exit 2).
|
||||||
|
///
|
||||||
|
/// ═══ RUNNING IT ═══
|
||||||
|
///
|
||||||
|
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
|
||||||
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/FlowThroughTool.tscn
|
||||||
|
///
|
||||||
|
/// ISLA_TASK / ISLA_TASK_SUFFIX / ISLA_BATCH / ISLA_CHAT / ISLA_MAPSIZE / ISLA_CALIB_SIZE / ISLA_SEEDS / ISLA_SKIP_RAW
|
||||||
|
/// ISLA_FLOW_CAP_M the rim cap on floor→spill climb (default 30) — the connected-vs-inland knob
|
||||||
|
/// ISLA_CAP_PREVIEW_M caps to re-run the walks at for the sensitivity table (default "15,30,60")
|
||||||
|
/// ISLA_LAKE_MIN_PX the lake significance floor (default 20000)
|
||||||
|
/// ISLA_PROMOTE_N / ISLA_PROMOTE_FLOOR_PX / ISLA_PROMOTE_MAX as rivers/03 (12 / 5000 / 24)
|
||||||
|
/// </summary>
|
||||||
|
public partial class FlowThroughTool : Node
|
||||||
|
{
|
||||||
|
private static readonly int[] DefaultSeeds = { 1063685222, 999999937, 31415926, 14142135 };
|
||||||
|
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
private const int DefaultMapSize = 8192;
|
||||||
|
private const int DefaultCalibSize = 2048;
|
||||||
|
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
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 FlowThroughRouting.Result Main;
|
||||||
|
public Dictionary<float, FlowThroughRouting.Result> ByCap = new();
|
||||||
|
public BasinGraph Graph;
|
||||||
|
public int DistinctMouths; public List<string> SharedMouths = new(); public string Spread = "";
|
||||||
|
public ulong RenderDigest, ClassifyDigest;
|
||||||
|
public int CandidateCount, SeaCandidates;
|
||||||
|
public double WalkSeconds, FieldSeconds, RenderSeconds; public ulong Ms;
|
||||||
|
public float GMin, GMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||||||
|
|
||||||
|
int task = EnvInt("ISLA_TASK", 5);
|
||||||
|
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", "");
|
||||||
|
string descr = EnvStr("ISLA_BATCH", "flow_through_routing");
|
||||||
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
|
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||||
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
|
||||||
|
int promoteN = EnvInt("ISLA_PROMOTE_N", 12);
|
||||||
|
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
|
||||||
|
int lakeMinPx = EnvInt("ISLA_LAKE_MIN_PX", RiverRouting.LakeMinTargetPx);
|
||||||
|
float capM = EnvFloat("ISLA_FLOW_CAP_M", 30f);
|
||||||
|
float[] caps = EnvFloats("ISLA_CAP_PREVIEW_M", new[] { 15f, 30f, 60f });
|
||||||
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "1") == "1";
|
||||||
|
if (Array.IndexOf(caps, capM) < 0) { var l = new List<float>(caps) { capM }; l.Sort(); caps = l.ToArray(); }
|
||||||
|
|
||||||
|
if (promoteMax < promoteN)
|
||||||
|
throw new InvalidOperationException($"ISLA_PROMOTE_MAX ({promoteMax}) is below the promoted count ({promoteN}).");
|
||||||
|
|
||||||
|
TerrainShapeV1.Assert("FlowThrough");
|
||||||
|
TerrainShapeV1.AssertErosionDefaultOn("FlowThrough");
|
||||||
|
|
||||||
|
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||||
|
|
||||||
|
var anchors = CurveAnchors.Default;
|
||||||
|
float sea = 0.15f;
|
||||||
|
var dp = new DrainageAnalysis.Params
|
||||||
|
{
|
||||||
|
SeaLevel = sea, TrunkCount = promoteMax, GiantCount = promoteMax,
|
||||||
|
EndorheicMaxCount = promoteMax, EndorheicMinInflowPx = (int)floorPx,
|
||||||
|
};
|
||||||
|
var dpDefaults = new DrainageAnalysis.Params();
|
||||||
|
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print(" FLOW-THROUGH ROUTING (rivers/05) — river → lake → over the spill → river → … → sea. THE HYDROLOGY MAP.");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||||
|
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||||||
|
GD.Print($"seeds : {seeds.Length} — {string.Join(", ", seeds)}");
|
||||||
|
GD.Print($"promoted : PURE top {promoteN} by drainage (unified ranking, rivers/02). No quota.");
|
||||||
|
GD.Print($"model : follow the D8 field on FullFilled from each terminal; at every basin entered, floor→spill climb (SpillClimbM) ≤ cap → overflow, > cap → walled.");
|
||||||
|
GD.Print($" reaches OceanMask → KEEP; walls at IsLake (≥ {lakeMinPx:N0} px, classify) → KEEP (lake-terminal); walls dry or puddle-only → DROP. Read through the confluence root.");
|
||||||
|
GD.Print($"cap : ISLA_FLOW_CAP_M = {capM:F0} m (floor→spill, clamped at sea as RouteTo) — sensitivity at {string.Join(" / ", Array.ConvertAll(caps, c => c.ToString("F0")))} m");
|
||||||
|
GD.Print($"lakes : entered on the real terrain (Plan.Dir; rivers/03c fix B lowground fallback), left at the lake's lowest FullFilled cell over the spill. The in-lake span is water, not channel.");
|
||||||
|
GD.Print($"confluence: rivers/03b's, unchanged — biggest-first, true cell intersection.");
|
||||||
|
GD.Print($"field : per land cell, the FullFilled D8 heading; walled basins' cells re-pointed onto the real terrain so flow entering one ends there. Emitted as a map; serialization DEFERRED.");
|
||||||
|
GD.Print($"⛔ RED LINE : routing + data only — no height mutated, no water filled, no bed carved. ASSERTED per seed.");
|
||||||
|
GD.Print($"batch : {batchRoot}");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
if (mapSize != 8192)
|
||||||
|
GD.PrintErr($" ⚠⚠ MAP SIZE {mapSize} — the basin gates are absolute pixel counts tuned at 8192; a smaller map under-produces basins. PLUMBING only.");
|
||||||
|
|
||||||
|
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 = "flow",
|
||||||
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 {100.0 * plan.SeaReachingCells / Math.Max(1, plan.LandCells):F1} %, endorheic {100.0 * plan.EndorheicCells / Math.Max(1, plan.LandCells):F1} %; terminal basins {plan.TerminalBasinCount}");
|
||||||
|
|
||||||
|
var e = RiverCandidates.Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, p1.Regions);
|
||||||
|
var promoted = e.Ranked.GetRange(0, Math.Min(promoteN, e.Ranked.Count));
|
||||||
|
RiverCandidates.BindCourses(plan, mapSize, promoted, $"the pure top {promoteN}");
|
||||||
|
int pSea = 0; foreach (var c in promoted) if (c.IsSea) pSea++;
|
||||||
|
GD.Print($" promoted: pure top {promoted.Count} — {pSea} sea / {promoted.Count - pSea} endorheic (from {e.Ranked.Count} candidates)");
|
||||||
|
|
||||||
|
bool[] significant = RegionLabeling.SignificantWaterMask(isClassifyWater, isOcean, mapSize, lakeMinPx,
|
||||||
|
out int keptBodies, out int totalBodies, out long keptCells, out long largestPx);
|
||||||
|
|
||||||
|
// ═══ ⛔ RED-LINE GUARD — digest both fields BEFORE the graph, the field, the walks and the plates ═══
|
||||||
|
ulong hRenderBefore = Digest(p2.Height, mapSize);
|
||||||
|
ulong hClassifyBefore = Digest(p2.HeightClassify, mapSize);
|
||||||
|
|
||||||
|
var graph = BasinGraph.Build(plan, p2.Height, mapSize, isOcean, isClassifyWater, significant, sea, lakeMinPx);
|
||||||
|
GD.Print($" basin graph: {graph.LandNodes.Count} land basins ({graph.LakeBasins} lake / {graph.DryBasins} dry), {graph.Seabed} seabed pits excluded; spill invariants {graph.SpillCrossCheckFailures}/{graph.SpillNotOnTerrain}/{graph.DirWalkDisagreements}");
|
||||||
|
|
||||||
|
ulong tf0 = Time.GetTicksMsec();
|
||||||
|
var field = FlowThroughRouting.BuildField(plan, mapSize, isOcean);
|
||||||
|
var prep = FlowThroughRouting.Prepare(plan, graph, p2.Height, mapSize, isOcean, isClassifyWater);
|
||||||
|
double fieldSec = (Time.GetTicksMsec() - tf0) / 1000.0;
|
||||||
|
|
||||||
|
var r = new SeedResult { Seed = seed, Graph = graph, CandidateCount = e.Ranked.Count, SeaCandidates = e.SeaCount, FieldSeconds = fieldSec };
|
||||||
|
ulong tw0 = Time.GetTicksMsec();
|
||||||
|
foreach (float cap in caps)
|
||||||
|
{
|
||||||
|
bool main = cap == capM;
|
||||||
|
GD.Print($" walks at cap {cap:F0} m{(main ? " (THE CAP)" : " (sensitivity)")}:");
|
||||||
|
var res = FlowThroughRouting.Run(promoted, plan, graph, field, prep, p2.Height, mapSize, isOcean, isClassifyWater, sea, cap,
|
||||||
|
m => { if (main) GD.Print(m); }, confluence: true);
|
||||||
|
r.ByCap[cap] = res;
|
||||||
|
if (main) r.Main = res;
|
||||||
|
GD.Print($" → {res.Trunks} trunk + {res.FlowThrough} flow-through + {res.LakeTerminal} lake-terminal = {res.Trunks + res.FlowThrough + res.LakeTerminal} kept; " +
|
||||||
|
$"{res.DroppedDry + res.DroppedClosed} dropped ({res.DroppedDry} dry, {res.DroppedClosed} closed); {res.Joined} joined; walled basins {res.WalledIds.Count}");
|
||||||
|
}
|
||||||
|
r.WalkSeconds = (Time.GetTicksMsec() - tw0) / 1000.0;
|
||||||
|
|
||||||
|
var mainRes = r.Main;
|
||||||
|
foreach (var fr in mainRes.Rivers)
|
||||||
|
GD.Print($" #{fr.Candidate.Rank,-3} {fr.Candidate.DrainagePx,10:N0} px {FlowThroughRouting.ClassName(fr).ToUpperInvariant(),-13} " +
|
||||||
|
$"hops {fr.Chain.Count} lakes {fr.LakesPassed} max rim {fr.MaxHopClimbM,5:F1} m lowland {fr.LowlandLenPx,6:F0} px" +
|
||||||
|
(fr.Routed.Joined ? $" → into #{fr.Routed.ConfluenceParentRank}" : "") + $" {fr.Why}");
|
||||||
|
FlowThroughRouting.BuildCappedField(mainRes, plan, graph, field, mapSize, isOcean);
|
||||||
|
FlowThroughRouting.RankHeroLakes(mainRes, graph, prep);
|
||||||
|
MeasureMouths(r, mainRes);
|
||||||
|
r.Spread = Spread(mainRes, mapSize);
|
||||||
|
|
||||||
|
GD.Print($" ⭐ HYDROLOGY at cap {capM:F0} m: {mainRes.Trunks} trunk + {mainRes.FlowThrough} flow-through + {mainRes.LakeTerminal} lake-terminal kept, {mainRes.DroppedDry + mainRes.DroppedClosed} dropped, {mainRes.Joined} joined" +
|
||||||
|
$" — {r.DistinctMouths} distinct sea mouths{(r.SharedMouths.Count > 0 ? $" ⚠ shared: {string.Join(", ", r.SharedMouths)}" : "")}; spread {r.Spread}");
|
||||||
|
GD.Print($" confluence: {mainRes.RescuedByConfluence} would-be-dropped river(s) rescued by joining a kept river; {mainRes.DroppedByConfluence} kept-on-its-own river(s) dropped by joining a dropped one");
|
||||||
|
GD.Print($" field: {100.0 * mainRes.CellsToSea / Math.Max(1, mainRes.LandCells):F1} % of land drains to the sea, {100.0 * mainRes.CellsToWalledLake / Math.Max(1, mainRes.LandCells):F1} % to a walled lake, " +
|
||||||
|
$"{100.0 * mainRes.CellsToWalledDry / Math.Max(1, mainRes.LandCells):F1} % to a walled dry sink, {100.0 * mainRes.CellsStuck / Math.Max(1, mainRes.LandCells):F2} % stuck; {mainRes.WalledIds.Count} walled basins");
|
||||||
|
GD.Print($" edge cross-check vs BasinGraph: {mainRes.EdgeAgree} agree / {mainRes.EdgeDisagree} disagree; lowground fallbacks into lakes {mainRes.LowgroundFallbacks}");
|
||||||
|
if (mainRes.HeroLakes.Count > 0)
|
||||||
|
{
|
||||||
|
var h = mainRes.HeroLakes[0];
|
||||||
|
GD.Print($" ⭐ HERO-LAKE CANDIDATE (data, NOT filled): basin #{h.BasinId} — lake {h.LakeCells:N0} px, fill volume {h.FillVolumeMPx / 1e6:F2} M m·px, river R{h.RiverRank} {h.RiverLenPx:F0} px, {h.RiversThrough} river(s) through; score {h.Score:F3}");
|
||||||
|
}
|
||||||
|
else GD.Print(" hero-lake candidate: none — no sea-reaching river passes through a lake on this seed");
|
||||||
|
|
||||||
|
WriteRiverCsv(batchRoot, r, caps);
|
||||||
|
|
||||||
|
ulong tr0 = Time.GetTicksMsec();
|
||||||
|
RenderSeed(batchRoot, r, plan, isOcean, isClassifyWater, p2, mapSize, sea, capM, skipRaw);
|
||||||
|
r.RenderSeconds = (Time.GetTicksMsec() - tr0) / 1000.0;
|
||||||
|
|
||||||
|
// ═══ ⛔ …and asserted byte-identical AFTER everything ═══
|
||||||
|
ulong hRenderAfter = Digest(p2.Height, mapSize);
|
||||||
|
ulong hClassifyAfter = Digest(p2.HeightClassify, mapSize);
|
||||||
|
if (hRenderAfter != hRenderBefore || hClassifyAfter != hClassifyBefore)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"[FlowThrough] RED-LINE VIOLATION: a height field CHANGED across routing / rendering.\n" +
|
||||||
|
$" render {hRenderBefore:X16} -> {hRenderAfter:X16}\n" +
|
||||||
|
$" classify {hClassifyBefore:X16} -> {hClassifyAfter:X16}\n" +
|
||||||
|
"This task routes and draws — it must never mutate a height, fill water, or carve. Refusing to continue.");
|
||||||
|
r.RenderDigest = hRenderBefore; r.ClassifyDigest = hClassifyBefore;
|
||||||
|
GD.Print($" ✅ RED LINE HELD: render {hRenderBefore:X16} and classify {hClassifyBefore:X16} byte-identical — no height mutated, no water filled, no bed carved.");
|
||||||
|
r.Ms = Time.GetTicksMsec() - t0;
|
||||||
|
results.Add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteIndex(batchRoot, mapSize, seeds, results, promoteN, lakeMinPx, capM, caps, dpDefaults, skipRaw);
|
||||||
|
GD.Print("\n==================================================================");
|
||||||
|
GD.Print($" DONE — {batchRoot}");
|
||||||
|
GD.Print(" ⛔ TASTE GATE: the hydrology is PRESENTED, not locked. The cap is a knob; nothing graduated.");
|
||||||
|
GD.Print(" ⛔ ROUTING + DATA ONLY: no height mutated, no water filled, no bed carved — asserted per seed. Flow field emitted as a map; serialization deferred.");
|
||||||
|
GD.Print("==================================================================");
|
||||||
|
GetTree().Quit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong Digest(float[,] f, int n)
|
||||||
|
{
|
||||||
|
ulong h = 14695981039346656037UL;
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
uint bits = (uint)BitConverter.SingleToInt32Bits(f[x, y]);
|
||||||
|
for (int b = 0; b < 4; b++) { h ^= (byte)(bits >> (b * 8)); h *= 1099511628211UL; }
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MeasureMouths(SeedResult r, FlowThroughRouting.Result res)
|
||||||
|
{
|
||||||
|
var at = new Dictionary<int, List<int>>();
|
||||||
|
foreach (var fr in res.Rivers)
|
||||||
|
{
|
||||||
|
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
|
||||||
|
if (!at.TryGetValue(fr.MouthCell, out var l)) { l = new List<int>(); at[fr.MouthCell] = l; }
|
||||||
|
l.Add(fr.Candidate.Rank);
|
||||||
|
}
|
||||||
|
r.DistinctMouths = at.Count;
|
||||||
|
foreach (var kv in at) if (kv.Value.Count > 1) r.SharedMouths.Add("#" + string.Join("+#", kv.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Spread(FlowThroughRouting.Result res, int n)
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<string, int>(); int total = 0;
|
||||||
|
foreach (var fr in res.Rivers)
|
||||||
|
{
|
||||||
|
if (fr.Dropped || fr.Routed.Joined || fr.Terminus != FlowThroughRouting.Terminus.Ocean || fr.MouthCell < 0) continue;
|
||||||
|
string c = Compass(fr.MouthCell / n, fr.MouthCell % n, n);
|
||||||
|
counts.TryGetValue(c, out int cur); counts[c] = cur + 1; total++;
|
||||||
|
}
|
||||||
|
if (total == 0) return "none";
|
||||||
|
var order = new[] { "N", "NE", "E", "SE", "S", "SW", "W", "NW", "centre" };
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (string k in order) if (counts.TryGetValue(k, out int v)) parts.Add($"{k}x{v}");
|
||||||
|
return $"{string.Join(" ", parts)} ({counts.Count} of 8 compass sectors)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Compass(float x, float y, int n)
|
||||||
|
{
|
||||||
|
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half;
|
||||||
|
const float band = 0.35f;
|
||||||
|
string ns = dy < -band ? "N" : dy > band ? "S" : "";
|
||||||
|
string ew = dx < -band ? "W" : dx > band ? "E" : "";
|
||||||
|
return ns + ew == "" ? "centre" : ns + ew;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ChainText(FlowThroughRouting.FlowRiver fr)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var h in fr.Chain)
|
||||||
|
parts.Add($"#{h.BasinId}{(h.IsLake ? "L" : "d")}:{h.ClimbM:F1}{(h.Walled ? "!" : "")}");
|
||||||
|
return string.Join(" > ", parts) + (fr.Terminus == FlowThroughRouting.Terminus.Ocean ? " > SEA" : fr.Terminus == FlowThroughRouting.Terminus.Closed ? " > closed" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteRiverCsv(string batchRoot, SeedResult r, float[] caps)
|
||||||
|
{
|
||||||
|
var res = r.Main; int n = r.Graph.MapSize;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("rank,class,own_terminus,root_terminus,drainage_px,is_sea_candidate,terminal_basin,hops,lakes_passed,chain,max_hop_climb_m,total_climb_m," +
|
||||||
|
"terminus_basin,mouth_x,mouth_y,joined,confluence_parent_rank,junction_x,junction_y,stem_len_px,lowland_len_px,total_len_px,lowground_fallbacks,edge_disagreements,width_px,why");
|
||||||
|
foreach (float cap in caps) sb.Append($",class_at_cap{cap:F0}");
|
||||||
|
sb.AppendLine();
|
||||||
|
foreach (var fr in res.Rivers)
|
||||||
|
{
|
||||||
|
var c = fr.Candidate; var rr = fr.Routed;
|
||||||
|
int fb = 0, dis = 0; foreach (var h in fr.Chain) { if (h.UsedLowgroundFallback) fb++; if (h.SpillCell >= 0 && !h.EdgeAgreesWithGraph) dis++; }
|
||||||
|
sb.Append($"{c.Rank},{FlowThroughRouting.ClassName(fr)},{FlowThroughRouting.TerminusName(fr.Terminus)},{FlowThroughRouting.TerminusName(fr.RootTerminus)},{c.DrainagePx},{(c.IsSea ? "yes" : "no")}," +
|
||||||
|
$"{(c.IsSea ? 0 : (fr.Chain.Count > 0 ? fr.Chain[0].BasinId : c.BasinId))},{fr.Chain.Count},{fr.LakesPassed},\"{ChainText(fr)}\",{fr.MaxHopClimbM:F2},{fr.TotalClimbM:F2}," +
|
||||||
|
$"{fr.TerminusBasinId},{(fr.MouthCell >= 0 ? (fr.MouthCell / n).ToString() : "")},{(fr.MouthCell >= 0 ? (fr.MouthCell % n).ToString() : "")}," +
|
||||||
|
$"{(rr.Joined ? "yes" : "no")},{(rr.Joined ? rr.ConfluenceParentRank.ToString() : "")},{(rr.Joined ? rr.JunctionCell.x.ToString() : "")},{(rr.Joined ? rr.JunctionCell.y.ToString() : "")}," +
|
||||||
|
$"{fr.StemLenPx:F1},{fr.LowlandLenPx:F1},{fr.TotalLenPx:F1},{fb},{dis},{DrainageRenderer.StemWidthFixed(c.DrainagePx)},\"{fr.Why}\"");
|
||||||
|
foreach (float cap in caps)
|
||||||
|
{
|
||||||
|
var other = r.ByCap[cap];
|
||||||
|
FlowThroughRouting.FlowRiver o = null;
|
||||||
|
foreach (var x in other.Rivers) if (x.Candidate.Rank == c.Rank) { o = x; break; }
|
||||||
|
sb.Append($",{(o == null ? "" : FlowThroughRouting.ClassName(o))}");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, $"rivers_{r.Seed}.csv"), sb.ToString());
|
||||||
|
|
||||||
|
// The hero-lake ranking, as data.
|
||||||
|
var hb = new StringBuilder();
|
||||||
|
hb.AppendLine("rank,basin_id,lake_cells,fill_volume_m_px,river_rank,river_len_px,rivers_through,score");
|
||||||
|
for (int i = 0; i < res.HeroLakes.Count; i++)
|
||||||
|
{
|
||||||
|
var h = res.HeroLakes[i];
|
||||||
|
hb.AppendLine($"{i + 1},{h.BasinId},{h.LakeCells},{h.FillVolumeMPx:F0},{h.RiverRank},{h.RiverLenPx:F0},{h.RiversThrough},{h.Score:F4}");
|
||||||
|
}
|
||||||
|
WriteText(Path.Combine(batchRoot, $"hero_lakes_{r.Seed}.csv"), hb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderSeed(string batchRoot, SeedResult r, DrainageAnalysis.Plan plan, bool[] isOcean, bool[] isClassifyWater,
|
||||||
|
Pass2Result p2, int n, float sea, float capM, bool skipRaw)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||||
|
var res = r.Main;
|
||||||
|
int kept = res.Trunks + res.FlowThrough + res.LakeTerminal;
|
||||||
|
|
||||||
|
Image baseImg = HydrologyRenderer.Base(p2.Height, n, sea);
|
||||||
|
HydrologyRenderer.Hydrology(res, res.CappedDir, plan, r.Graph, baseImg, n, isOcean, isClassifyWater,
|
||||||
|
$"SEED {r.Seed} - HYDROLOGY: {kept} RIVERS OF THE PURE TOP {res.Rivers.Count}, CHAINING THROUGH LAKES AND LOW GROUND TO THE SEA. CAP {capM:F0} M.",
|
||||||
|
$"{res.Trunks} NATURAL TRUNK + {res.FlowThrough} FLOW-THROUGH TO THE SEA + {res.LakeTerminal} LAKE-TERMINAL; {res.DroppedDry + res.DroppedClosed} DROPPED; {res.Joined} JOINED. {r.DistinctMouths} DISTINCT SEA MOUTHS. SPREAD: {r.Spread.ToUpperInvariant()}",
|
||||||
|
$"THE FIELD: {100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F0}% OF LAND DRAINS TO THE SEA, {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F0}% TO A WALLED LAKE, {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F0}% TO A WALLED DRY SINK ({res.WalledIds.Count} WALLED BASINS). TASTE GATE - NOTHING LOCKED")
|
||||||
|
.SavePng(Path.Combine(dir, $"hydrology_{r.Seed}.png"));
|
||||||
|
|
||||||
|
HydrologyRenderer.FlowDirection(res.CappedDir, res.CappedAcc, plan, r.Graph, res.WalledIds, n, isOcean, isClassifyWater,
|
||||||
|
$"SEED {r.Seed} - FLOW DIRECTION FIELD AT CAP {capM:F0} M: PER LAND CELL, THE D8 HEADING ON THE OVERFLOW SURFACE (FULLFILLED), CHAINED OVER SPILLS TOWARD THE SEA",
|
||||||
|
$"{100.0 * res.CellsToSea / Math.Max(1, res.LandCells):F1}% OF LAND DRAINS TO THE SEA; {100.0 * res.CellsToWalledLake / Math.Max(1, res.LandCells):F1}% ENDS IN A WALLED LAKE; {100.0 * res.CellsToWalledDry / Math.Max(1, res.LandCells):F1}% IN A WALLED DRY SINK; {100.0 * res.CellsStuck / Math.Max(1, res.LandCells):F2}% STUCK. {res.WalledIds.Count} WALLED BASINS (RIM > CAP).",
|
||||||
|
"DATA MAP - THE REFERENCE FOR PLACEMENT, FLOODING (C3) AND IRRIGATION (C4). HELD IN MEMORY; SERIALIZATION DEFERRED TO THE COLUMN WATER-DATA PHASE.")
|
||||||
|
.SavePng(Path.Combine(dir, $"flow_direction_{r.Seed}.png"));
|
||||||
|
|
||||||
|
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||||||
|
r.GMin = gmin; r.GMax = gmax;
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIndex(string batchRoot, int mapSize, int[] seeds, List<SeedResult> rows, int promoteN, int lakeMinPx,
|
||||||
|
float capM, float[] caps, DrainageAnalysis.Params def, bool skipRaw)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int primary = seeds.Length > 0 ? seeds[0] : 0;
|
||||||
|
sb.AppendLine($"# Batch 05 — flow-through routing: the hydrology map (cap {capM:F0} m)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**⛔ TASTE GATE. Nothing is locked** — the cap is a knob, the count falls out, nothing is graduated. This is the");
|
||||||
|
sb.AppendLine("routing finale: if the hydrology map reads right, routing is done and the next step is the bed carve.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**⛔ ROUTING AND DATA ONLY. No height mutated, no water filled or created, no bed carved** — asserted per seed by an");
|
||||||
|
sb.AppendLine("FNV digest of both height fields taken before the basin graph was built and after the last plate was drawn.");
|
||||||
|
sb.AppendLine("`DrainageAnalysis` and `BasinGraph` reused. The flow-direction field is emitted as a map and held in memory; its");
|
||||||
|
sb.AppendLine("serialization is deferred to the blueprint / column water-data phase.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## 👉 The pick");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Open **`{primary}/hydrology_{primary}.png`**. Then the other three: " +
|
||||||
|
string.Join(", ", Array.ConvertAll(Array.FindAll(seeds, x => x != primary), x => $"`{x}`")) + ".");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||||||
|
sb.AppendLine("> **Does the island's water now read as one connected, natural, gorgeous system — rivers chaining through lakes");
|
||||||
|
sb.AppendLine("> and low ground to the sea, dead-ends dropped, the whole network legible?** Then the count, the spread, and");
|
||||||
|
sb.AppendLine($"> whether the cap (`ISLA_FLOW_CAP_M`, {capM:F0} m) wants moving — the sensitivity table below says how the counts move.");
|
||||||
|
sb.AppendLine(">");
|
||||||
|
sb.AppendLine("> **Reading the map.** Pale-blue rivers reach the sea (natural trunks and flow-through chains alike; square = mouth).");
|
||||||
|
sb.AppendLine("> Amber rivers are lake-terminal (disc = where the river enters its lake). A river's span across a lake is water,");
|
||||||
|
sb.AppendLine("> not a drawn channel. White dot = confluence. A faint red ghost is a river's upland stem that was considered and");
|
||||||
|
sb.AppendLine("> dropped — its chain walled at a dry sink. The streamline texture is the flow-direction field.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐⭐ The hydrology, per seed (at the cap)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | trunk | **flow-through → sea** | **lake-terminal** | **kept** | dropped (dry / closed) | joined | rescued by confluence | **distinct sea mouths** | spread | land → sea / walled lake / walled dry |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var m = r.Main;
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | {m.Trunks} | **{m.FlowThrough}** | **{m.LakeTerminal}** | **{m.Trunks + m.FlowThrough + m.LakeTerminal}** of {m.Rivers.Count} | {m.DroppedDry} / {m.DroppedClosed} | {m.Joined} | {m.RescuedByConfluence} | **{r.DistinctMouths}**{(r.SharedMouths.Count > 0 ? $" ⚠ {string.Join(", ", r.SharedMouths)}" : "")} | {r.Spread} | " +
|
||||||
|
$"{100.0 * m.CellsToSea / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledLake / Math.Max(1, m.LandCells):F0} % / {100.0 * m.CellsToWalledDry / Math.Max(1, m.LandCells):F0} % |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ The cap — how the counts move (the connected-vs-inland knob, off one run)");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("| Seed |");
|
||||||
|
foreach (float cap in caps) sb.Append($" @ {cap:F0} m: kept (sea + lake) / dropped / joined |");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("|---|" + string.Concat(Array.ConvertAll(caps, _ => "---|")));
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
sb.Append($"| `{r.Seed}` |");
|
||||||
|
foreach (float cap in caps)
|
||||||
|
{
|
||||||
|
var m = r.ByCap[cap];
|
||||||
|
sb.Append($" {(cap == capM ? "**" : "")}{m.Trunks + m.FlowThrough + m.LakeTerminal} ({m.Trunks + m.FlowThrough} + {m.LakeTerminal}) / {m.DroppedDry + m.DroppedClosed} / {m.Joined}{(cap == capM ? "**" : "")} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("*A higher cap lets rivers overflow deeper basins: more reach the sea, fewer end at lakes or drop. The per-river");
|
||||||
|
sb.AppendLine("class at every cap is in `rivers_<seed>.csv` (`class_at_capNN` columns), so the flip points are readable per river.*");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ The hero-lake candidate — DATA, not filled");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Among lakes a sea-reaching, un-joined river flows *through*, ranked by √(fill volume × attached river length), each");
|
||||||
|
sb.AppendLine("normalised to the seed's maximum. Recorded intent: procedural, executed post-water-render. **Nothing is filled.**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| Seed | basin | lake px | fill volume (M m·px) | river | river length px | rivers through | score | runner-up |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var hl = r.Main.HeroLakes;
|
||||||
|
if (hl.Count == 0) { sb.AppendLine($"| `{r.Seed}` | — | | | | | | | no sea-reaching river passes through a lake |"); continue; }
|
||||||
|
var h = hl[0];
|
||||||
|
string ru = hl.Count > 1 ? $"#{hl[1].BasinId} ({hl[1].Score:F2})" : "—";
|
||||||
|
sb.AppendLine($"| `{r.Seed}` | **#{h.BasinId}** | {h.LakeCells:N0} | {h.FillVolumeMPx / 1e6:F2} | R{h.RiverRank} | {h.RiverLenPx:F0} | {h.RiversThrough} | {h.Score:F3} | {ru} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## ⭐ Per river — the chain each one walked");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Chain notation: `#id L|d : climb` per basin entered (L = lake basin, d = dry), `!` = walled there. The climb is the");
|
||||||
|
sb.AppendLine("basin's floor→spill (`SpillClimbM`, render surface, clamped at sea as `RouteTo`).");
|
||||||
|
sb.AppendLine();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"### `{r.Seed}`");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| rank | class | drainage px | hops | lakes | max rim m | chain | terminus | joins | lowland px |");
|
||||||
|
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|");
|
||||||
|
foreach (var fr in r.Main.Rivers)
|
||||||
|
{
|
||||||
|
var c = fr.Candidate; int n = r.Graph.MapSize;
|
||||||
|
string cls = fr.Dropped ? "~~dropped~~" : fr.Trunk ? "trunk" : fr.ReachesSea ? "**flow-through**" : "**lake-terminal**";
|
||||||
|
string term = fr.Routed.Joined ? $"→ tributary of #{fr.Routed.ConfluenceParentRank}"
|
||||||
|
: fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"sea ({fr.MouthCell / n},{fr.MouthCell % n})"
|
||||||
|
: fr.Terminus == FlowThroughRouting.Terminus.Lake ? $"lake #{fr.TerminusBasinId}"
|
||||||
|
: fr.Terminus == FlowThroughRouting.Terminus.DrySink ? $"dry sink #{fr.TerminusBasinId} → dropped" : "closed → dropped";
|
||||||
|
sb.AppendLine($"| #{c.Rank} | {cls} | {c.DrainagePx:N0} | {fr.Chain.Count} | {fr.LakesPassed} | {(fr.Chain.Count > 0 ? fr.MaxHopClimbM.ToString("F1") : "—")} | `{(c.IsSea ? "trunk" : ChainText(fr))}` | {term} | {(fr.Routed.Joined ? $"#{fr.Routed.ConfluenceParentRank}" : "—")} | {fr.LowlandLenPx:F0} |");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"*Candidates {r.CandidateCount} ({r.SeaCandidates} sea) · walled basins at the cap {r.Main.WalledIds.Count} · edge cross-check vs the basin graph {r.Main.EdgeAgree} agree / {r.Main.EdgeDisagree} disagree · " +
|
||||||
|
$"lowground fallbacks into lakes {r.Main.LowgroundFallbacks} · field {r.FieldSeconds:F1}s, walks ×{caps.Length} {r.WalkSeconds:F1}s, plates {r.RenderSeconds:F0}s, seed {r.Ms / 1000.0:F0}s · " +
|
||||||
|
$"digests render `{r.RenderDigest:X16}` classify `{r.ClassifyDigest:X16}` · grayscale {r.GMin:F4}..{r.GMax:F4} raw = {WorldScale.MetresFromRaw(r.GMin):F1}..{WorldScale.MetresFromRaw(r.GMax):F1} m.*");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
sb.AppendLine("## The model, as run");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("1. **The field** — per land cell, the D8 heading on `Plan.FullFilled` (the overflow surface), cap-independent; on it a");
|
||||||
|
sb.AppendLine(" basin's minimum is its spill, so descent leaves every basin over its spill into the next (rivers/04 §0.2).");
|
||||||
|
sb.AppendLine("2. **The walk** — each promoted river follows the field from its terminal. Every basin entered is checked once:");
|
||||||
|
sb.AppendLine($" floor→spill climb ≤ {capM:F0} m → overflow; > {capM:F0} m → walled. Uniform for lake and dry basins.");
|
||||||
|
sb.AppendLine("3. **Lakes** — inside an `IsLake` basin the river runs down the REAL terrain (`Plan.Dir`) into the basin's own classify");
|
||||||
|
sb.AppendLine(" water (rivers/03c fix B's lowground route as the fallback when the descent pools short), crosses the lake as water,");
|
||||||
|
sb.AppendLine(" and leaves from the lake's lowest `FullFilled` cell over the spill. A dry basin is crossed on the field as a visible line.");
|
||||||
|
sb.AppendLine($"4. **Disposition** — reaches `OceanMask` → keep; walls at `IsLake` (≥ {lakeMinPx:N0} px, classify) → keep (lake-terminal);");
|
||||||
|
sb.AppendLine(" walls dry or puddle-only → drop. Read through the confluence root, so a river that joins a kept river is kept.");
|
||||||
|
sb.AppendLine("5. **Confluence** — rivers/03b's, unchanged: biggest-first, true cell intersection, never proximity.");
|
||||||
|
sb.AppendLine("6. **The field at the cap** — every walled basin's cells re-pointed onto the real terrain, so flow entering one ends at");
|
||||||
|
sb.AppendLine(" its floor. That is the `flow_direction_<seed>.png` plate and the streamline texture on the hydrology map.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"**⚠ NOT touched:** `DrainageAnalysis`, `BasinGraph`; `EndorheicMinDepthM` {def.EndorheicMinDepthM} m / `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0}; `MinOutletSeparationPx` {def.MinOutletSeparationPx}.");
|
||||||
|
sb.AppendLine("Termini by `OceanMask` and `IsLake` only — no bare `h < sea`.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("## Files");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("| File | What it is |");
|
||||||
|
sb.AppendLine("|---|---|");
|
||||||
|
sb.AppendLine("| `<seed>/hydrology_<seed>.png` | **the showpiece** — relief, lakes as water, the field as streamlines, the kept rivers outlined at the fixed width law, confluences, mouths, dropped ghosts |");
|
||||||
|
sb.AppendLine("| `<seed>/flow_direction_<seed>.png` | **the data map** — the field at the cap: hue by heading, sinks black, walled basins darkened |");
|
||||||
|
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||||||
|
sb.AppendLine("| `rivers_<seed>.csv` | per river: class, own vs root terminus, the chain with per-hop climbs, terminus, confluence, lengths, and the class at every preview cap |");
|
||||||
|
sb.AppendLine("| `hero_lakes_<seed>.csv` | the hero-lake ranking, as data |");
|
||||||
|
if (skipRaw) sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — byte-identical to `chat2/11_erosion` (rivers/01). |");
|
||||||
|
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/05_flow_through_routing.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 float EnvFloat(string k, float fallback) =>
|
||||||
|
float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float 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;
|
||||||
|
}
|
||||||
|
private static float[] EnvFloats(string k, float[] fallback)
|
||||||
|
{
|
||||||
|
string v = EnvStr(k, null);
|
||||||
|
if (v == null) return fallback;
|
||||||
|
var outp = new List<float>();
|
||||||
|
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
|
||||||
|
return outp.Count > 0 ? outp.ToArray() : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_MAPSIZE gallery size (default 8192)
|
/// ISLA_MAPSIZE gallery size (default 8192)
|
||||||
/// ISLA_CALIB_SIZE curve calibration size (default 2048)
|
/// ISLA_CALIB_SIZE curve calibration size (default 2048)
|
||||||
/// ISLA_SEEDS the gallery seeds (default: the 2 anchors + 6 fresh below)
|
/// ISLA_SEEDS the gallery seeds (default: the 2 anchors + 6 fresh below)
|
||||||
/// ISLA_SKIP_ANCHOR_CHECK=1 skip the 4096 bit-identity check against the 09 frag_4 dumps
|
/// ISLA_SKIP_ANCHOR_CHECK=1 skip the 4096 interior-locked invariant check
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class FragGalleryTool : Node
|
public partial class FragGalleryTool : Node
|
||||||
{
|
{
|
||||||
|
|
@ -35,17 +35,22 @@ namespace IslaApocalypse.Tools
|
||||||
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
|
/// <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[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
|
||||||
|
|
||||||
// ═══ THE FROZEN frag_4 SETTING — every value pinned explicitly (chat2/09 batch, level 4) ═══
|
// ═══ THE FROZEN frag_4 SETTING (chat2/09 batch, level 4) ═══
|
||||||
private const float FrozenFragmentAmp = 0.5f;
|
//
|
||||||
private const float FrozenFragmentFreq = 12f;
|
// ⭐ rivers/01: these were the ONLY home of the locked values. They now ALIAS
|
||||||
private const float FrozenBandCentre = 0.66f;
|
// `TerrainShapeV1`, which is itself the assertion target for `TerrainGenConfig`'s defaults —
|
||||||
private const float FrozenBandHalfWidth = 0.18f;
|
// so the chain is: bare defaults → asserted against TerrainShapeV1 → printed here. One value,
|
||||||
private const bool FrozenBitesOnly = false;
|
// one place, and a throw if the generator ever stops agreeing with it.
|
||||||
private const float FrozenStretch = 2f;
|
private const float FrozenFragmentAmp = TerrainShapeV1.FragmentAmp;
|
||||||
private const float FrozenBandStart = 0.70f;
|
private const float FrozenFragmentFreq = TerrainShapeV1.FragmentFreq;
|
||||||
private const float FrozenBandFeather = 0.05f;
|
private const float FrozenBandCentre = TerrainShapeV1.BandCentre;
|
||||||
private const bool FrozenStretchSinker = true;
|
private const float FrozenBandHalfWidth = TerrainShapeV1.BandHalfWidth;
|
||||||
private const float FrozenSpeckFrac = 2.5e-7f; // 09's low speck revert (≈ 4 cells at 4096, ≈ 17 at 8192)
|
private const bool FrozenBitesOnly = TerrainShapeV1.BitesOnly;
|
||||||
|
private const float FrozenStretch = TerrainShapeV1.Stretch;
|
||||||
|
private const float FrozenBandStart = TerrainShapeV1.BandStart;
|
||||||
|
private const float FrozenBandFeather = TerrainShapeV1.BandFeather;
|
||||||
|
private const bool FrozenStretchSinker = TerrainShapeV1.StretchSinker;
|
||||||
|
private const float FrozenSpeckFrac = TerrainShapeV1.SpeckFrac; // ≈ 4 cells at 4096, ≈ 17 at 8192
|
||||||
|
|
||||||
private const int DefaultMapSize = 8192;
|
private const int DefaultMapSize = 8192;
|
||||||
private const int DefaultCalibSize = 2048;
|
private const int DefaultCalibSize = 2048;
|
||||||
|
|
@ -79,6 +84,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 10);
|
int task = EnvInt("ISLA_TASK", 10);
|
||||||
string descr = EnvStr("ISLA_BATCH", "frag4_seed_gallery");
|
string descr = EnvStr("ISLA_BATCH", "frag4_seed_gallery");
|
||||||
|
|
@ -87,9 +96,7 @@ namespace IslaApocalypse.Tools
|
||||||
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
|
int[] seedsEnv = EnvSeeds("ISLA_SEEDS", null);
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
bool skipAnchor = EnvStr("ISLA_SKIP_ANCHOR_CHECK", "0") == "1";
|
bool skipAnchor = EnvStr("ISLA_SKIP_ANCHOR_CHECK", "0") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
|
|
||||||
string t09Source = EnvStr("ISLA_T09_SOURCE", "09_coastal_fragment");
|
|
||||||
|
|
||||||
var seeds = new List<int>(AnchorSeeds); if (seedsEnv == null) seeds.AddRange(FreshSeeds); else { seeds.Clear(); seeds.AddRange(seedsEnv); }
|
var seeds = new List<int>(AnchorSeeds); if (seedsEnv == null) seeds.AddRange(FreshSeeds); else { seeds.Clear(); seeds.AddRange(seedsEnv); }
|
||||||
var anchorSet = new HashSet<int>(AnchorSeeds);
|
var anchorSet = new HashSet<int>(AnchorSeeds);
|
||||||
|
|
@ -118,17 +125,40 @@ namespace IslaApocalypse.Tools
|
||||||
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
|
||||||
GD.Print($" {knots}");
|
GD.Print($" {knots}");
|
||||||
|
|
||||||
TerrainGenConfig Frozen(int size, int seed, string label, bool frag = true, bool revert = true, bool stretch = true) => new TerrainGenConfig
|
// ⭐⭐ rivers/01: THE FROZEN SETTING IS NOW THE BARE DEFAULT.
|
||||||
|
//
|
||||||
|
// Every `Frozen*` constant above was re-homed into `TerrainGenConfig`'s defaults by the
|
||||||
|
// re-baseline, so this helper no longer SETS the shape — it only ABLATES it, for the
|
||||||
|
// family-off halves of the regression checks. That is the whole point: this batch is the
|
||||||
|
// `terrain-shape-v1` acceptance, and it can only prove the defaults reproduce the locked
|
||||||
|
// shape if it reads them instead of re-stating them.
|
||||||
|
//
|
||||||
|
// ⚠ The `frag` / `revert` / `stretch` flags are ABLATIONS ONLY. All three true = the bare
|
||||||
|
// default = the locked shape; `TerrainShapeV1.Assert` below is what keeps that claim
|
||||||
|
// honest if a default ever drifts.
|
||||||
|
TerrainGenConfig Frozen(int size, int seed, string label, bool frag = true, bool revert = true, bool stretch = true)
|
||||||
{
|
{
|
||||||
MapSize = size, Seed = seed, VariantLabel = label,
|
var c = new TerrainGenConfig
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
{
|
||||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
CoastShelf = false, Offshore = new OffshoreSettings(),
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = FrozenSpeckFrac,
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
SouthStretch = stretch ? FrozenStretch : 0f, SouthBandStartFrac = FrozenBandStart, SouthBandFeatherFrac = FrozenBandFeather, StretchSinker = FrozenStretchSinker,
|
};
|
||||||
FragmentAmp = frag ? FrozenFragmentAmp : 0f, FragmentFreqPerMapWidth = FrozenFragmentFreq,
|
if (!stretch) c.SouthStretch = 0f;
|
||||||
FragmentBandCentre = FrozenBandCentre, FragmentBandHalfWidth = FrozenBandHalfWidth, FragmentBitesOnly = FrozenBitesOnly,
|
if (!frag) c.FragmentAmp = 0f;
|
||||||
};
|
if (!revert) c.SpeckRevert = false;
|
||||||
|
// This batch is render-only shape: erosion is a later pass and never ran here.
|
||||||
|
c.Erosion = false;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠⚠ THE DEFAULT-DRIFT GUARD (rivers/01). The gallery above stopped STATING the locked shape
|
||||||
|
// and started READING it. If a default ever moves, every render silently moves with it and
|
||||||
|
// the batch still "passes" — so the claim is asserted, loudly, before a pixel is drawn.
|
||||||
|
// The `Frozen*` constants below are unchanged in value; their ROLE flipped from source to
|
||||||
|
// assertion target. → Tools/Scripts/TerrainShapeV1.cs
|
||||||
|
TerrainShapeV1.Assert("FragGallery");
|
||||||
|
GD.Print($" defaults : ✅ {TerrainShapeV1.Describe()}");
|
||||||
|
|
||||||
// ═══ 1. THE ORACLE — no code change, same setting ═══
|
// ═══ 1. THE ORACLE — no code change, same setting ═══
|
||||||
GD.Print($"\n--- 1. ORACLE: the setting is the 09 frag_4 setting, and nothing upstream moved ---");
|
GD.Print($"\n--- 1. ORACLE: the setting is the 09 frag_4 setting, and nothing upstream moved ---");
|
||||||
|
|
@ -137,25 +167,32 @@ namespace IslaApocalypse.Tools
|
||||||
var offCfg = Frozen(calibSize, AnchorSeeds[0], "off", frag: false, revert: false, stretch: false);
|
var offCfg = Frozen(calibSize, AnchorSeeds[0], "off", frag: false, revert: false, stretch: false);
|
||||||
Pass1Result p1 = Topography.Generate(offCfg);
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{AnchorSeeds[0]}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{AnchorSeeds[0]}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)",
|
||||||
|
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
|
// ⚑ RETIRED at rivers/01 — a8 (`08_southern_stretch_explore`) and a9 (`09_coastal_fragment`).
|
||||||
|
// Both are EXPLORATION ladders this gallery was built to CONCLUDE: chat2/09 climbed the
|
||||||
|
// fragmentation ladder, chat2/10 froze rung 4 across 8 seeds, and the developer tagged the
|
||||||
|
// result `terrain-shape-v1`. Since rivers/01 that frozen setting IS the bare default, and
|
||||||
|
// `TerrainShapeV1.Assert` + a10 assert it directly — asserting it a third time through the
|
||||||
|
// rungs it was chosen from is circular, and 08's dump is at stretch 3 (off-shape) besides.
|
||||||
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
|
|
||||||
if (!skipAnchor)
|
if (!skipAnchor)
|
||||||
{
|
{
|
||||||
foreach (int seed in AnchorSeeds)
|
foreach (int seed in AnchorSeeds)
|
||||||
{
|
{
|
||||||
// ⭐ a9 — the frozen setting at the 09 batch's size reproduces the 09 frag_4 field bit for bit.
|
// ⭐ r KEPT and SELF-ANCHORED — the interior-locked invariant is coastal fragmentation's
|
||||||
string t09Dump = Path.Combine(ToolingPaths.BatchesRoot, t09Source, $"{seed}_frag_4", "height.f32");
|
// load-bearing claim (it must touch the coastal window and NOTHING else), and it needs
|
||||||
|
// no external dump: both fields are generated here, from the bare defaults and from the
|
||||||
|
// same defaults with frag ablated off. That is what let 08 and 09 retire intact.
|
||||||
var c9 = Frozen(AnchorCheckSize, seed, "frag_4");
|
var c9 = Frozen(AnchorCheckSize, seed, "frag_4");
|
||||||
Pass1Result q9p = Topography.Generate(c9);
|
Pass1Result q9p = Topography.Generate(c9);
|
||||||
Pass2Result q9 = Shaping.Shape(q9p, c9);
|
|
||||||
hard.Add(ShapingOracle.DumpRegression("a9", $"frozen frag_4 at {AnchorCheckSize} == task-09 frag_4 dump [{seed}] (no code change, same setting)", q9.Height, HeightField.Load(t09Dump, AnchorCheckSize), AnchorCheckSize, t09Dump));
|
|
||||||
|
|
||||||
// a8 — the stretch-2, frag-off baseline still equals the 08 field, and the interior is still locked against it.
|
|
||||||
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_stretch_3", "height.f32");
|
|
||||||
var c8 = Frozen(AnchorCheckSize, seed, "t08", frag: false, revert: false);
|
var c8 = Frozen(AnchorCheckSize, seed, "t08", frag: false, revert: false);
|
||||||
Pass1Result q8p = Topography.Generate(c8);
|
Pass1Result q8p = Topography.Generate(c8);
|
||||||
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch 2 at {AnchorCheckSize} == task-08 stretch_3 dump [{seed}]", Shaping.Shape(q8p, c8).Height, HeightField.Load(t08Dump, AnchorCheckSize), AnchorCheckSize, t08Dump));
|
|
||||||
var r = ShapingOracle.InteriorLocked(q8p, q9p, FrozenBandCentre, FrozenBandHalfWidth); r.Name += $" [{seed}, {AnchorCheckSize}]"; hard.Add(r);
|
var r = ShapingOracle.InteriorLocked(q8p, q9p, FrozenBandCentre, FrozenBandHalfWidth); r.Name += $" [{seed}, {AnchorCheckSize}]"; hard.Add(r);
|
||||||
}
|
}
|
||||||
// determinism at the check size
|
// determinism at the check size
|
||||||
|
|
@ -203,7 +240,7 @@ namespace IslaApocalypse.Tools
|
||||||
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
|
foreach (var c in perSeed) if (!c.Passed) GD.PrintErr(" " + c);
|
||||||
|
|
||||||
WriteTable(batchRoot, mapSize, rows, big, speckCells);
|
WriteTable(batchRoot, mapSize, rows, big, speckCells);
|
||||||
WriteIndex(batchRoot, mapSize, calibSize, rows, big, speckCells, hard, perSeed, allOk);
|
WriteIndex(batchRoot, task, mapSize, calibSize, rows, big, speckCells, hard, perSeed, allOk);
|
||||||
|
|
||||||
GD.Print("\n==================================================================");
|
GD.Print("\n==================================================================");
|
||||||
GD.Print($" DONE — {batchRoot}");
|
GD.Print($" DONE — {batchRoot}");
|
||||||
|
|
@ -262,7 +299,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -275,11 +312,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
@ -356,11 +396,11 @@ namespace IslaApocalypse.Tools
|
||||||
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, List<Row> rows, long big, long speckCells,
|
private static void WriteIndex(string batchRoot, int task, int mapSize, int calibSize, List<Row> rows, long big, long speckCells,
|
||||||
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
|
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perSeed, bool allOk)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine($"# Batch 10 — frag_4 seed gallery: does the look generalize? (render-only, {mapSize})");
|
sb.AppendLine($"# Batch {task:D2} — frag_4 seed gallery: does the look generalize? (render-only, {mapSize})");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("**A contact sheet, not a tune.** Every plate is the SAME setting — chat2/09's `frag_4`, frozen — across the two");
|
sb.AppendLine("**A contact sheet, not a tune.** Every plate is the SAME setting — chat2/09's `frag_4`, frozen — across the two");
|
||||||
sb.AppendLine("seeds it was judged on (⭐ anchors) and six fresh seeds chosen before any render. The question: does a coherent");
|
sb.AppendLine("seeds it was judged on (⭐ anchors) and six fresh seeds chosen before any render. The question: does a coherent");
|
||||||
|
|
@ -370,7 +410,13 @@ namespace IslaApocalypse.Tools
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"`FragmentAmp {FrozenFragmentAmp}` · `FragmentFreqPerMapWidth {FrozenFragmentFreq}` · window `{FrozenBandCentre} ± {FrozenBandHalfWidth}` · bites-only `{FrozenBitesOnly}` · " +
|
sb.AppendLine($"`FragmentAmp {FrozenFragmentAmp}` · `FragmentFreqPerMapWidth {FrozenFragmentFreq}` · window `{FrozenBandCentre} ± {FrozenBandHalfWidth}` · bites-only `{FrozenBitesOnly}` · " +
|
||||||
$"`SouthStretch {FrozenStretch}` (band `{FrozenBandStart}` / feather `{FrozenBandFeather}`, sinker stretched `{FrozenStretchSinker}`) · speck revert < {speckCells} cells (`{FrozenSpeckFrac:G2}` of the map) · " +
|
$"`SouthStretch {FrozenStretch}` (band `{FrozenBandStart}` / feather `{FrozenBandFeather}`, sinker stretched `{FrozenStretchSinker}`) · speck revert < {speckCells} cells (`{FrozenSpeckFrac:G2}` of the map) · " +
|
||||||
"offshore OFF · shelf OFF · region labeling ON · the tagged curve (calibrated on task 01's pool at " + calibSize + "). Pinned explicitly in `FragGalleryTool` — nothing is left to a default.");
|
"offshore OFF · shelf OFF · region labeling ON · the tagged curve (calibrated on task 01's pool at " + calibSize + ").");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("> ### ⭐ Since rivers/01, this setting IS the bare `TerrainGenConfig` default — it is not stated here, it is READ.");
|
||||||
|
sb.AppendLine("> That is what makes this batch the acceptance for the re-baseline rather than a restatement of it: if a default");
|
||||||
|
sb.AppendLine("> ever drifts, `TerrainShapeV1.Assert` refuses the run instead of rendering a gallery that would look right and");
|
||||||
|
sb.AppendLine("> mean nothing. The curve calibration pool is pinned FAMILY-OFF (`TerrainGenConfig.WithFamilyOff`), which is what");
|
||||||
|
sb.AppendLine("> keeps the knots — and therefore these renders — bit-identical across the flip.");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine("## ⭐ The contact sheet");
|
sb.AppendLine("## ⭐ The contact sheet");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
|
||||||
279
Tools/Scripts/HydrologyRenderer.cs
Normal file
279
Tools/Scripts/HydrologyRenderer.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE HYDROLOGY MAP (rivers/05) — a first-class reference map, not a diagnostic dump (→ D-056).
|
||||||
|
///
|
||||||
|
/// "Here is where the water lives and where it flows": the island's relief, its lakes as water, the
|
||||||
|
/// flow-direction field as a quiet streamline texture, and the flow-through rivers prominent —
|
||||||
|
/// chaining visibly through lakes and low ground to the sea, width ∝ √drainage on the fixed law,
|
||||||
|
/// sea-reaching and lake-terminal distinguished, confluences as merges, dropped rivers as a ghost of
|
||||||
|
/// their stem only. Presentation only — reads data, writes pixels.
|
||||||
|
///
|
||||||
|
/// The companion <see cref="FlowDirection"/> plate is the DATA map of the field on its own: hue by
|
||||||
|
/// heading, sinks black, walled basins darkened — the one placement / flooding / irrigation reference.
|
||||||
|
/// </summary>
|
||||||
|
public static class HydrologyRenderer
|
||||||
|
{
|
||||||
|
private static readonly Color RiverSea = new(0.860f, 0.960f, 1.000f);
|
||||||
|
private static readonly Color RiverLake = new(1.000f, 0.840f, 0.520f);
|
||||||
|
private static readonly Color RiverEdge = new(0.050f, 0.110f, 0.240f);
|
||||||
|
private static readonly Color LakeWater = new(0.300f, 0.560f, 0.940f);
|
||||||
|
private static readonly Color PondWater = new(0.330f, 0.540f, 0.860f);
|
||||||
|
private static readonly Color Ghost = new(0.620f, 0.220f, 0.200f);
|
||||||
|
private static readonly Color Junction = new(1.000f, 1.000f, 1.000f);
|
||||||
|
private static readonly Color Stream = new(0.980f, 0.990f, 1.000f);
|
||||||
|
private static readonly Color Ink = new(0.941f, 0.949f, 0.961f);
|
||||||
|
private static readonly Color Ocean = new(0.055f, 0.110f, 0.235f);
|
||||||
|
private static readonly Color Sink = new(0.020f, 0.020f, 0.020f);
|
||||||
|
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
|
||||||
|
/// <summary>TinyFont carries only <c>. - : / ( ) 0-9 A-Z</c>; everything else would print as a gap. Map the punctuation the plate text uses.</summary>
|
||||||
|
public static string Txt(string s) => s
|
||||||
|
.Replace("%", " PCT").Replace("+", " AND ").Replace(";", " -").Replace(",", " -").Replace("'", "")
|
||||||
|
.Replace("→", "-").Replace("≤", "UNDER").Replace(">", "OVER").Replace("<", "UNDER").Replace("!", ".").Replace(" ", " ");
|
||||||
|
|
||||||
|
/// <summary>The shaded-relief base (the atlas look), quietened — desaturated and darkened a little so the water reads on top of it.</summary>
|
||||||
|
public static Image Base(float[,] height, int n, float sea)
|
||||||
|
{
|
||||||
|
var look = new LookConfig { SeaLevel = sea };
|
||||||
|
var img = ReliefRenderer.Render(height, n, look);
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
for (int y = 0; y < n; y++)
|
||||||
|
{
|
||||||
|
Color c = img.GetPixel(x, y);
|
||||||
|
float l = 0.299f * c.R + 0.587f * c.G + 0.114f * c.B;
|
||||||
|
Color q = c.Lerp(new Color(l, l, l), 0.38f);
|
||||||
|
img.SetPixel(x, y, new Color(q.R * 0.88f, q.G * 0.88f, q.B * 0.88f));
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool[] LakeIds(BasinGraph graph)
|
||||||
|
{
|
||||||
|
int max = 0;
|
||||||
|
foreach (var b in graph.Nodes) if (b.Id > max) max = b.Id;
|
||||||
|
var lake = new bool[max + 1];
|
||||||
|
foreach (var b in graph.Nodes) if (b.IsLake) lake[b.Id] = true;
|
||||||
|
return lake;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Image Hydrology(FlowThroughRouting.Result r, sbyte[] dir, DrainageAnalysis.Plan plan, BasinGraph graph,
|
||||||
|
Image img, int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
bool[] lakeId = LakeIds(graph);
|
||||||
|
bool IsLakeWater(int i) => isClassifyWater[i] && !isOcean[i] && plan.BasinId[i] != 0 && plan.BasinId[i] < lakeId.Length && lakeId[plan.BasinId[i]];
|
||||||
|
|
||||||
|
// 1. the lakes — every non-ocean classify body drawn as water; the significant ones (lake basins) a touch brighter.
|
||||||
|
for (int i = 0; i < total; i++)
|
||||||
|
{
|
||||||
|
if (!isClassifyWater[i] || isOcean[i]) continue;
|
||||||
|
img.SetPixel(i / n, i % n, IsLakeWater(i) ? LakeWater : PondWater);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. the field as a streamline texture — quiet, present, not shouting.
|
||||||
|
Streamlines(img, dir, n, isOcean, isClassifyWater, n >= 4096 ? 56 : 28, 0.30f);
|
||||||
|
|
||||||
|
// 3. dropped rivers — a ghost of the upland stem only: "considered, dropped".
|
||||||
|
foreach (var fr in r.Rivers)
|
||||||
|
{
|
||||||
|
if (!fr.Dropped || fr.Routed.CellPath == null) continue;
|
||||||
|
int stem = Math.Min(fr.Routed.StemCells, fr.Routed.CellPath.Count);
|
||||||
|
for (int i = 0; i < stem; i++)
|
||||||
|
{
|
||||||
|
var (x, y) = fr.Routed.CellPath[i];
|
||||||
|
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(Ghost, 0.55f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. the kept rivers — outlined, smallest first so the big ones finish on top; lake spans not drawn.
|
||||||
|
var kept = new List<FlowThroughRouting.FlowRiver>();
|
||||||
|
foreach (var fr in r.Rivers) if (!fr.Dropped && fr.Routed.CellPath != null && fr.Routed.CellPath.Count > 0) kept.Add(fr);
|
||||||
|
kept.Sort((a, b) => a.Candidate.DrainagePx.CompareTo(b.Candidate.DrainagePx));
|
||||||
|
foreach (var fr in kept) DrawRiver(img, fr, n, RiverEdge, +1, IsLakeWater);
|
||||||
|
foreach (var fr in kept) DrawRiver(img, fr, n, fr.ReachesSea ? RiverSea : RiverLake, 0, IsLakeWater);
|
||||||
|
|
||||||
|
// 5. markers — through the confluence root: a tributary's mouth is its trunk's mouth.
|
||||||
|
int mark = n >= 4096 ? 14 : 8;
|
||||||
|
foreach (var fr in kept)
|
||||||
|
{
|
||||||
|
if (fr.Routed.Joined)
|
||||||
|
{
|
||||||
|
DrainageRenderer.Disc(img, fr.Routed.JunctionCell.x, fr.Routed.JunctionCell.y, Math.Max(3, mark / 2), n, Junction);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fr.MouthCell < 0) continue;
|
||||||
|
int mx = fr.MouthCell / n, my = fr.MouthCell % n;
|
||||||
|
if (fr.Terminus == FlowThroughRouting.Terminus.Ocean)
|
||||||
|
{
|
||||||
|
DrainageRenderer.Square(img, mx, my, mark / 2, n, RiverSea);
|
||||||
|
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrainageRenderer.Disc(img, mx, my, mark / 2, n, RiverLake);
|
||||||
|
DrainageRenderer.Ring(img, mx, my, mark, n, RiverEdge, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. labels.
|
||||||
|
int ls = n >= 4096 ? 4 : 3;
|
||||||
|
var placer = new DrainageRenderer.LabelPlacer(n, ls, headerLines: 8);
|
||||||
|
int droppedLabels = 0;
|
||||||
|
var byArea = new List<FlowThroughRouting.FlowRiver>(kept);
|
||||||
|
byArea.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||||||
|
foreach (var fr in byArea)
|
||||||
|
{
|
||||||
|
var c = fr.Candidate;
|
||||||
|
string tag = fr.Routed.Joined ? $"INTO R{fr.Routed.ConfluenceParentRank}"
|
||||||
|
: fr.Trunk ? "TRUNK" : fr.Terminus == FlowThroughRouting.Terminus.Ocean ? $"SEA VIA {fr.Chain.Count}" : $"LAKE {fr.TerminusBasinId}";
|
||||||
|
int lx = fr.Routed.Joined ? fr.Routed.JunctionCell.x : fr.MouthCell / n;
|
||||||
|
int ly = fr.Routed.Joined ? fr.Routed.JunctionCell.y : fr.MouthCell % n;
|
||||||
|
if (!placer.Place(img, Txt($"R{c.Rank} {DrainageRenderer.DrainageLabel(c.DrainagePx)} {tag}"), lx, ly, mark,
|
||||||
|
fr.Routed.Joined ? Junction : fr.ReachesSea ? RiverSea : RiverLake)) droppedLabels++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. the legend, on a dark bar so it reads on the relief.
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
Bar(img, n, 12 + lh * 8 + 8);
|
||||||
|
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
|
||||||
|
TinyFont.Draw(img, Txt($"PALE BLUE RIVER = REACHES THE SEA ({r.Trunks} NATURAL TRUNK + {r.FlowThrough} FLOW-THROUGH). SQUARE = MOUTH. AMBER RIVER = LAKE-TERMINAL ({r.LakeTerminal}), DISC = WHERE IT ENTERS ITS LAKE"), 12, 12 + lh * 3, s, RiverSea);
|
||||||
|
TinyFont.Draw(img, Txt($"WHITE DOT = CONFLUENCE ({r.Joined} JOINED). FAINT RED GHOST = A RIVER CONSIDERED AND DROPPED ({r.DroppedDry + r.DroppedClosed}) - ITS CHAIN WALLED AT A DRY SINK, SO IT IS NOT DRAWN"), 12, 12 + lh * 4, s, Junction);
|
||||||
|
TinyFont.Draw(img, Txt($"BLUE = EXISTING LAKES (CLASSIFY WATER). A RIVER'S SPAN ACROSS A LAKE IS WATER, NOT A DRAWN CHANNEL. STREAMLINES = THE FLOW-DIRECTION FIELD AT CAP {r.CapM:F0} M"), 12, 12 + lh * 5, s, LakeWater);
|
||||||
|
TinyFont.Draw(img, Txt($"WIDTH: {DrainageRenderer.StemWidthLaw()} - AS RIVERS/02B, 03, 03B, 03C"), 12, 12 + lh * 6, s, Ink);
|
||||||
|
TinyFont.Draw(img, "ROUTING AND DATA ONLY - NO HEIGHT MUTATED, NO WATER FILLED OR CREATED, NO BED CARVED." + (droppedLabels > 0 ? $" ({droppedLabels} LABEL(S) DROPPED)" : ""), 12, 12 + lh * 7, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a river's OWN reach (up to its junction) as discs along its rasterised cells, skipping cells that are lake water.</summary>
|
||||||
|
private static void DrawRiver(Image img, FlowThroughRouting.FlowRiver fr, int n, Color c, int grow, Func<int, bool> isLakeWater)
|
||||||
|
{
|
||||||
|
int w = DrainageRenderer.StemWidthFixed(fr.Candidate.DrainagePx);
|
||||||
|
int rad = Math.Max(1, w / 2) + grow;
|
||||||
|
var cells = fr.Routed.CellPath;
|
||||||
|
int own = fr.Routed.Joined ? OwnLength(fr.Routed) : cells.Count;
|
||||||
|
for (int i = 0; i < own; i++)
|
||||||
|
{
|
||||||
|
var (x, y) = cells[i];
|
||||||
|
if (x < 0 || y < 0 || x >= n || y >= n) continue;
|
||||||
|
if (isLakeWater(x * n + y)) continue;
|
||||||
|
DrainageRenderer.Disc(img, x, y, rad, n, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int OwnLength(RiverRouting.RoutedRiver r)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < r.CellPath.Count; i++)
|
||||||
|
if (r.CellPath[i].x == r.JunctionCell.x && r.CellPath[i].y == r.JunctionCell.y) return i + 1;
|
||||||
|
return r.CellPath.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The field as a texture: from a grid of seed cells on land, follow the field for a short run and
|
||||||
|
/// draw it faint-to-stronger along the flow, with a dot at the downstream end. A quiet island-wide
|
||||||
|
/// "which way does water go here" that never competes with the rivers.
|
||||||
|
/// </summary>
|
||||||
|
private static void Streamlines(Image img, sbyte[] dir, int n, bool[] isOcean, bool[] isClassifyWater, int step, float alpha)
|
||||||
|
{
|
||||||
|
int len = (int)(step * 0.7f);
|
||||||
|
for (int gx = step / 2; gx < n; gx += step)
|
||||||
|
for (int gy = step / 2; gy < n; gy += step)
|
||||||
|
{
|
||||||
|
int c = gx * n + gy;
|
||||||
|
if (isOcean[c] || isClassifyWater[c] || dir[c] < 0) continue;
|
||||||
|
for (int k = 0; k < len; k++)
|
||||||
|
{
|
||||||
|
sbyte d = dir[c];
|
||||||
|
if (d < 0) break;
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
int t = (cx + DX[d]) * n + (cy + DY[d]);
|
||||||
|
if (isOcean[t]) break;
|
||||||
|
float a = alpha * (0.35f + 0.65f * k / len);
|
||||||
|
img.SetPixel(t / n, t % n, img.GetPixel(t / n, t % n).Lerp(Stream, a));
|
||||||
|
c = t;
|
||||||
|
}
|
||||||
|
img.SetPixel(c / n, c % n, img.GetPixel(c / n, c % n).Lerp(Stream, alpha * 1.4f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Bar(Image img, int n, int height)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < Math.Min(height, n); y++)
|
||||||
|
for (int x = 0; x < n; x++)
|
||||||
|
img.SetPixel(x, y, img.GetPixel(x, y).Lerp(new Color(0.04f, 0.05f, 0.07f), 0.72f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE FLOW-DIRECTION DATA MAP — hue by heading (the wheel: N red, E yellow-green, S cyan, W violet),
|
||||||
|
/// sinks black, ocean dark, walled basins darkened, lakes as water at half strength so the field still
|
||||||
|
/// shows through, streamlines on top. Not pretty by design — legible.
|
||||||
|
/// </summary>
|
||||||
|
public static Image FlowDirection(sbyte[] dir, int[] acc, DrainageAnalysis.Plan plan, BasinGraph graph, HashSet<int> walled,
|
||||||
|
int n, bool[] isOcean, bool[] isClassifyWater, string title, string subtitle, string third)
|
||||||
|
{
|
||||||
|
var img = Image.CreateEmpty(n, n, false, Image.Format.Rgb8);
|
||||||
|
long maxAcc = 1;
|
||||||
|
for (int i = 0; i < acc.Length; i++) if (acc[i] > maxAcc) maxAcc = acc[i];
|
||||||
|
double lmax = Math.Log(1.0 + maxAcc);
|
||||||
|
var hue = new Color[8];
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
// screen +y is SOUTH, so flip y to get a compass angle; hue 0 at north, clockwise.
|
||||||
|
float ang = MathF.Atan2(DX[k], -DY[k]); // 0 = north, +π/2 = east
|
||||||
|
float h = (ang / (2f * MathF.PI) + 1f) % 1f;
|
||||||
|
hue[k] = Color.FromHsv(h, 0.62f, 0.86f);
|
||||||
|
}
|
||||||
|
bool[] lakeId = LakeIds(graph);
|
||||||
|
for (int i = 0; i < n * n; i++)
|
||||||
|
{
|
||||||
|
int x = i / n, y = i % n;
|
||||||
|
if (isOcean[i]) { img.SetPixel(x, y, Ocean); continue; }
|
||||||
|
sbyte d = dir[i];
|
||||||
|
Color c;
|
||||||
|
if (d < 0) c = Sink;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Hue = heading; brightness = log accumulation on the capped field, so the field's own drainage
|
||||||
|
// tree reads as bright channels on dark slopes and the per-cell heading noise stays quiet.
|
||||||
|
float v = (float)(Math.Log(1.0 + acc[i]) / lmax);
|
||||||
|
float b = 0.22f + 0.78f * v;
|
||||||
|
c = new Color(hue[d].R * b, hue[d].G * b, hue[d].B * b);
|
||||||
|
}
|
||||||
|
int id = plan.BasinId[i];
|
||||||
|
if (id != 0 && walled.Contains(id)) c = c.Lerp(new Color(0.55f, 0.08f, 0.08f), 0.35f);
|
||||||
|
if (isClassifyWater[i])
|
||||||
|
c = c.Lerp(id != 0 && id < lakeId.Length && lakeId[id] ? LakeWater : PondWater, 0.45f);
|
||||||
|
img.SetPixel(x, y, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||||
|
Bar(img, n, 12 + lh * 5 + 8);
|
||||||
|
TinyFont.Draw(img, Txt(title), 12, 12, s, Ink);
|
||||||
|
TinyFont.Draw(img, Txt(subtitle), 12, 12 + lh, s, Ink);
|
||||||
|
TinyFont.Draw(img, Txt(third), 12, 12 + lh * 2, s, Ink);
|
||||||
|
// the wheel, as swatches
|
||||||
|
string[] names = { "NW", "W", "SW", "N", "S", "NE", "E", "SE" };
|
||||||
|
int cx0 = 12, cy0 = 12 + lh * 3;
|
||||||
|
TinyFont.Draw(img, Txt("HUE = HEADING:"), cx0, cy0, s, Ink);
|
||||||
|
int cursor = cx0 + TinyFont.Width("HUE = HEADING: ", s);
|
||||||
|
int[] order = { 3, 5, 6, 7, 4, 2, 1, 0 }; // N NE E SE S SW W NW
|
||||||
|
foreach (int k in order)
|
||||||
|
{
|
||||||
|
DrainageRenderer.Square(img, cursor + 8, cy0 + TinyFont.Height(s) / 2, 7, n, hue[k]);
|
||||||
|
TinyFont.Draw(img, names[k], cursor + 20, cy0, s, Ink);
|
||||||
|
cursor += 20 + TinyFont.Width(names[k] + " ", s);
|
||||||
|
}
|
||||||
|
TinyFont.Draw(img, Txt("BRIGHTNESS = LOG FLOW ACCUMULATION ON THIS FIELD (CHANNELS BRIGHT). BLACK = SINK (A WALLED BASIN FLOOR). RED-TINTED = INSIDE A WALLED BASIN - FLOW ENTERING IT ENDS THERE. BLUE HAZE = CLASSIFY WATER."), 12, 12 + lh * 4, s, Ink);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/MountainRestoreTool.tscn
|
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/MountainRestoreTool.tscn
|
||||||
///
|
///
|
||||||
/// ISLA_TASK / ISLA_BATCH / ISLA_MAPSIZE / ISLA_SEEDS / ISLA_SHOWPIECE_SIZE / ISLA_SHOWPIECE
|
/// ISLA_TASK / ISLA_BATCH / ISLA_MAPSIZE / ISLA_SEEDS / ISLA_SHOWPIECE_SIZE / ISLA_SHOWPIECE
|
||||||
/// ISLA_PHASE1_SOURCE (default "02_pass1_port") · ISLA_T01_SOURCE (default "01_curve_baseline")
|
/// ISLA_PHASE1_SOURCE (default "chat1/02_pass1_port")
|
||||||
/// ISLA_SKIP_RAW
|
/// ISLA_SKIP_RAW
|
||||||
/// ISLA_LIFT_BIG probe: the `continuous_bigger` lift (default 1.35)
|
/// ISLA_LIFT_BIG probe: the `continuous_bigger` lift (default 1.35)
|
||||||
/// ISLA_SHARP probe: the `continuous_sharper_peak` knob (default 2.5)
|
/// ISLA_SHARP probe: the `continuous_sharper_peak` knob (default 2.5)
|
||||||
|
|
@ -77,6 +77,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 3);
|
int task = EnvInt("ISLA_TASK", 3);
|
||||||
string descr = EnvStr("ISLA_BATCH", "mountain_restore");
|
string descr = EnvStr("ISLA_BATCH", "mountain_restore");
|
||||||
|
|
@ -84,8 +88,7 @@ namespace IslaApocalypse.Tools
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
|
||||||
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t01Source = EnvStr("ISLA_T01_SOURCE", "01_curve_baseline");
|
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
|
float liftBig = EnvFloat("ISLA_LIFT_BIG", 1.35f);
|
||||||
float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
|
float sharpKnob = EnvFloat("ISLA_SHARP", 2.5f);
|
||||||
|
|
@ -114,7 +117,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int seed in CalibrationSeeds)
|
foreach (int seed in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed));
|
||||||
pass1[seed] = p1;
|
pass1[seed] = p1;
|
||||||
rawPool.Accumulate(p1.Height, mapSize);
|
rawPool.Accumulate(p1.Height, mapSize);
|
||||||
}
|
}
|
||||||
|
|
@ -219,12 +222,17 @@ namespace IslaApocalypse.Tools
|
||||||
var hard = new List<ShapingOracle.Check>();
|
var hard = new List<ShapingOracle.Check>();
|
||||||
var soft = new List<ShapingOracle.Check>();
|
var soft = new List<ShapingOracle.Check>();
|
||||||
|
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off).
|
||||||
|
// ⚠ A missing dump now THROWS instead of skipping silently.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
|
||||||
string t01Dump = Path.Combine(ToolingPaths.BatchesRoot, t01Source, $"{primary}_curve_on", "height.f32");
|
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
|
||||||
offs[primary].Height, HeightField.Load(p1Dump, mapSize), mapSize, p1Dump));
|
offs[primary].Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, mapSize), mapSize, p1Dump));
|
||||||
hard.Add(ShapingOracle.DumpRegression("a2", "staircase == task-01 curve_on .f32 dump",
|
|
||||||
results[(primary, "staircase")].Height, HeightField.Load(t01Dump, mapSize), mapSize, t01Dump));
|
// ⚑ RETIRED at rivers/01 — a2, the staircase == `01_curve_baseline` control.
|
||||||
|
// Superseded by the continuous grade (→ D-062) — see CurveContinuousTool for the full note.
|
||||||
|
// The dump is NOT deleted (file-safety; it is regenerable and it is the record of what
|
||||||
|
// was judged); its `INDEX.md` is marked superseded. The check is gone so nothing can
|
||||||
|
// pass against a superseded baseline. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
|
|
||||||
long bFail = 0;
|
long bFail = 0;
|
||||||
foreach (int seed in seeds)
|
foreach (int seed in seeds)
|
||||||
|
|
@ -335,13 +343,20 @@ namespace IslaApocalypse.Tools
|
||||||
GetTree().Quit(hardOk ? 0 : 3);
|
GetTree().Quit(hardOk ? 0 : 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
|
||||||
|
/// and judged before the shape family existed, on the family-off distribution the knots are
|
||||||
|
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
|
||||||
|
/// keeps this tool measuring the thing it was written to measure.
|
||||||
|
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
|
||||||
|
/// </remarks>
|
||||||
private static TerrainGenConfig MakeConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
private static TerrainGenConfig MakeConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
||||||
=> new TerrainGenConfig
|
=> new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
||||||
LowlandCeilingM = 30f,
|
LowlandCeilingM = 30f,
|
||||||
};
|
}.WithFamilyOff();
|
||||||
|
|
||||||
// ---- output ---------------------------------------------------------
|
// ---- output ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_OFF_MINAREA / ISLA_OFF_MINSEP / ISLA_OFF_MAXAREA the guards (probe overrides)
|
/// ISLA_OFF_MINAREA / ISLA_OFF_MINSEP / ISLA_OFF_MAXAREA the guards (probe overrides)
|
||||||
/// ISLA_OFF_FREQ / ISLA_OFF_CORE / ISLA_OFF_SHARP / ISLA_OFF_CREST the shape (probe overrides)
|
/// ISLA_OFF_FREQ / ISLA_OFF_CORE / ISLA_OFF_SHARP / ISLA_OFF_CREST the shape (probe overrides)
|
||||||
/// ISLA_TABLE_ONLY=1 probe: diagnosis + count table only (no regressions, no plates)
|
/// ISLA_TABLE_ONLY=1 probe: diagnosis + count table only (no regressions, no plates)
|
||||||
/// ISLA_SKIP_8K=1 skip the 8192 regression against the 04 gallery dump (a4)
|
/// ISLA_PHASE1_SOURCE the Phase-1 regression dump's batch (default "chat1/02_pass1_port")
|
||||||
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE the regression dumps' batches
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class OffshoreIslandsTool : Node
|
public partial class OffshoreIslandsTool : Node
|
||||||
{
|
{
|
||||||
|
|
@ -77,7 +76,6 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
private const int DefaultMapSize = 4096;
|
private const int DefaultMapSize = 4096;
|
||||||
private const int DefaultCalibSize = 2048;
|
private const int DefaultCalibSize = 2048;
|
||||||
private const int GallerySize = 8192; // the 04 gallery's render size
|
|
||||||
|
|
||||||
/// <summary>The consistency targets the table is read against: "a couple north, a few south".</summary>
|
/// <summary>The consistency targets the table is read against: "a couple north, a few south".</summary>
|
||||||
private const int TargetNorth = 2, TargetSouth = 3;
|
private const int TargetNorth = 2, TargetSouth = 3;
|
||||||
|
|
@ -112,6 +110,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 6);
|
int task = EnvInt("ISLA_TASK", 6);
|
||||||
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
|
string descr = EnvStr("ISLA_BATCH", "offshore_organic_tune");
|
||||||
|
|
@ -121,12 +123,9 @@ namespace IslaApocalypse.Tools
|
||||||
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
||||||
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
||||||
int bulgeSeedEnv = EnvInt("ISLA_BULGE_SEED", 0);
|
int bulgeSeedEnv = EnvInt("ISLA_BULGE_SEED", 0);
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
|
||||||
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
||||||
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
|
@ -185,14 +184,17 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF == Phase-1 .f32 dump",
|
||||||
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
pOff.Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
Pass2Result pRest = Shaping.Shape(p1, offCfg);
|
// ⚑ RETIRED at rivers/01 — a3, `continuous_restored` == `03_mountain_restore`.
|
||||||
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
|
// A curve-development intermediate: it proved the chat2/03 climb restoration against
|
||||||
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF == task-03 .f32 dump (lowlands + curve untouched)",
|
// chat2/02. Both are upstream of the locked shape, and `terrain-shape-v1` (a10) now
|
||||||
pRest.Height, HeightField.Load(t03Dump, calibSize), calibSize, t03Dump));
|
// asserts the whole chain end-to-end — subsuming it.
|
||||||
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
|
|
||||||
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
|
// Shelf ON, islets OFF: land must be bit-identical (the shelf touches only sea).
|
||||||
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
||||||
|
|
@ -205,22 +207,12 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
// ⭐ a4 — offshore OFF at the 04 gallery's size == the terrain-curve-v1 tag's OWN output.
|
// ⭐ a4 — offshore OFF at the 04 gallery's size == the terrain-curve-v1 tag's OWN output.
|
||||||
// The literal "offshore-off is bit-identical to terrain-curve-v1", at full size.
|
// The literal "offshore-off is bit-identical to terrain-curve-v1", at full size.
|
||||||
if (!skip8k)
|
// ⚑ RETIRED at rivers/01 — a4, offshore-OFF at 8192 == `04_seed_gallery` (`terrain-curve-v1`).
|
||||||
{
|
// The PRE-FAMILY committed curve. The locked shape is family-ON, so this dump is a
|
||||||
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
|
// baseline the generator is deliberately no longer on; `a10` replaced it as the 8192
|
||||||
if (File.Exists(t04Dump))
|
// acceptance. (It also cost an 8192 generation on every run of this tool.)
|
||||||
{
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, against {t04Dump} …");
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
var gCfg = BaseConfig(GallerySize, plateSeed, knots, anchors, calibration, "off");
|
|
||||||
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
|
|
||||||
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
|
|
||||||
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
|
|
||||||
hard.Add(a4);
|
|
||||||
GD.Print(" " + a4);
|
|
||||||
}
|
|
||||||
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
|
||||||
}
|
|
||||||
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
|
// ═══ 2. THE DIAGNOSIS — measure the south before touching a knob ═══
|
||||||
|
|
@ -402,7 +394,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // offshore OFF by default
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -416,11 +408,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
@ -436,14 +431,21 @@ namespace IslaApocalypse.Tools
|
||||||
return (knots, cal, pass1);
|
return (knots, cal, pass1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT
|
||||||
|
/// (chat2/05–06): authored and judged before the shape family existed, and its regressions hold
|
||||||
|
/// pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the bare defaults
|
||||||
|
/// family-ON, so without <see cref="TerrainGenConfig.WithFamilyOff"/> every config here would
|
||||||
|
/// silently acquire stretch + fragmentation and the anchor checks would fail for a configuration
|
||||||
|
/// reason, not a regression. The variants re-enable offshore explicitly, after the pin.
|
||||||
|
/// </remarks>
|
||||||
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
|
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
|
||||||
ClimbCalibration cal, string label) => new TerrainGenConfig
|
ClimbCalibration cal, string label) => new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
|
Knots = k, Anchors = a, ClimbCalibration = cal, LowlandCeilingM = 30f,
|
||||||
CoastShelf = false, Offshore = new OffshoreSettings(), // OFF unless the variant turns it on
|
}.WithFamilyOff(); // ⭐ shelf / islets / speck / stretch / frag / erosion all OFF — pinned
|
||||||
};
|
|
||||||
|
|
||||||
// ---- output -----------------------------------------------------------
|
// ---- output -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_SECOND_SEED the second plate seed (default 0 = auto: most natural islands)
|
/// ISLA_SECOND_SEED the second plate seed (default 0 = auto: most natural islands)
|
||||||
/// ISLA_THR_LOW / ISLA_THR_MID / ISLA_THR_HIGH thresholds, fraction of map area (probe overrides)
|
/// ISLA_THR_LOW / ISLA_THR_MID / ISLA_THR_HIGH thresholds, fraction of map area (probe overrides)
|
||||||
/// ISLA_TABLE_ONLY=1 probe: table only (no regressions, no plates)
|
/// ISLA_TABLE_ONLY=1 probe: table only (no regressions, no plates)
|
||||||
/// ISLA_SKIP_8K=1 skip the 8192 regression (a4)
|
/// ISLA_PHASE1_SOURCE the Phase-1 regression dump's batch (default "chat1/02_pass1_port")
|
||||||
/// ISLA_PHASE1_SOURCE / ISLA_T03_SOURCE / ISLA_T04_SOURCE / ISLA_T06_SOURCE the regression dumps' batches
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class RegionLabelingTool : Node
|
public partial class RegionLabelingTool : Node
|
||||||
{
|
{
|
||||||
|
|
@ -57,7 +56,6 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
private const int DefaultMapSize = 4096;
|
private const int DefaultMapSize = 4096;
|
||||||
private const int DefaultCalibSize = 2048;
|
private const int DefaultCalibSize = 2048;
|
||||||
private const int GallerySize = 8192;
|
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -89,6 +87,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 7);
|
int task = EnvInt("ISLA_TASK", 7);
|
||||||
string descr = EnvStr("ISLA_BATCH", "region_labeling");
|
string descr = EnvStr("ISLA_BATCH", "region_labeling");
|
||||||
|
|
@ -98,13 +100,9 @@ namespace IslaApocalypse.Tools
|
||||||
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
int[] tableSeeds = EnvSeeds("ISLA_TABLE_SEEDS", DefaultTableSeeds);
|
||||||
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
int plateSeed = EnvInt("ISLA_PLATE_SEED", 1063685222);
|
||||||
int secondEnv = EnvInt("ISLA_SECOND_SEED", 0);
|
int secondEnv = EnvInt("ISLA_SECOND_SEED", 0);
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
|
||||||
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
|
||||||
string t06Source = EnvStr("ISLA_T06_SOURCE", "06_offshore_organic_tune");
|
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
bool tableOnly = EnvStr("ISLA_TABLE_ONLY", "0") == "1";
|
||||||
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
|
||||||
|
|
||||||
var levels = new List<Level>
|
var levels = new List<Level>
|
||||||
{
|
{
|
||||||
|
|
@ -141,7 +139,14 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
TerrainGenConfig Cfg(int size, int seed, string label, bool offshoreOn, bool revertOn, float frac)
|
TerrainGenConfig Cfg(int size, int seed, string label, bool offshoreOn, bool revertOn, float frac)
|
||||||
{
|
{
|
||||||
var c = BaseConfig(size, seed, knots, anchors, calibration, label);
|
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
|
||||||
|
// it was authored and judged before the shape family existed, and its regression checks
|
||||||
|
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
|
||||||
|
// bare defaults family-ON, so without this pin every config here would silently acquire
|
||||||
|
// stretch + fragmentation and every anchor check would fail for a configuration reason.
|
||||||
|
// → TerrainGenConfig.WithFamilyOff().
|
||||||
|
var c = BaseConfig(size, seed, knots, anchors, calibration, label).WithFamilyOff();
|
||||||
|
// …then this tool's own axes, AFTER the pin (the pin would otherwise clear them).
|
||||||
if (offshoreOn) { c.CoastShelf = true; c.Offshore = OffshoreSettings.Organic(); }
|
if (offshoreOn) { c.CoastShelf = true; c.Offshore = OffshoreSettings.Organic(); }
|
||||||
c.RegionLabeling = true;
|
c.RegionLabeling = true;
|
||||||
c.SpeckRevert = revertOn;
|
c.SpeckRevert = revertOn;
|
||||||
|
|
@ -159,26 +164,17 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
Pass2Result pOff = Shaping.Shape(p1, curveOff);
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plateSeed}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF, revert OFF (labeling on) == Phase-1 .f32 dump",
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, offshore OFF, revert OFF (labeling on) == Phase-1 .f32 dump",
|
||||||
pOff.Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
pOff.Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
|
||||||
|
|
||||||
Pass2Result pRest = Shaping.Shape(p1, offCfg);
|
// ⚑ RETIRED at rivers/01 — a3 and a3r, both against `03_mountain_restore`.
|
||||||
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plateSeed}_continuous_restored", "height.f32");
|
// A curve-development intermediate, subsumed by the `terrain-shape-v1` acceptance (a10).
|
||||||
float[,] t03 = HeightField.Load(t03Dump, calibSize);
|
// a3r was informational only, and its subject — how many natural specks the revert takes —
|
||||||
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, offshore OFF, revert OFF (labeling on) == task-03 .f32 dump",
|
// is now reported by the region ledger every run, with the revert ON by default.
|
||||||
pRest.Height, t03, calibSize, t03Dump));
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
// Informational: offshore OFF, revert ON — how many NATURAL speck cells the revert removes
|
|
||||||
// from the bare field. Allowed to differ (the revert may change terrain); reported, not asserted.
|
|
||||||
var revCfg = Cfg(calibSize, plateSeed, "off_revert", offshoreOn: false, revertOn: true, mid.Frac);
|
|
||||||
Pass1Result p1Rev = Topography.Generate(revCfg);
|
|
||||||
Pass2Result pRev = Shaping.Shape(p1Rev, revCfg);
|
|
||||||
var info = ShapingOracle.DumpRegression("a3r", "(informational) offshore OFF, revert ON at threshold_mid vs task-03 dump — the natural specks removed", pRev.Height, t03, calibSize, t03Dump);
|
|
||||||
info.Detail = (info.Passed ? "no natural speck below the threshold on this seed — " : "") + info.Detail +
|
|
||||||
$" · reverted {p1Rev.RegionLedger.RevertedComponents} natural components / {p1Rev.RegionLedger.RevertedCells:N0} cells";
|
|
||||||
info.Passed = true;
|
|
||||||
hard.Add(info);
|
|
||||||
|
|
||||||
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
var shelfCfg = offCfg.Clone(); shelfCfg.CoastShelf = true; shelfCfg.VariantLabel = "shelf_only";
|
||||||
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
|
Pass1Result p1Shelf = Topography.Generate(shelfCfg);
|
||||||
|
|
@ -188,34 +184,13 @@ namespace IslaApocalypse.Tools
|
||||||
hard.Add(ShapingOracle.CentreIsLand(p1));
|
hard.Add(ShapingOracle.CentreIsLand(p1));
|
||||||
foreach (var c in hard) GD.Print(" " + c);
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
|
||||||
if (!skip8k)
|
// ⚑ RETIRED at rivers/01 — a4 (`04_seed_gallery`) and a6 (`06_offshore_organic_tune`).
|
||||||
{
|
// a4 held the PRE-FAMILY committed curve; the locked shape is family-ON, and a10 is the
|
||||||
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plateSeed}", "height.f32");
|
// 8192 acceptance now. a6 held the ORGANIC ISLET layer — the DROPPED mechanism (→ D-063):
|
||||||
if (File.Exists(t04Dump))
|
// islands are organic-only, made by the stretch + fragmentation and identified here, never
|
||||||
{
|
// placed. An oracle pinning islet output is an oracle defending a design that was reversed.
|
||||||
GD.Print($" a4: generating {plateSeed} at {GallerySize}, offshore OFF, revert OFF …");
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
var gCfg = Cfg(GallerySize, plateSeed, "off", offshoreOn: false, revertOn: false, mid.Frac);
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
Pass2Result pG = Shaping.Shape(Topography.Generate(gCfg), gCfg);
|
|
||||||
var a4 = ShapingOracle.DumpRegression("a4", $"offshore OFF, revert OFF at {GallerySize} == terrain-curve-v1's 04 gallery .f32 dump",
|
|
||||||
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, t04Dump);
|
|
||||||
hard.Add(a4); GD.Print(" " + a4);
|
|
||||||
}
|
|
||||||
else GD.Print($" a4: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
|
||||||
}
|
|
||||||
else GD.Print(" a4: skipped (ISLA_SKIP_8K)");
|
|
||||||
|
|
||||||
// ⭐ a6 — labeling ON, revert OFF, on the chat2/06 preset: bit-identical to the 06 batch's
|
|
||||||
// render field. Labeling is pure analysis; only the revert may change terrain.
|
|
||||||
string t06Dump = Path.Combine(ToolingPaths.BatchesRoot, t06Source, $"{plateSeed}_density_mid", "height.f32");
|
|
||||||
if (File.Exists(t06Dump) && mapSize == 4096)
|
|
||||||
{
|
|
||||||
var c6 = Cfg(mapSize, plateSeed, "density_mid", offshoreOn: true, revertOn: false, mid.Frac);
|
|
||||||
Pass2Result p6 = Shaping.Shape(Topography.Generate(c6), c6);
|
|
||||||
var a6 = ShapingOracle.DumpRegression("a6", "offshore density_mid ON, labeling ON, revert OFF == task-06 .f32 dump (labeling is pure analysis)",
|
|
||||||
p6.Height, HeightField.Load(t06Dump, mapSize), mapSize, t06Dump);
|
|
||||||
hard.Add(a6); GD.Print(" " + a6);
|
|
||||||
}
|
|
||||||
else GD.Print($" a6: ⚠ skipped — {(mapSize != 4096 ? "map size is not the 06 batch's 4096" : $"no 06 dump at {t06Dump}")}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ 2. DETERMINISM ═══
|
// ═══ 2. DETERMINISM ═══
|
||||||
|
|
@ -354,7 +329,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // offshore OFF, revert OFF by default
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -368,11 +343,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
|
/// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk)
|
||||||
/// ISLA_TASK authoring task number (default 3)
|
/// ISLA_TASK authoring task number (default 3)
|
||||||
/// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
|
/// ISLA_BATCH descriptor, NO prefix (default "relief_taste")
|
||||||
/// ISLA_SOURCE batch to read .f32 from (default 02_pass1_port)
|
/// ISLA_SOURCE batch to read .f32 from (default "chat1/02_pass1_port")
|
||||||
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
|
/// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ReliefRenderTool : Node
|
public partial class ReliefRenderTool : Node
|
||||||
|
|
@ -36,6 +36,10 @@ namespace IslaApocalypse.Tools
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
|
||||||
|
|
||||||
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
|
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
|
@ -43,8 +47,8 @@ namespace IslaApocalypse.Tools
|
||||||
string batch = EnvStr("ISLA_BATCH", "relief_taste");
|
string batch = EnvStr("ISLA_BATCH", "relief_taste");
|
||||||
// ⚠ A FULL batch folder name, prefix included — it names an EXISTING folder rather than
|
// ⚠ A FULL batch folder name, prefix included — it names an EXISTING folder rather than
|
||||||
// composing a new one, so it is not run through BatchRoot. Tracks TerrainGenTool's
|
// composing a new one, so it is not run through BatchRoot. Tracks TerrainGenTool's
|
||||||
// default output: BatchRoot(task 2, "pass1_port") = 02_pass1_port.
|
// default output: BatchRoot(task 2, "pass1_port") = <chat>/02_pass1_port (rivers/01: chat-namespaced).
|
||||||
string source = EnvStr("ISLA_SOURCE", "02_pass1_port");
|
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
|
||||||
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
|
bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1";
|
||||||
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
|
LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null));
|
||||||
|
|
||||||
|
|
@ -83,7 +87,9 @@ namespace IslaApocalypse.Tools
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
|
// ⭐ rivers/01: family-off PINNED — this regenerates a PHASE-1 field to stand in for a
|
||||||
|
// missing `02_pass1_port` dump, so it must reproduce that dump, not the new default.
|
||||||
|
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff();
|
||||||
Pass1Result r = Topography.Generate(cfg);
|
Pass1Result r = Topography.Generate(cfg);
|
||||||
height = r.Height;
|
height = r.Height;
|
||||||
origin = "generated";
|
origin = "generated";
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ namespace IslaApocalypse.Tools
|
||||||
///
|
///
|
||||||
/// ISLA_TASK authoring task number (default 4)
|
/// ISLA_TASK authoring task number (default 4)
|
||||||
/// ISLA_BATCH descriptor, NO prefix (default "review")
|
/// ISLA_BATCH descriptor, NO prefix (default "review")
|
||||||
/// ISLA_SOURCE batch holding the .f32 (default 02_pass1_port)
|
/// ISLA_SOURCE batch holding the .f32 (default "chat1/02_pass1_port")
|
||||||
/// ISLA_MAPSIZE side in columns (default 2048)
|
/// ISLA_MAPSIZE side in columns (default 2048)
|
||||||
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
|
/// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -59,16 +59,21 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 4);
|
int task = EnvInt("ISLA_TASK", 4);
|
||||||
string descr = EnvStr("ISLA_BATCH", "review");
|
string descr = EnvStr("ISLA_BATCH", "review");
|
||||||
// ⚠ A FULL batch folder name, prefix included — this NAMES AN EXISTING FOLDER rather than
|
// ⚠ A FULL batch folder name, prefix included — this NAMES AN EXISTING FOLDER rather than
|
||||||
// composing a new one, so it is not run through BatchRoot. The default tracks where
|
// composing a new one, so it is not run through BatchRoot. The default tracks where
|
||||||
// TerrainGenTool writes by default: BatchRoot(task 2, "pass1_port") = 02_pass1_port.
|
// TerrainGenTool writes by default: BatchRoot(task 2, "pass1_port") = chat1/02_pass1_port
|
||||||
|
// (rivers/01 namespaced batches by chat; the READ side carries the chatN/ prefix explicitly).
|
||||||
// It was "01_pass1_port" until chat1/05 renamed the folder to its authoring-task number;
|
// It was "01_pass1_port" until chat1/05 renamed the folder to its authoring-task number;
|
||||||
// a stale default here does not fail loudly, it just silently regenerates instead of
|
// a stale default here does not fail loudly, it just silently regenerates instead of
|
||||||
// loading — which is exactly the kind of quiet cost worth pinning to the real name.
|
// loading — which is exactly the kind of quiet cost worth pinning to the real name.
|
||||||
string source = EnvStr("ISLA_SOURCE", "02_pass1_port");
|
string source = EnvStr("ISLA_SOURCE", "chat1/02_pass1_port");
|
||||||
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
|
int mapSize = EnvInt("ISLA_MAPSIZE", 2048);
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
|
||||||
|
|
@ -103,7 +108,7 @@ namespace IslaApocalypse.Tools
|
||||||
if (h == null)
|
if (h == null)
|
||||||
{
|
{
|
||||||
GD.PrintErr($" ⚠ no full dump for {seed} at {mapSize} — generating deterministically.");
|
GD.PrintErr($" ⚠ no full dump for {seed} at {mapSize} — generating deterministically.");
|
||||||
h = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed }).Height;
|
h = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed)).Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.Add(Gray(h, mapSize, batchRoot, "2_height_grayscale", seed,
|
notes.Add(Gray(h, mapSize, batchRoot, "2_height_grayscale", seed,
|
||||||
|
|
|
||||||
120
Tools/Scripts/RiverCandidate.cs
Normal file
120
Tools/Scripts/RiverCandidate.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ ONE CANDIDATE MAJOR DRAINAGE — the unit the river count is chosen over (rivers/02).
|
||||||
|
///
|
||||||
|
/// ═══ WHY THIS TYPE EXISTS: ONE LIST, NOT TWO ═══
|
||||||
|
///
|
||||||
|
/// The reference promoted rivers from TWO separate lists with TWO separate quotas — N sea-reaching
|
||||||
|
/// trunks and N endorheic giants (<c>DrainageAnalysis.Params.TrunkCount</c> / <c>GiantCount</c>,
|
||||||
|
/// both 3). That structure encodes an assumption this terrain does not satisfy: that reaching the
|
||||||
|
/// sea is what makes a drainage a river, and inland ones are a second category to be quota'd
|
||||||
|
/// separately.
|
||||||
|
///
|
||||||
|
/// **On the reshaped terrain ~68 % of land drains INLAND** (measured: 67.6 % on the primary seed,
|
||||||
|
/// 116 terminal basins). A separate quota would fight that — it would promote small coastal
|
||||||
|
/// drainages over far larger inland ones purely because of where they end.
|
||||||
|
///
|
||||||
|
/// > ### So selection is UNIFIED: rank every major drainage by contributing area, promote the top N,
|
||||||
|
/// > and let the sea-vs-endorheic split FALL OUT of which promoted rivers happen to reach the ocean.
|
||||||
|
/// > **An endorheic terminus is a PASS, not a fallback** — a river ending in a significant lake is
|
||||||
|
/// > as real as one reaching the coast, and is never forced to the coast.
|
||||||
|
///
|
||||||
|
/// ⚠ This is a DELIBERATE DEPARTURE from the reference's two-list structure (approved in chat;
|
||||||
|
/// D-050 port-discipline noted). Only the SELECTION is unified — <see cref="IsSea"/> is retained
|
||||||
|
/// per river because downstream routing branches on it, and <c>Trunk</c> / <c>Giant</c> are left
|
||||||
|
/// exactly as ported.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠ THE METRIC IS THE SAME UNIT ON BOTH SIDES, AND THAT IS LOAD-BEARING ═══
|
||||||
|
///
|
||||||
|
/// <see cref="DrainagePx"/> is a COUNT OF CONTRIBUTING LAND CELLS in both cases, computed on the
|
||||||
|
/// same D8 field in the same pass:
|
||||||
|
///
|
||||||
|
/// SEA <c>Plan.Acc</c> at the outlet — every non-ocean cell is seeded 1 and accumulated
|
||||||
|
/// along <c>Plan.Dir</c>, so the outlet's value is the count of cells whose flow path
|
||||||
|
/// passes through it.
|
||||||
|
/// ENDORHEIC <c>Plan.BasinInflow[BasinId]</c> — the memoised downstream walk over the SAME
|
||||||
|
/// <c>Dir</c>, counting cells whose flow TERMINATES in that basin.
|
||||||
|
///
|
||||||
|
/// Every land cell has exactly one destination, so the two populations are disjoint and exhaustive:
|
||||||
|
/// <c>Σ sea-outlet Acc + Σ BasinInflow + UnroutedCells == LandCells</c>. `RiverPromotionTool`
|
||||||
|
/// ASSERTS that identity per seed — it is the mechanical proof that one ranking over both is sound.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RiverCandidate
|
||||||
|
{
|
||||||
|
/// <summary>⭐ The terminus. True = the outlet touches <c>RegionLabeling.OceanMask</c>; false = it pools in a terminal basin. Never a bare <c>h < sea</c> test.</summary>
|
||||||
|
public bool IsSea;
|
||||||
|
|
||||||
|
/// <summary>Row-major cell: the sea outlet, or the terminal basin's MINIMUM (its deepest cell).</summary>
|
||||||
|
public int Cell;
|
||||||
|
public int X, Y;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Where the RIVER actually ends — the point its main stem pools at, i.e. the first point of
|
||||||
|
/// <see cref="Course"/>. Defaults to <see cref="X"/>/<see cref="Y"/> until bound.
|
||||||
|
///
|
||||||
|
/// ⚠⚠ FOR AN ENDORHEIC RIVER THIS IS NOT THE BASIN'S DEEPEST CELL, and the difference is
|
||||||
|
/// visible on a map. `DrainageAnalysis` is explicit about why: *"Terminal is where the MAIN
|
||||||
|
/// STEM actually pools (its sub-minimum), which on a flat basin floor is more truthful than the
|
||||||
|
/// basin's deepest cell."* On a wide flat lagoon bed those two points can sit far apart.
|
||||||
|
///
|
||||||
|
/// Both are real and both are kept: the basin minimum is the BASIN's identity (and is what the
|
||||||
|
/// CSV records), this is the RIVER's terminus (and is what the plates mark). Marking a river at
|
||||||
|
/// its basin's deepest cell draws the stem visibly detached from its own endpoint — which reads
|
||||||
|
/// as a broken river and would corrupt a count judgment.
|
||||||
|
/// </summary>
|
||||||
|
public int TermX, TermY;
|
||||||
|
|
||||||
|
/// <summary>⭐ THE RANKING METRIC — contributing land cells. Same unit for both termini (see the class note).</summary>
|
||||||
|
public long DrainagePx;
|
||||||
|
|
||||||
|
/// <summary>Terminal-basin id (endorheic only; 0 for sea). The stable key for binding a candidate to its <c>Giant</c>.</summary>
|
||||||
|
public int BasinId;
|
||||||
|
|
||||||
|
/// <summary>Endorheic only: the basin's max fill depth, metres.</summary>
|
||||||
|
public float BasinDepthM;
|
||||||
|
|
||||||
|
/// <summary>Endorheic only: the basin's area in cells.</summary>
|
||||||
|
public long BasinAreaPx;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠ Sea only. True when this outlet was DROPPED by the <c>MinOutletSeparationPx</c> rule
|
||||||
|
/// because a larger outlet sits within that radius. Kept in the distribution (it is a real
|
||||||
|
/// drainage) but excluded from ranking — see the tool's note on what separation discards.
|
||||||
|
/// </summary>
|
||||||
|
public bool SuppressedBySeparation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The REAL upland stem — the max-accumulation traced course from <c>DrainageAnalysis</c>'s own
|
||||||
|
/// <c>TraceStem</c>, bound after selection. Null for candidates outside the promoted set.
|
||||||
|
/// ⚠ Downstream-first and decimated ×4, as the analysis produces it.
|
||||||
|
/// ⚠⚠ This is the erosion-carved course, NOT <c>Giant.ProvisionalRoute</c> — the steepest-descent
|
||||||
|
/// placeholder ("the comb") is deliberately never drawn here; replacing it is rivers/03's job,
|
||||||
|
/// and drawing it would mislead a count judgment.
|
||||||
|
/// </summary>
|
||||||
|
public List<(float x, float y)> Course;
|
||||||
|
|
||||||
|
/// <summary>1-based rank in the unified descending ranking. 0 until ranked.</summary>
|
||||||
|
public int Rank;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ rivers/03 — THE ANALYSIS'S OWN routed/lake-ender verdict, copied from <c>Giant.Kind</c> at
|
||||||
|
/// bind time. Endorheic only ("" for sea candidates).
|
||||||
|
///
|
||||||
|
/// ⚠⚠ READ THE TEST BEFORE TRUSTING THE NAME. `DrainageAnalysis` assigns this as
|
||||||
|
/// <c>(basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"</c> — i.e. purely on
|
||||||
|
/// **whether the terminal basin holds classify water**. It is NOT a path test: "routed" means
|
||||||
|
/// "this basin is a dry pan, so it SHOULD be routed", not "a route to the sea exists". Whether
|
||||||
|
/// one actually does is what `RiverRouting.RouteToOcean` decides, and the two CAN disagree.
|
||||||
|
/// rivers/03 reports both per river rather than silently picking one.
|
||||||
|
/// </summary>
|
||||||
|
public string AnalysisKind = "";
|
||||||
|
|
||||||
|
/// <summary>Endorheic only: the analysis found classify water in this terminal basin.</summary>
|
||||||
|
public bool TerminalInClassifyWater;
|
||||||
|
|
||||||
|
public string TerminusName => IsSea ? "sea" : "endorheic";
|
||||||
|
}
|
||||||
|
}
|
||||||
233
Tools/Scripts/RiverCandidates.cs
Normal file
233
Tools/Scripts/RiverCandidates.cs
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1074
Tools/Scripts/RiverPromotionTool.cs
Normal file
1074
Tools/Scripts/RiverPromotionTool.cs
Normal file
File diff suppressed because it is too large
Load diff
754
Tools/Scripts/RiverRouting.cs
Normal file
754
Tools/Scripts/RiverRouting.cs
Normal file
|
|
@ -0,0 +1,754 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ LOWLAND ROUTING (rivers/03) — the ROUTING PORTION of the reference's `RiverCarvePass`,
|
||||||
|
/// ported faithfully (D-050). **Courses only. This file reads heights and writes none.**
|
||||||
|
///
|
||||||
|
/// ═══ ⛔ THE RED LINE ═══
|
||||||
|
///
|
||||||
|
/// **Nothing here fills water, creates a water body, or mutates any height field.** It produces
|
||||||
|
/// polylines. The bed CARVE (`CarveRiver`, mutates render height, flood-guarded) and the STEPPED
|
||||||
|
/// WATER model (`AddSteppedWater`, creates bodies) are the reference's separate stages and are
|
||||||
|
/// separate later tasks. Verified at rivers/03 Part 0: in the reference, routing is pure — the
|
||||||
|
/// carve mutates, and `AddSteppedWater` is a call the CALLER makes afterwards, not something
|
||||||
|
/// `Apply` does. Lake-enders target EXISTING classify water; no lake is ever created.
|
||||||
|
///
|
||||||
|
/// ═══ ⭐ WHY THE COST MODEL IS THE LOAD-BEARING PIECE ═══
|
||||||
|
///
|
||||||
|
/// **An endorheic terminal is a local minimum by definition** — a downhill path out of it does not
|
||||||
|
/// exist, so "can it flow to the sea?" cannot be answered by descent. It is answered by cost: the
|
||||||
|
/// cheapest LOWGROUND path is allowed to climb over the basin's rim, paying heavily for it
|
||||||
|
/// (uphill penalised, never forbidden). That is the route-version of an overflow channel — a
|
||||||
|
/// channel over the spill, **with no water filled**.
|
||||||
|
///
|
||||||
|
/// SHORT cost ≈ distance, uphill lightly penalised — heads direct, avoids walls. (Rejected
|
||||||
|
/// by the reference's own gate as "a dead-straight canal"; ported for completeness.)
|
||||||
|
/// LOWGROUND cost ≈ BEING high (per px of travel) plus heavily for CLIMBING, so the cheapest
|
||||||
|
/// corridor is the lowest ground even when that wanders. **The locked style.**
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ THE CONSTANTS ARE DECLARED == EFFECTIVE, AND THAT WAS CHECKED ═══
|
||||||
|
///
|
||||||
|
/// `00_ground` warned that the reference's effective river tunables live in `ConfigManager`, not in
|
||||||
|
/// the `Params` initializers (WidthScale 1.0→1.75, DepthScale 1.0→1.5). **Those are carve-time and
|
||||||
|
/// out of scope here.** The four ROUTING cost constants below are `private const` inside
|
||||||
|
/// `RiverCarvePass` with no `ConfigManager` key and no `[Export]` anywhere in the reference repo —
|
||||||
|
/// verified by grep at rivers/03 Part 0 — so for routing, declared IS effective. The one routing
|
||||||
|
/// value that does come from config is the STYLE, effective `"lowground"`, which equals the
|
||||||
|
/// declared default.
|
||||||
|
/// </summary>
|
||||||
|
public static class RiverRouting
|
||||||
|
{
|
||||||
|
public const byte StyleShort = 0;
|
||||||
|
public const byte StyleLowground = 1;
|
||||||
|
|
||||||
|
// ⚠ Ported verbatim. SHORT pays lightly for climbing (8 per metre of rise, so a 10 m wall costs
|
||||||
|
// like an 80 px detour). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||||||
|
// heavily for climbing (50 per metre).
|
||||||
|
public const float ShortUphillPerM = 8f;
|
||||||
|
public const float LowgroundElevPerM = 1f;
|
||||||
|
public const float LowgroundBase = 0.05f;
|
||||||
|
public const float LowgroundUphillPerM = 50f;
|
||||||
|
|
||||||
|
/// <summary>The reference's smallest water body a lake-ender may target (`RiverLakeMinTargetPx`,
|
||||||
|
/// effective 20,000 — declared and config agree). "Nearest wet pixel" routed one into a 3-cell
|
||||||
|
/// puddle a few hundred px short of the obvious lagoon; that was the task-23 gate finding.</summary>
|
||||||
|
public const int LakeMinTargetPx = 20_000;
|
||||||
|
|
||||||
|
// 8-connectivity in the reference's exact order — the tie-break structure is part of the result.
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
/// <summary>One lowland route, with the diagnostics the gate needs to judge it.</summary>
|
||||||
|
public sealed class Route
|
||||||
|
{
|
||||||
|
/// <summary>Terminal → target, 1-px steps, as Dijkstra produced it. Empty when no path exists.</summary>
|
||||||
|
public List<(float x, float y)> Path = new();
|
||||||
|
/// <summary>The same reach after RDP + Chaikin. This is what is drawn and spliced.</summary>
|
||||||
|
public List<(float x, float y)> Smoothed = new();
|
||||||
|
/// <summary>⭐ Did a path exist at all? Empty list on no path — never thrown.</summary>
|
||||||
|
public bool Reached;
|
||||||
|
/// <summary>Dijkstra cost at the goal (cost-model units, not metres).</summary>
|
||||||
|
public float Cost;
|
||||||
|
/// <summary>⭐⭐ THE RIM: the largest single-step climb on the route, metres. The number that
|
||||||
|
/// says whether a route crawls over a saddle or vaults a wall.</summary>
|
||||||
|
public float MaxStepUphillM;
|
||||||
|
/// <summary>⭐ Total metres climbed along the route, and how many steps climbed at all.</summary>
|
||||||
|
public float TotalUphillM;
|
||||||
|
public int UphillSteps;
|
||||||
|
/// <summary>Highest point on the route, metres above sea — the rim's absolute height.</summary>
|
||||||
|
public float MaxElevM;
|
||||||
|
/// <summary>Net climb from the terminal to the route's high point, metres — what "over the rim" costs.</summary>
|
||||||
|
public float RimClimbM;
|
||||||
|
/// <summary>⭐ WHERE the route tops out — the rim cell, ringed on the plate.</summary>
|
||||||
|
public (float x, float y) RimPoint;
|
||||||
|
public float LenPx, StraightPx, WanderRatio;
|
||||||
|
/// <summary>Cells settled by the search — the honest cost of a Dijkstra at this map size.</summary>
|
||||||
|
public long Expanded;
|
||||||
|
public (float x, float y) Target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Deterministic Dijkstra from a start cell to the nearest cell of <paramref name="targets"/>
|
||||||
|
/// under the selected cost model. Ported from `RiverCarvePass.RouteToOcean`.
|
||||||
|
///
|
||||||
|
/// ⚠ **Returns an empty path when no path exists — it never throws.** That contract is
|
||||||
|
/// load-bearing: "no affordable route" is a RESULT (the river is a lake-ender), not an error.
|
||||||
|
///
|
||||||
|
/// ⚠ `targets` is a generic mask: `OceanMask` for a route to the sea, significant-water for a
|
||||||
|
/// lake-ender's extension. One routine, two uses — as the reference has it.
|
||||||
|
///
|
||||||
|
/// Determinism: the priority is `(cost, cellIndex)`, so equal costs break on the lower index and
|
||||||
|
/// the result cannot depend on heap internals. The search settles a cell once (`closed`) and
|
||||||
|
/// stops the moment it DEQUEUES a target, so the first target reached is the cheapest.
|
||||||
|
/// </summary>
|
||||||
|
public static Route RouteTo(float[,] height, int n, bool[] targets, int sx, int sy, byte style, float sea)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var gcost = new float[total];
|
||||||
|
var parent = new int[total];
|
||||||
|
var closed = new bool[total];
|
||||||
|
Array.Fill(gcost, float.MaxValue);
|
||||||
|
Array.Fill(parent, -1);
|
||||||
|
|
||||||
|
// ⚠ Elevation is clamped at sea: below-sea ground is not "cheaper than sea level", it is sea
|
||||||
|
// level. Without the clamp a route would dive for the deepest hole it could find.
|
||||||
|
float ElevM(int x, int y) => MathF.Max(0f, WorldScale.MetresFromRaw(height[x, y] - sea));
|
||||||
|
|
||||||
|
var pq = new PriorityQueue<int, (float c, int i)>();
|
||||||
|
int start = sx * n + sy;
|
||||||
|
gcost[start] = 0f;
|
||||||
|
pq.Enqueue(start, (0f, start));
|
||||||
|
int goal = -1;
|
||||||
|
long expanded = 0;
|
||||||
|
|
||||||
|
while (pq.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pq.Dequeue();
|
||||||
|
if (closed[c]) continue;
|
||||||
|
closed[c] = true;
|
||||||
|
expanded++;
|
||||||
|
if (targets[c]) { goal = c; break; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float hc = height[cx, cy];
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (closed[ni]) continue;
|
||||||
|
float dhM = MathF.Max(0f, WorldScale.MetresFromRaw(height[nx, ny] - hc));
|
||||||
|
float step = style == StyleShort
|
||||||
|
? DIST[k] + dhM * ShortUphillPerM
|
||||||
|
: DIST[k] * (LowgroundBase + ElevM(nx, ny) * LowgroundElevPerM)
|
||||||
|
+ dhM * LowgroundUphillPerM;
|
||||||
|
float nc = gcost[c] + step;
|
||||||
|
if (nc < gcost[ni])
|
||||||
|
{
|
||||||
|
gcost[ni] = nc;
|
||||||
|
parent[ni] = c;
|
||||||
|
pq.Enqueue(ni, (nc, ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = new Route { Expanded = expanded };
|
||||||
|
if (goal < 0) return r; // no path — an empty route, reported upstream
|
||||||
|
|
||||||
|
for (int c = goal; c >= 0; c = parent[c]) r.Path.Add((c / n, c % n));
|
||||||
|
r.Path.Reverse();
|
||||||
|
r.Reached = true;
|
||||||
|
r.Cost = gcost[goal];
|
||||||
|
r.Target = r.Path[^1];
|
||||||
|
Measure(r, height, n, sea);
|
||||||
|
r.Smoothed = SmoothCourse(r.Path);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The diagnostics the gate reads — measured on the RAW path, before smoothing, because the
|
||||||
|
/// rim it crossed is a fact about the terrain and must not be a function of the pretty pass.
|
||||||
|
/// </summary>
|
||||||
|
private static void Measure(Route r, float[,] height, int n, float sea)
|
||||||
|
{
|
||||||
|
float startElev = ElevAt(r.Path[0]);
|
||||||
|
float maxElev = startElev;
|
||||||
|
r.RimPoint = r.Path[0];
|
||||||
|
for (int i = 1; i < r.Path.Count; i++)
|
||||||
|
{
|
||||||
|
var a = r.Path[i - 1]; var b = r.Path[i];
|
||||||
|
float dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
r.LenPx += MathF.Sqrt(dx * dx + dy * dy);
|
||||||
|
float climb = ElevAt(b) - ElevAt(a);
|
||||||
|
if (climb > 0f) { r.TotalUphillM += climb; r.UphillSteps++; }
|
||||||
|
if (climb > r.MaxStepUphillM) r.MaxStepUphillM = climb;
|
||||||
|
if (ElevAt(b) > maxElev) { maxElev = ElevAt(b); r.RimPoint = b; }
|
||||||
|
}
|
||||||
|
r.MaxElevM = maxElev;
|
||||||
|
r.RimClimbM = maxElev - startElev;
|
||||||
|
var s = r.Path[0]; var e = r.Path[^1];
|
||||||
|
r.StraightPx = MathF.Sqrt((e.x - s.x) * (e.x - s.x) + (e.y - s.y) * (e.y - s.y));
|
||||||
|
// ⚠ Wander is POLYLINE length over straight-line — a cell count undercounts diagonal steps
|
||||||
|
// and can read below 1, which is geometrically impossible. (The reference's own fix.)
|
||||||
|
r.WanderRatio = r.StraightPx > 1f ? r.LenPx / r.StraightPx : 1f;
|
||||||
|
|
||||||
|
float ElevAt((float x, float y) p) =>
|
||||||
|
MathF.Max(0f, WorldScale.MetresFromRaw(height[(int)p.x, (int)p.y] - sea));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Route smoothing — ported verbatim: RDP(4.0) + 4 Chaikin passes, endpoints pinned ------
|
||||||
|
//
|
||||||
|
// ⚠⚠ THIS IS APPLIED TO THE LOWLAND REACH ONLY, NEVER THE UPLAND STEM, and that split is not a
|
||||||
|
// style preference — it is a measured result. The Dijkstra's 45° kinks live on near-flat ground
|
||||||
|
// where a rounded corner costs nothing. The upland stems already thread the erosion-carved
|
||||||
|
// valley FLOORS; smoothing them cuts the corners off the valleys themselves, which in the
|
||||||
|
// reference took the max cut from 14.6 m to 27.3 m.
|
||||||
|
|
||||||
|
/// <summary>RDP tol 4 + 4 Chaikin corner-cutting passes, endpoints pinned.</summary>
|
||||||
|
public static List<(float x, float y)> SmoothCourse(List<(float x, float y)> raw)
|
||||||
|
{
|
||||||
|
if (raw.Count < 3) return raw;
|
||||||
|
var dec = Rdp(raw, 0, raw.Count - 1, 4.0f);
|
||||||
|
if (dec.Count < 3) return raw;
|
||||||
|
var sm = dec;
|
||||||
|
for (int pass = 0; pass < 4; pass++)
|
||||||
|
{
|
||||||
|
var nxt = new List<(float x, float y)>(sm.Count * 2) { sm[0] };
|
||||||
|
for (int i = 0; i + 1 < sm.Count; i++)
|
||||||
|
{
|
||||||
|
var a = sm[i]; var b = sm[i + 1];
|
||||||
|
nxt.Add((a.x * 0.75f + b.x * 0.25f, a.y * 0.75f + b.y * 0.25f));
|
||||||
|
nxt.Add((a.x * 0.25f + b.x * 0.75f, a.y * 0.25f + b.y * 0.75f));
|
||||||
|
}
|
||||||
|
nxt.Add(sm[^1]);
|
||||||
|
sm = nxt;
|
||||||
|
}
|
||||||
|
return sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(float x, float y)> Rdp(List<(float x, float y)> pts, int i0, int i1, float tol)
|
||||||
|
{
|
||||||
|
if (i1 - i0 <= 1) return new List<(float x, float y)> { pts[i0], pts[i1] };
|
||||||
|
var a = pts[i0]; var b = pts[i1];
|
||||||
|
float abx = b.x - a.x, aby = b.y - a.y;
|
||||||
|
float abLen = MathF.Sqrt(abx * abx + aby * aby);
|
||||||
|
float maxD = 0f; int maxI = i0;
|
||||||
|
for (int i = i0 + 1; i < i1; i++)
|
||||||
|
{
|
||||||
|
float d = abLen < 1e-6f
|
||||||
|
? MathF.Sqrt((pts[i].x - a.x) * (pts[i].x - a.x) + (pts[i].y - a.y) * (pts[i].y - a.y))
|
||||||
|
: MathF.Abs(abx * (a.y - pts[i].y) - (a.x - pts[i].x) * aby) / abLen;
|
||||||
|
if (d > maxD) { maxD = d; maxI = i; }
|
||||||
|
}
|
||||||
|
if (maxD <= tol) return new List<(float x, float y)> { pts[i0], pts[i1] };
|
||||||
|
var left = Rdp(pts, i0, maxI, tol);
|
||||||
|
var right = Rdp(pts, maxI, i1, tol);
|
||||||
|
left.RemoveAt(left.Count - 1);
|
||||||
|
left.AddRange(right);
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The three classes the MIX is made of.</summary>
|
||||||
|
public enum RiverClass
|
||||||
|
{
|
||||||
|
/// <summary>Sea-reaching already, exactly as erosion carved it. No lowland route needed.</summary>
|
||||||
|
OceanTrunk,
|
||||||
|
/// <summary>An endorheic basin connected to the coast by a routed over-the-rim channel.</summary>
|
||||||
|
RoutedGiant,
|
||||||
|
/// <summary>Stays inland: terminates at a significant lake, or at its own terminal.</summary>
|
||||||
|
LakeEnder,
|
||||||
|
|
||||||
|
// ═══ rivers/03b — two new termini, from the three approved DIVERGENCES ═══
|
||||||
|
|
||||||
|
/// <summary>⭐ rivers/03b (fix 3): a dry-basin router that reached a SIGNIFICANT LAKE before it
|
||||||
|
/// reached the sea, and terminates there. In the reference a router targets ocean only, so it
|
||||||
|
/// would skirt the lake and carry on — which is what this corrects. **No water is created.**</summary>
|
||||||
|
LakeFed,
|
||||||
|
|
||||||
|
/// <summary>⚠ rivers/03b (fix 1): a dry-basin router whose cheapest route to the sea had to
|
||||||
|
/// climb a rim HIGHER THAN THE CAP. The reference routes at any cost, which produced an
|
||||||
|
/// uphill river over a 66.7 m wall. Refused: the course ends at its own terminal — a real
|
||||||
|
/// terminal basin. **Nothing is filled; it just ends there.**</summary>
|
||||||
|
WalledOff,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⚠⚠ THE THREE DELIBERATE DIVERGENCES FROM THE REFERENCE (rivers/03b), off by default.
|
||||||
|
///
|
||||||
|
/// Defaults reproduce rivers/03's faithful port EXACTLY — no cap, ocean-only targets, no
|
||||||
|
/// confluence — so that batch stays re-runnable bit-for-bit. The refinement task turns them on.
|
||||||
|
/// **None of these is a port. Each is a motivated correction of a faithful behaviour that
|
||||||
|
/// produced a physically-wrong result**, on the developer's explicit call.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Options
|
||||||
|
{
|
||||||
|
/// <summary>⭐ FIX 1 — the rim cap, metres. A route to the sea that must climb higher than
|
||||||
|
/// this above its terminal is refused and the river becomes a walled-off lake-ender.
|
||||||
|
/// Infinity = the reference's behaviour (route at any cost).</summary>
|
||||||
|
public float RimCapM = float.PositiveInfinity;
|
||||||
|
|
||||||
|
/// <summary>⭐ FIX 3 — include significant lakes in a ROUTER's target mask, so a river stops
|
||||||
|
/// at the nearer of {ocean, significant lake} instead of skirting a lake to reach the sea.
|
||||||
|
/// False = the reference's behaviour (routers target ocean only).</summary>
|
||||||
|
public bool LakeTargetForRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ rivers/03c FIX A — lake-termination becomes a PREFERENCE instead of an unconditional
|
||||||
|
/// capture. Set > 0 to enable; it then supersedes the plain nearest-of-union rule above.
|
||||||
|
///
|
||||||
|
/// ⚠⚠ WHY rivers/03b OVERSHOT. "Nearest of {ocean ∪ lake}" lets a lake that is merely a
|
||||||
|
/// *little* closer capture a river that had a clear shot at the coast — and it moved **12
|
||||||
|
/// rivers** to lake-fed, roughly halving the island's sea mouths (5/5/6/5 → 4/2/3/3). The
|
||||||
|
/// rule here is deliberately sea-biased instead:
|
||||||
|
/// <code>
|
||||||
|
/// lake-fed iff cost_lake < LakePreferRatio × cost_ocean
|
||||||
|
/// </code>
|
||||||
|
/// so a lake must be MATERIALLY cheaper to reach, not just nearer. **Lower ratio → more sea
|
||||||
|
/// rivers.** Both costs are recorded per router whether or not the lake wins, so the knob can
|
||||||
|
/// be read off the table without a re-run.
|
||||||
|
/// </summary>
|
||||||
|
public float LakePreferRatio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ rivers/03c FIX B — a NATURAL lake-ender terminates at the water inside its OWN
|
||||||
|
/// terminal basin, at any size.
|
||||||
|
///
|
||||||
|
/// ⚠ The 20,000 px significance threshold is what exiled `999999937 #3` from its own home:
|
||||||
|
/// its basin's lake was sub-threshold, so it marched ~5,800 px along the shoreline hunting a
|
||||||
|
/// distant "significant" body. A basin's own water is where its flow goes regardless of how
|
||||||
|
/// big it is. **The threshold still applies to ROUTERS choosing a DISTANT lake** — a dry
|
||||||
|
/// basin still cannot connect itself to a three-cell puddle.
|
||||||
|
/// </summary>
|
||||||
|
public bool OwnBasinLakeEnder;
|
||||||
|
|
||||||
|
/// <summary>⭐ FIX 2 — the confluence post-pass: courses laid biggest-first join on true cell
|
||||||
|
/// intersection instead of running as parallel duplicates to the same mouth.
|
||||||
|
/// False = the reference's behaviour (no dedup, no join).</summary>
|
||||||
|
public bool Confluence;
|
||||||
|
|
||||||
|
/// <summary>rivers/03's faithful settings — every divergence off.</summary>
|
||||||
|
public static Options Faithful => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One promoted river, classified, routed and assembled.</summary>
|
||||||
|
public sealed class RoutedRiver
|
||||||
|
{
|
||||||
|
public RiverCandidate Candidate;
|
||||||
|
public RiverClass Class;
|
||||||
|
/// <summary>The lowland reach actually used: the ocean route for a routed giant, the lake
|
||||||
|
/// route for a lake-ender. Null for trunks.</summary>
|
||||||
|
public Route Lowland;
|
||||||
|
/// <summary>⭐ The ocean route computed for EVERY giant, including lake-enders — see the note
|
||||||
|
/// on <see cref="RouteAll"/>. This is what makes an affordability threshold judgeable.</summary>
|
||||||
|
public Route OceanProbe;
|
||||||
|
/// <summary>Lake-enders: did the extension reach a SIGNIFICANT body (vs the classify fallback, vs nothing)?</summary>
|
||||||
|
public bool LakeReached, LakeWasFallback;
|
||||||
|
/// <summary>Head → terminus, stem + smoothed lowland reach.</summary>
|
||||||
|
public List<(float x, float y)> Course;
|
||||||
|
public string Why = "";
|
||||||
|
|
||||||
|
// ═══ rivers/03b ═══
|
||||||
|
|
||||||
|
/// <summary>⚠ The rim climb that was tested against the cap, and whether it was refused.</summary>
|
||||||
|
public float CappedRimM;
|
||||||
|
public bool RefusedByCap;
|
||||||
|
|
||||||
|
/// <summary>⭐ rivers/03c FIX A's lever, recorded for EVERY router — lake-fed or not — so the
|
||||||
|
/// developer can read off which ratio value flips which river without a re-run.
|
||||||
|
/// <see cref="CostRatio"/> is cost_lake / cost_ocean; a river is lake-fed iff it is below
|
||||||
|
/// the configured ratio. NaN where the leg was not reachable.</summary>
|
||||||
|
public float CostOcean = float.NaN, CostLake = float.NaN, CostRatio = float.NaN;
|
||||||
|
/// <summary>Lake-enders (fix B): the route to its own basin's water, for the coast-hugger check.</summary>
|
||||||
|
public bool OwnBasinTargeted;
|
||||||
|
/// <summary>The class this river WOULD have had under the reference's rules — so every
|
||||||
|
/// reclassification the divergences caused is legible rather than silent.</summary>
|
||||||
|
public RiverClass FaithfulClass;
|
||||||
|
|
||||||
|
/// <summary>The full course rasterised to cells — what the confluence test intersects on.</summary>
|
||||||
|
public List<(int x, int y)> CellPath;
|
||||||
|
/// <summary>⭐ What this river draws: its OWN reach, truncated at its junction if it joined.
|
||||||
|
/// The union of every river's own reach is the dendritic tree.</summary>
|
||||||
|
public List<(float x, float y)> OwnPath;
|
||||||
|
/// <summary>The rank of the river this one flows into, or 0 if it keeps its own terminus.</summary>
|
||||||
|
public int ConfluenceParentRank;
|
||||||
|
public bool Joined;
|
||||||
|
public (int x, int y) JunctionCell;
|
||||||
|
/// <summary>How many leading cells of <see cref="CellPath"/> are the NATURAL upland stem.
|
||||||
|
/// Everything after is the lowland reach routing added — the plate colours the two apart.</summary>
|
||||||
|
public int StemCells;
|
||||||
|
|
||||||
|
/// <summary>Does this river's own course end at the sea, before any confluence?</summary>
|
||||||
|
public bool ReachesSea => Class == RiverClass.OceanTrunk || Class == RiverClass.RoutedGiant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ CLASSIFY AND ROUTE THE PROMOTED SET.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠⚠ WHAT DECIDES routed-vs-lake-ender, AND WHY IT IS NOT A PATH TEST ═══
|
||||||
|
///
|
||||||
|
/// rivers/03's task states the sort as *"an affordable over-the-rim LOWGROUND path to the ocean
|
||||||
|
/// exists → routed-through; none → lake-ender."* **Ported literally, that test classifies
|
||||||
|
/// everything as routed, because on an 8-connected grid with all-finite costs a path to the
|
||||||
|
/// ocean ALWAYS exists.** `RouteTo` returns empty only when the queue drains without reaching a
|
||||||
|
/// target, which cannot happen when the ocean is reachable at *some* price. There is no "none".
|
||||||
|
/// The word doing the work is *affordable*, and no threshold is specified anywhere.
|
||||||
|
///
|
||||||
|
/// **So the reference's sort is used, because it is the one that actually discriminates:**
|
||||||
|
/// <code>
|
||||||
|
/// Kind = (basinHasLake[id] && !SouthernCandidate) ? "lake-ender" : "routed"
|
||||||
|
/// </code>
|
||||||
|
/// i.e. **does the terminal basin hold classify water?** A basin that is already a lake is a
|
||||||
|
/// natural lake-ender; a dry pan gets routed to the sea. That is `DrainageAnalysis`'s own
|
||||||
|
/// verdict, carried on `Giant.Kind`, and this port consumes it rather than inventing a rule.
|
||||||
|
/// (v2 has no towns, so `southernPick` is −1 and the southern override never fires.)
|
||||||
|
///
|
||||||
|
/// ⭐ **And the missing threshold is surfaced rather than guessed:** the ocean route is computed
|
||||||
|
/// for EVERY giant, lake-enders included (<see cref="RoutedRiver.OceanProbe"/>), so the batch can
|
||||||
|
/// report what each one WOULD cost and how high a rim it WOULD have to cross. That turns
|
||||||
|
/// "affordable" from an unstated assumption into a number the developer can put a bar under.
|
||||||
|
/// **Nothing is locked here — the classification shown is the reference's.**
|
||||||
|
/// </summary>
|
||||||
|
public static List<RoutedRiver> RouteAll(List<RiverCandidate> promoted, float[,] height, int n,
|
||||||
|
bool[] isOcean, bool[] isClassifyWater, bool[] isSignificantWater, float sea, byte style,
|
||||||
|
Action<string> log, Options opt = null, int[] basinId = null)
|
||||||
|
{
|
||||||
|
opt ??= Options.Faithful;
|
||||||
|
if (opt.OwnBasinLakeEnder && basinId == null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"[RiverRouting] OwnBasinLakeEnder needs Plan.BasinId to know which water is a basin's OWN. " +
|
||||||
|
"Pass it; refusing to silently fall back to the distant-significant-body rule that produced the coast-hugger.");
|
||||||
|
|
||||||
|
// ⭐ FIX 3 — the router's target mask. With the divergence off this is the ocean alone, which
|
||||||
|
// is the reference. With it on, a significant lake is an equally valid place for a river to
|
||||||
|
// stop, so the Dijkstra halts at whichever it reaches first and a river can no longer skirt
|
||||||
|
// a lake on its way to a distant coast.
|
||||||
|
bool[] routerTargets = isOcean;
|
||||||
|
if (opt.LakeTargetForRouters)
|
||||||
|
{
|
||||||
|
routerTargets = new bool[n * n];
|
||||||
|
for (int i = 0; i < routerTargets.Length; i++)
|
||||||
|
routerTargets[i] = isOcean[i] || isSignificantWater[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
var outp = new List<RoutedRiver>();
|
||||||
|
foreach (var c in promoted)
|
||||||
|
{
|
||||||
|
var rr = new RoutedRiver { Candidate = c };
|
||||||
|
|
||||||
|
if (c.IsSea)
|
||||||
|
{
|
||||||
|
// A natural ocean trunk needs no lowland route: erosion already carried it to the
|
||||||
|
// coast, and its outlet is ON the coast by construction. The stem IS the course.
|
||||||
|
rr.Class = RiverClass.OceanTrunk;
|
||||||
|
rr.Course = new List<(float x, float y)>(c.Course);
|
||||||
|
rr.Course.Reverse();
|
||||||
|
rr.Why = "sea outlet — erosion already reaches the coast; no lowland route needed";
|
||||||
|
outp.Add(rr);
|
||||||
|
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px TRUNK (natural, {rr.Course.Count} pts)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ The ocean probe, for every giant — the affordability evidence (rivers/03).
|
||||||
|
var probe = RouteTo(height, n, isOcean, c.TermX, c.TermY, style, sea);
|
||||||
|
rr.OceanProbe = probe;
|
||||||
|
|
||||||
|
// ⚠ `basinHasLake` is KEPT as the sort (rivers/03's finding): a basin that already holds a
|
||||||
|
// visible lake is a natural lake-ender and its river feeds its own lake — it is not routed
|
||||||
|
// anywhere. Only DRY basins are candidate routers. None of the three divergences touches this.
|
||||||
|
bool refLakeEnder = c.AnalysisKind == "lake-ender";
|
||||||
|
if (!refLakeEnder)
|
||||||
|
{
|
||||||
|
rr.FaithfulClass = RiverClass.RoutedGiant;
|
||||||
|
Route route;
|
||||||
|
bool stoppedAtLake;
|
||||||
|
|
||||||
|
if (opt.LakePreferRatio > 0f)
|
||||||
|
{
|
||||||
|
// ⭐⭐ rivers/03c FIX A — the two legs are costed SEPARATELY and compared, instead of
|
||||||
|
// racing in one search. That is the whole difference: a shared search returns
|
||||||
|
// whichever is nearer, this one returns the sea unless the lake is materially cheaper.
|
||||||
|
var lakeLeg = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, style, sea);
|
||||||
|
rr.CostOcean = probe.Reached ? probe.Cost : float.NaN;
|
||||||
|
rr.CostLake = lakeLeg.Reached ? lakeLeg.Cost : float.NaN;
|
||||||
|
rr.CostRatio = probe.Reached && lakeLeg.Reached && probe.Cost > 0f
|
||||||
|
? lakeLeg.Cost / probe.Cost : float.NaN;
|
||||||
|
|
||||||
|
// ⚠ No reachable lake → the sea, always. No reachable ocean → the lake if there is one.
|
||||||
|
stoppedAtLake = lakeLeg.Reached && probe.Reached
|
||||||
|
&& lakeLeg.Cost < opt.LakePreferRatio * probe.Cost;
|
||||||
|
if (lakeLeg.Reached && !probe.Reached) stoppedAtLake = true;
|
||||||
|
route = stoppedAtLake ? lakeLeg : probe;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// rivers/03b — the nearest of the union mask, whichever that turns out to be.
|
||||||
|
route = opt.LakeTargetForRouters
|
||||||
|
? RouteTo(height, n, routerTargets, c.TermX, c.TermY, style, sea)
|
||||||
|
: probe;
|
||||||
|
stoppedAtLake = route.Reached
|
||||||
|
&& isSignificantWater[(int)route.Target.x * n + (int)route.Target.y]
|
||||||
|
&& !isOcean[(int)route.Target.x * n + (int)route.Target.y];
|
||||||
|
}
|
||||||
|
|
||||||
|
rr.Lowland = route;
|
||||||
|
rr.CappedRimM = route.Reached ? route.RimClimbM : 0f;
|
||||||
|
|
||||||
|
if (!route.Reached)
|
||||||
|
{
|
||||||
|
rr.Class = RiverClass.RoutedGiant;
|
||||||
|
rr.Why = "dry pan → routed, but NO path to a target was found (unexpected — report)";
|
||||||
|
}
|
||||||
|
else if (stoppedAtLake)
|
||||||
|
{
|
||||||
|
// It ends at a significant lake — because that lake was nearer (03b) or materially
|
||||||
|
// cheaper (03c). NO WATER CREATED: the course simply ends at an existing body.
|
||||||
|
rr.Class = RiverClass.LakeFed;
|
||||||
|
rr.Why = opt.LakePreferRatio > 0f
|
||||||
|
? $"dry pan → LAKE-FED: reaching a significant lake costs {rr.CostLake:N0} vs {rr.CostOcean:N0} to the sea (ratio {rr.CostRatio:F3} < {opt.LakePreferRatio:F2}) — materially cheaper, so it ends at the lake"
|
||||||
|
: $"dry pan → reached a SIGNIFICANT LAKE at ({(int)route.Target.x},{(int)route.Target.y}) before the sea, {route.LenPx:F0} px away — terminates there (faithful: would have skirted it for the coast)";
|
||||||
|
}
|
||||||
|
else if (route.RimClimbM > opt.RimCapM)
|
||||||
|
{
|
||||||
|
// ⭐ FIX 1 — the cheapest way to the sea still climbs a wall. Refuse it. The course
|
||||||
|
// ends at its own terminal, a real terminal basin. NOTHING IS FILLED.
|
||||||
|
rr.Class = RiverClass.WalledOff;
|
||||||
|
rr.RefusedByCap = true;
|
||||||
|
rr.Lowland = null;
|
||||||
|
rr.Why = $"dry pan → WALLED OFF: cheapest route to the sea climbs {route.RimClimbM:F1} m > cap {opt.RimCapM:F0} m (cost {route.Cost:N0}) — ends at its own terminal";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rr.Class = RiverClass.RoutedGiant;
|
||||||
|
rr.Why = $"dry pan → routed to the SEA; rim climb {route.RimClimbM:F1} m ≤ cap {(float.IsInfinity(opt.RimCapM) ? "none" : opt.RimCapM.ToString("F0") + " m")}, cost {route.Cost:N0}" +
|
||||||
|
(opt.LakePreferRatio > 0f && !float.IsNaN(rr.CostRatio) ? $"; the nearest lake was not materially cheaper (ratio {rr.CostRatio:F3} ≥ {opt.LakePreferRatio:F2})" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rr.Class = RiverClass.LakeEnder;
|
||||||
|
// The stem pools on dry ground short of its lake BECAUSE the pooling point is a local
|
||||||
|
// minimum — a blind descent dead-ends there immediately. Route with the same lowground
|
||||||
|
// Dijkstra so the course actually joins the water.
|
||||||
|
// ⚠ Lake-enders route with LOWGROUND regardless of the style knob (the reference's rule).
|
||||||
|
Route ext;
|
||||||
|
if (opt.OwnBasinLakeEnder)
|
||||||
|
{
|
||||||
|
// ⭐⭐ rivers/03c FIX B — its OWN basin's water, at any size. This is where its flow
|
||||||
|
// goes; it has no business hunting a distant body. Killing the coast-hugger outright.
|
||||||
|
var ownWater = new bool[n * n];
|
||||||
|
int owned = 0;
|
||||||
|
for (int i = 0; i < ownWater.Length; i++)
|
||||||
|
if (basinId[i] == c.BasinId && isClassifyWater[i] && !isOcean[i]) { ownWater[i] = true; owned++; }
|
||||||
|
if (owned > 0)
|
||||||
|
{
|
||||||
|
ext = RouteTo(height, n, ownWater, c.TermX, c.TermY, StyleLowground, sea);
|
||||||
|
rr.OwnBasinTargeted = ext.Reached;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ⚠ Should not happen — basinHasLake is what put it in this branch — but a
|
||||||
|
// basin whose water is all ocean-masked would land here. Report, do not crash.
|
||||||
|
ext = new Route();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var far = RouteTo(height, n, isSignificantWater, c.TermX, c.TermY, StyleLowground, sea);
|
||||||
|
ext = far;
|
||||||
|
if (!ext.Reached)
|
||||||
|
{
|
||||||
|
// Fall back to ANY classify water, so a seed whose lake-ender genuinely has only
|
||||||
|
// small ponds still connects rather than dead-ending.
|
||||||
|
var fb = RouteTo(height, n, isClassifyWater, c.TermX, c.TermY, StyleLowground, sea);
|
||||||
|
if (fb.Reached) { ext = fb; rr.LakeWasFallback = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ext.Reached) { rr.Lowland = ext; rr.LakeReached = true; }
|
||||||
|
rr.FaithfulClass = RiverClass.LakeEnder;
|
||||||
|
rr.Why = rr.LakeReached
|
||||||
|
? (rr.OwnBasinTargeted
|
||||||
|
? $"terminal basin holds classify water → natural lake-ender; terminates at its OWN basin's water {ext.LenPx:F0} px away (fix B — no distant-body hunt, so no coast-hugging)"
|
||||||
|
: $"terminal basin holds classify water → natural lake-ender; joins {(rr.LakeWasFallback ? "a small body (fallback)" : "a significant body")} {ext.LenPx:F0} px away")
|
||||||
|
: "terminal basin holds classify water → natural lake-ender; no water body reachable, course ends at its terminal";
|
||||||
|
}
|
||||||
|
|
||||||
|
rr.Course = Assemble(c.Course, rr.Lowland);
|
||||||
|
outp.Add(rr);
|
||||||
|
log($" #{c.Rank,-3} {c.DrainagePx,10:N0} px {ClassLabel(rr.Class),-11} " +
|
||||||
|
$"probe{(probe.Reached ? $" cost {probe.Cost,12:N0} rim {probe.RimClimbM,6:F1} m maxstep {probe.MaxStepUphillM,5:F2} m len {probe.LenPx,6:F0} px wander {probe.WanderRatio:F2}" : " NO PATH")}" +
|
||||||
|
$"{(rr.Class == RiverClass.LakeEnder ? $" | lake {(rr.LakeReached ? (rr.LakeWasFallback ? "fallback" : "significant") : "NONE")}" : "")}" +
|
||||||
|
$"{(rr.RefusedByCap ? " ⚠ REFUSED BY CAP" : "")}" +
|
||||||
|
$"{(rr.Class == RiverClass.LakeFed ? $" ⭐ LAKE-FED (ratio {rr.CostRatio:F3})" : "")}" +
|
||||||
|
$"{(!float.IsNaN(rr.CostRatio) && rr.Class == RiverClass.RoutedGiant ? $" → SEA (lake ratio {rr.CostRatio:F3})" : "")}" +
|
||||||
|
$"{(rr.OwnBasinTargeted ? $" ⭐ own-basin water, {rr.Lowland.LenPx:F0} px" : "")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opt.Confluence) Confluence(outp, log);
|
||||||
|
else foreach (var rr in outp) rr.OwnPath = rr.Course;
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ClassLabel(RiverClass c) => c switch
|
||||||
|
{
|
||||||
|
RiverClass.OceanTrunk => "TRUNK",
|
||||||
|
RiverClass.RoutedGiant => "ROUTED",
|
||||||
|
RiverClass.LakeFed => "LAKE-FED",
|
||||||
|
RiverClass.WalledOff => "WALLED-OFF",
|
||||||
|
_ => "LAKE-ENDER",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ FIX 2 — THE CONFLUENCE POST-PASS (rivers/03b) ═══════════════════════════════════
|
||||||
|
//
|
||||||
|
// ⚠⚠ A DIVERGENCE, NOT A PORT. The reference lays every route independently and never dedups or
|
||||||
|
// joins them, which rivers/03 measured: on EVERY seed two routed rivers arrived at the identical
|
||||||
|
// ocean cell without ever having met. Two channels reaching the same mouth as parallel
|
||||||
|
// duplicates is not geography; two channels that meet and continue as one is.
|
||||||
|
//
|
||||||
|
// The rule, deliberately strict: courses are laid BIGGEST-FIRST by drainage, and a later course
|
||||||
|
// joins an earlier one only on TRUE CELL INTERSECTION — the later course's rasterised cell path
|
||||||
|
// actually reaching a cell an earlier one occupies. **Never proximity.** Two rivers running 3 px
|
||||||
|
// apart down the same valley stay two rivers; that is a question for the carve's channel width,
|
||||||
|
// not for routing to guess at.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Join intersecting courses into a dendritic tree. Biggest-first, so the largest drainage is
|
||||||
|
/// the trunk and smaller ones become its tributaries — the later river is truncated at the
|
||||||
|
/// FIRST (most-upstream) cell it shares with an already-laid course, and adopts that course's
|
||||||
|
/// downstream and terminus from there.
|
||||||
|
/// </summary>
|
||||||
|
internal static void Confluence(List<RoutedRiver> rivers, Action<string> log)
|
||||||
|
{
|
||||||
|
var order = new List<RoutedRiver>(rivers);
|
||||||
|
order.Sort((a, b) => b.Candidate.DrainagePx.CompareTo(a.Candidate.DrainagePx));
|
||||||
|
|
||||||
|
// cell -> (the river occupying it, and how far along that river's cell path it sits)
|
||||||
|
var owner = new Dictionary<(int x, int y), (RoutedRiver river, int idx)>();
|
||||||
|
int joins = 0;
|
||||||
|
|
||||||
|
foreach (var r in order)
|
||||||
|
{
|
||||||
|
r.CellPath = Rasterise(r.Course);
|
||||||
|
var stemOnly = new List<(float x, float y)>(r.Candidate.Course);
|
||||||
|
stemOnly.Reverse();
|
||||||
|
r.StemCells = Rasterise(stemOnly).Count;
|
||||||
|
if (r.CellPath.Count == 0) { r.OwnPath = r.Course; continue; }
|
||||||
|
|
||||||
|
// The first cell of THIS course that someone bigger already occupies.
|
||||||
|
int hit = -1;
|
||||||
|
(RoutedRiver river, int idx) into = default;
|
||||||
|
for (int i = 0; i < r.CellPath.Count; i++)
|
||||||
|
if (owner.TryGetValue(r.CellPath[i], out into)) { hit = i; break; }
|
||||||
|
|
||||||
|
if (hit < 0)
|
||||||
|
{
|
||||||
|
// Keeps its own route and its own mouth.
|
||||||
|
r.OwnPath = r.Course;
|
||||||
|
for (int i = 0; i < r.CellPath.Count; i++)
|
||||||
|
if (!owner.ContainsKey(r.CellPath[i])) owner[r.CellPath[i]] = (r, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ It joins. Truncate here and adopt the parent's downstream from the junction on.
|
||||||
|
var parent = into.river;
|
||||||
|
r.Joined = true;
|
||||||
|
r.ConfluenceParentRank = parent.Candidate.Rank;
|
||||||
|
r.JunctionCell = r.CellPath[hit];
|
||||||
|
joins++;
|
||||||
|
|
||||||
|
// What it DRAWS is its own reach only, up to the junction — the union of every river's
|
||||||
|
// own reach is the tree. Drawing the adopted downstream too would just overdraw the parent.
|
||||||
|
r.OwnPath = new List<(float x, float y)>();
|
||||||
|
for (int i = 0; i <= hit; i++) r.OwnPath.Add((r.CellPath[i].x, r.CellPath[i].y));
|
||||||
|
|
||||||
|
// The full course of record: its own reach, then the parent's from the junction to the sea.
|
||||||
|
var full = new List<(int x, int y)>();
|
||||||
|
for (int i = 0; i <= hit; i++) full.Add(r.CellPath[i]);
|
||||||
|
for (int i = into.idx + 1; i < parent.CellPath.Count; i++) full.Add(parent.CellPath[i]);
|
||||||
|
r.CellPath = full;
|
||||||
|
r.Course = new List<(float x, float y)>();
|
||||||
|
foreach (var cpt in full) r.Course.Add((cpt.x, cpt.y));
|
||||||
|
|
||||||
|
// Only its OWN reach becomes occupiable, so a third river can join this tributary.
|
||||||
|
for (int i = 0; i <= hit; i++)
|
||||||
|
if (!owner.ContainsKey(full[i])) owner[full[i]] = (r, i);
|
||||||
|
|
||||||
|
log($" ⭐ CONFLUENCE: #{r.Candidate.Rank} ({r.Candidate.DrainagePx:N0} px) joins #{parent.Candidate.Rank} " +
|
||||||
|
$"({parent.Candidate.DrainagePx:N0} px) at ({r.JunctionCell.x},{r.JunctionCell.y}) — " +
|
||||||
|
$"{hit} px of its own reach, then adopts #{parent.Candidate.Rank}'s downstream and terminus");
|
||||||
|
}
|
||||||
|
if (joins == 0) log(" (no confluences — every course keeps its own mouth)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE ROOT of a confluence chain — the river whose terminus this one actually ends at. A
|
||||||
|
/// tributary's mouth is its trunk's mouth, so this is what mouth counting and terminus class
|
||||||
|
/// must both be read through.
|
||||||
|
/// </summary>
|
||||||
|
public static RoutedRiver Root(RoutedRiver r, List<RoutedRiver> all)
|
||||||
|
{
|
||||||
|
var cur = r;
|
||||||
|
// The chain is finite and strictly increasing in drainage (biggest-first laying), so it
|
||||||
|
// cannot cycle; the guard is belt-and-braces against a future change to the ordering.
|
||||||
|
for (int guard = 0; guard < all.Count + 1 && cur.Joined; guard++)
|
||||||
|
{
|
||||||
|
RoutedRiver parent = null;
|
||||||
|
foreach (var o in all) if (o.Candidate.Rank == cur.ConfluenceParentRank) { parent = o; break; }
|
||||||
|
if (parent == null) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rasterise a polyline to a deduped 1-px cell path. ⚠ The confluence test is a TRUE CELL
|
||||||
|
/// intersection, so the courses must be compared as the cells they occupy, not as the sparse
|
||||||
|
/// vertices the analysis decimated them to (stems are decimated ×4, routes are Chaikin-smoothed).
|
||||||
|
/// </summary>
|
||||||
|
private static List<(int x, int y)> Rasterise(List<(float x, float y)> pts)
|
||||||
|
{
|
||||||
|
var outp = new List<(int x, int y)>();
|
||||||
|
if (pts == null || pts.Count == 0) return outp;
|
||||||
|
void Push(int x, int y)
|
||||||
|
{
|
||||||
|
if (outp.Count > 0 && outp[^1].x == x && outp[^1].y == y) return;
|
||||||
|
outp.Add((x, y));
|
||||||
|
}
|
||||||
|
for (int i = 0; i + 1 < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var a = pts[i]; var b = pts[i + 1];
|
||||||
|
float dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
int steps = Math.Max(1, (int)MathF.Ceiling(MathF.Max(MathF.Abs(dx), MathF.Abs(dy))));
|
||||||
|
for (int s = 0; s < steps; s++)
|
||||||
|
Push((int)MathF.Round(a.x + dx * s / steps), (int)MathF.Round(a.y + dy * s / steps));
|
||||||
|
}
|
||||||
|
Push((int)MathF.Round(pts[^1].x), (int)MathF.Round(pts[^1].y));
|
||||||
|
return outp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Assemble one river's full course: upland stem (head → terminal) + the smoothed lowland
|
||||||
|
/// reach (terminal → target).
|
||||||
|
///
|
||||||
|
/// ⚠ `Course` from the analysis is DOWNSTREAM-FIRST and decimated ×4, so it is reversed to run
|
||||||
|
/// head → terminal, exactly as the reference does. The route's first point IS the terminal, so
|
||||||
|
/// it is skipped when splicing — otherwise the join carries a duplicate vertex.
|
||||||
|
///
|
||||||
|
/// ⚠ The reference then DENSIFIES the spliced polyline to ~1-px samples. That is done inside
|
||||||
|
/// `CarveRiver`, for the bed stamp — it is carve-time and deliberately not done here: this task
|
||||||
|
/// produces courses, and a densified polyline draws and measures identically.
|
||||||
|
/// </summary>
|
||||||
|
public static List<(float x, float y)> Assemble(List<(float x, float y)> uplandStem, Route lowland)
|
||||||
|
{
|
||||||
|
var pts = new List<(float x, float y)>(uplandStem);
|
||||||
|
pts.Reverse(); // downstream-first → head → terminal
|
||||||
|
if (lowland != null && lowland.Smoothed != null && lowland.Smoothed.Count > 1)
|
||||||
|
pts.AddRange(lowland.Smoothed.GetRange(1, lowland.Smoothed.Count - 1));
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1076
Tools/Scripts/RiverRoutingTool.cs
Normal file
1076
Tools/Scripts/RiverRoutingTool.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -126,6 +126,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 4);
|
int task = EnvInt("ISLA_TASK", 4);
|
||||||
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
|
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
|
||||||
|
|
@ -180,7 +184,7 @@ namespace IslaApocalypse.Tools
|
||||||
var poolPass1 = new Dictionary<int, Pass1Result>();
|
var poolPass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
|
||||||
poolPass1[s] = p1;
|
poolPass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -288,13 +292,18 @@ namespace IslaApocalypse.Tools
|
||||||
public ulong ElapsedMs;
|
public ulong ElapsedMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/04 is the PRE-FAMILY committed-curve
|
||||||
|
/// gallery (`terrain-curve-v1`); the re-baseline flipped the bare defaults family-ON, and this
|
||||||
|
/// tool must keep producing the curve gallery it was judged as. → TerrainGenConfig.WithFamilyOff().
|
||||||
|
/// </remarks>
|
||||||
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
|
||||||
=> new TerrainGenConfig
|
=> new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
MapSize = mapSize, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
|
||||||
LowlandCeilingM = LowlandCeilingM,
|
LowlandCeilingM = LowlandCeilingM,
|
||||||
};
|
}.WithFamilyOff();
|
||||||
|
|
||||||
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
|
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
|
||||||
float sea, CurveAnchors anchors, bool skipRaw)
|
float sea, CurveAnchors anchors, bool skipRaw)
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,82 @@ namespace IslaApocalypse.Tools
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ ANCHOR RESOLUTION — A MISSING ANCHOR IS LOUD (rivers/01) ═══════════════════════════
|
||||||
|
//
|
||||||
|
// ═══ THE FAILURE MODE THIS CLOSES ═══
|
||||||
|
//
|
||||||
|
// Every anchor check used to be written as:
|
||||||
|
//
|
||||||
|
// if (File.Exists(dump) && mapSize == 8192) hard.Add(DumpRegression(...));
|
||||||
|
// else GD.Print(" ⚠ skipped — no dump at …");
|
||||||
|
//
|
||||||
|
// so a moved, renamed or deleted anchor did not make the oracle FAIL. It made the oracle
|
||||||
|
// NOT RUN — and a batch with a silently-skipped check prints an all-PASS table and reads
|
||||||
|
// exactly like a clean one. The `INDEX.md` is then evidence for a claim nothing checked.
|
||||||
|
//
|
||||||
|
// > ### ⚠ This is the INVERSE of the hazard the re-baseline guards against.
|
||||||
|
// > The re-baseline stops an oracle PASSING FOR THE WRONG REASON. This stops one
|
||||||
|
// > DISAPPEARING FOR NO REASON. Both end with a green table and an unproven claim, and the
|
||||||
|
// > rivers/01 batch-root migration is exactly the event that would have triggered the second
|
||||||
|
// > one — nine anchors moved under `<chat>/` in a single commit.
|
||||||
|
//
|
||||||
|
// The two cases the old code conflated are now separated, in ONE place (`LoadAnchor`) so every
|
||||||
|
// anchor site behaves identically:
|
||||||
|
// MISSING FILE the anchor moved, was renamed, or was never written. THROWS — the check
|
||||||
|
// cannot run, and dropping it quietly is the failure this exists to close.
|
||||||
|
// WRONG SIZE the anchor exists, but only at the size it was captured. A legitimate case
|
||||||
|
// (a probe run at another size); reported loudly and recorded INCONCLUSIVE,
|
||||||
|
// which is a FAIL in the table. ⚠ Deliberately NOT a throw: a guard that fires
|
||||||
|
// on ordinary small-map probe work is a guard people learn to route around.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Load a regression anchor's `.f32`, separating the two failures the old code conflated.
|
||||||
|
///
|
||||||
|
/// FILE ABSENT → THROWS. The anchor moved, was renamed, or was never written.
|
||||||
|
/// This is the migration hazard, and it is not survivable: the
|
||||||
|
/// check cannot run and must not be quietly dropped.
|
||||||
|
/// PRESENT, WRONG SIZE → returns null, LOUDLY. A legitimate case — an anchor exists only
|
||||||
|
/// at the size it was captured, and a batch run at another size
|
||||||
|
/// genuinely cannot check against it. The caller's
|
||||||
|
/// <see cref="DumpRegression"/> records it INCONCLUSIVE, which is
|
||||||
|
/// a FAIL in the table, never a pass.
|
||||||
|
///
|
||||||
|
/// ⚠ The distinction matters because only ONE of them means something is broken. Throwing on a
|
||||||
|
/// size mismatch would make every small-map probe run refuse, and a guard that fires on ordinary
|
||||||
|
/// work is a guard people route around.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="checkId">The oracle check this anchor feeds, e.g. "a10" — named in the message.</param>
|
||||||
|
/// <param name="envVar">The env override that can re-point it, e.g. "ISLA_T10_SOURCE".</param>
|
||||||
|
/// <param name="dumpPath">The resolved absolute path.</param>
|
||||||
|
/// <param name="mapSize">The field size to read.</param>
|
||||||
|
public static float[,] LoadAnchor(string checkId, string envVar, string dumpPath, int mapSize)
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(dumpPath))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[Oracle] MISSING ANCHOR for check '{checkId}' — nothing at:\n" +
|
||||||
|
$" {dumpPath}\n" +
|
||||||
|
"An oracle whose anchor is absent does not fail, it does not RUN — and a batch with a " +
|
||||||
|
"silently-skipped check prints an all-PASS table that reads exactly like a clean one. " +
|
||||||
|
"Refusing to render evidence for a claim nothing checked.\n" +
|
||||||
|
$"→ Re-point it with {envVar}, or regenerate the anchor. If the anchor is genuinely " +
|
||||||
|
"retired, DELETE THE CHECK — never leave one aimed at nothing. (rivers/01.)");
|
||||||
|
|
||||||
|
long expected = (long)mapSize * mapSize * 4;
|
||||||
|
long actual = new System.IO.FileInfo(dumpPath).Length;
|
||||||
|
if (actual != expected)
|
||||||
|
{
|
||||||
|
int anchorSize = (int)System.Math.Round(System.Math.Sqrt(actual / 4.0));
|
||||||
|
Godot.GD.PrintErr(
|
||||||
|
$" ⚠⚠ {checkId}: NOT CHECKED — the anchor exists but was captured at {anchorSize}, " +
|
||||||
|
$"and this run is at {mapSize} ({actual:N0} bytes, expected {expected:N0}). This is a size " +
|
||||||
|
$"mismatch, NOT a missing file: the batch is simply not proven against it at this size. " +
|
||||||
|
$"Run at {anchorSize} to check it. The oracle records INCONCLUSIVE, which is a FAIL — never a pass.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return HeightField.Load(dumpPath, mapSize);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
|
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
|
||||||
public static string ToMarkdownTable(IEnumerable<Check> checks)
|
public static string ToMarkdownTable(IEnumerable<Check> checks)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ namespace IslaApocalypse.Tools
|
||||||
/// ISLA_BAND_START / ISLA_BAND_FEATHER the fixed band (fractions of the map; constants for the batch)
|
/// ISLA_BAND_START / ISLA_BAND_FEATHER the fixed band (fractions of the map; constants for the batch)
|
||||||
/// ISLA_STRETCH_SINKER 1 = the sinker rides the stretched distance (default), 0 = real y
|
/// ISLA_STRETCH_SINKER 1 = the sinker rides the stretched distance (default), 0 = real y
|
||||||
/// ISLA_DIAG_ONLY=1 diagnostic only
|
/// ISLA_DIAG_ONLY=1 diagnostic only
|
||||||
/// ISLA_SKIP_8K=1 skip the 8192 band regression (a4b)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SouthernStretchTool : Node
|
public partial class SouthernStretchTool : Node
|
||||||
{
|
{
|
||||||
|
|
@ -64,7 +63,6 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
private const int DefaultMapSize = 4096;
|
private const int DefaultMapSize = 4096;
|
||||||
private const int DefaultCalibSize = 2048;
|
private const int DefaultCalibSize = 2048;
|
||||||
private const int GallerySize = 8192;
|
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
|
|
@ -91,6 +89,10 @@ namespace IslaApocalypse.Tools
|
||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
|
||||||
|
|
||||||
int task = EnvInt("ISLA_TASK", 8);
|
int task = EnvInt("ISLA_TASK", 8);
|
||||||
string descr = EnvStr("ISLA_BATCH", "southern_stretch_explore");
|
string descr = EnvStr("ISLA_BATCH", "southern_stretch_explore");
|
||||||
|
|
@ -103,11 +105,8 @@ namespace IslaApocalypse.Tools
|
||||||
float bandFeather = EnvFloat("ISLA_BAND_FEATHER", SouthernStretch.DefaultBandFeatherFrac);
|
float bandFeather = EnvFloat("ISLA_BAND_FEATHER", SouthernStretch.DefaultBandFeatherFrac);
|
||||||
bool stretchSinker = EnvStr("ISLA_STRETCH_SINKER", SouthernStretch.DefaultStretchSinker ? "1" : "0") == "1";
|
bool stretchSinker = EnvStr("ISLA_STRETCH_SINKER", SouthernStretch.DefaultStretchSinker ? "1" : "0") == "1";
|
||||||
bool diagOnly = EnvStr("ISLA_DIAG_ONLY", "0") == "1";
|
bool diagOnly = EnvStr("ISLA_DIAG_ONLY", "0") == "1";
|
||||||
bool skip8k = EnvStr("ISLA_SKIP_8K", "0") == "1";
|
|
||||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||||
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
|
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
|
||||||
string t03Source = EnvStr("ISLA_T03_SOURCE", "03_mountain_restore");
|
|
||||||
string t04Source = EnvStr("ISLA_T04_SOURCE", "04_seed_gallery");
|
|
||||||
|
|
||||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||||
|
|
@ -136,16 +135,26 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
TerrainGenConfig Cfg(int size, int seed, string label, float stretch, bool sinkerStretched, bool sinkerOn = true, bool edgeOn = true)
|
TerrainGenConfig Cfg(int size, int seed, string label, float stretch, bool sinkerStretched, bool sinkerOn = true, bool edgeOn = true)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This tool is chat-2 shaping DEVELOPMENT:
|
||||||
|
// it was authored and judged before the shape family existed, and its regression checks
|
||||||
|
// hold pass 1 against the FAMILY-OFF `02_pass1_port` dump. The re-baseline flipped the
|
||||||
|
// bare defaults family-ON, so without this pin every config here would silently acquire
|
||||||
|
// stretch + fragmentation and every anchor check would fail for a configuration reason.
|
||||||
|
// → TerrainGenConfig.WithFamilyOff().
|
||||||
var c = new TerrainGenConfig
|
var c = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = size, Seed = seed, VariantLabel = label,
|
MapSize = size, Seed = seed, VariantLabel = label,
|
||||||
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
|
||||||
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
|
||||||
CoastShelf = false, Offshore = new OffshoreSettings(),
|
RegionLabeling = true,
|
||||||
RegionLabeling = true, SpeckRevert = false,
|
|
||||||
SouthStretch = stretch, SouthBandStartFrac = bandStart, SouthBandFeatherFrac = bandFeather, StretchSinker = sinkerStretched,
|
|
||||||
SouthernSinker = sinkerOn, EdgeNoise = edgeOn,
|
SouthernSinker = sinkerOn, EdgeNoise = edgeOn,
|
||||||
};
|
}.WithFamilyOff();
|
||||||
|
// …then the swept axis, AFTER the pin. Coastal fragmentation stays OFF here: chat2/08
|
||||||
|
// predates it, and this tool's ladder measures the stretch ALONE.
|
||||||
|
c.SouthStretch = stretch;
|
||||||
|
c.SouthBandStartFrac = bandStart;
|
||||||
|
c.SouthBandFeatherFrac = bandFeather;
|
||||||
|
c.StretchSinker = sinkerStretched;
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,34 +264,31 @@ namespace IslaApocalypse.Tools
|
||||||
var offCfg = Cfg(calibSize, plate, "off", 0f, stretchSinker);
|
var offCfg = Cfg(calibSize, plate, "off", 0f, stretchSinker);
|
||||||
Pass1Result p1 = Topography.Generate(offCfg);
|
Pass1Result p1 = Topography.Generate(offCfg);
|
||||||
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
var curveOff = offCfg.Clone(); curveOff.Curve = false;
|
||||||
|
// ⭐ a1 KEPT at rivers/01 — the family-off pass-1 guard (config pinned family-off). ⚠ loud.
|
||||||
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plate}_full", "height.f32");
|
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{plate}_full", "height.f32");
|
||||||
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF == Phase-1 .f32 dump", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
|
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF == Phase-1 .f32 dump",
|
||||||
string t03Dump = Path.Combine(ToolingPaths.BatchesRoot, t03Source, $"{plate}_continuous_restored", "height.f32");
|
Shaping.Shape(p1, curveOff).Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, calibSize), calibSize, p1Dump));
|
||||||
float[,] t03 = HeightField.Load(t03Dump, calibSize);
|
|
||||||
hard.Add(ShapingOracle.DumpRegression("a3", "continuous_restored, stretch OFF == task-03 .f32 dump", Shaping.Shape(p1, offCfg).Height, t03, calibSize, t03Dump));
|
|
||||||
|
|
||||||
// ⭐ a3b — stretch ON at the ladder's TOP: north of the band bit-identical to the tag's own dump.
|
// ⭐ a3c KEPT and RE-POINTED — the north-locked invariant is the load-bearing claim of the
|
||||||
|
// southern stretch (→ D-065) and it does NOT need an external anchor: the unstretched field
|
||||||
|
// is generated right here. Re-pointing it off `03_mountain_restore` is what let that dump
|
||||||
|
// retire without losing the guarantee.
|
||||||
var topCfg = Cfg(calibSize, plate, "top", maxStretch, stretchSinker);
|
var topCfg = Cfg(calibSize, plate, "top", maxStretch, stretchSinker);
|
||||||
Pass2Result pTop = Shaping.Shape(Topography.Generate(topCfg), topCfg);
|
Pass2Result pTop = Shaping.Shape(Topography.Generate(topCfg), topCfg);
|
||||||
if (t03 != null)
|
Pass2Result pUnstretched = Shaping.Shape(p1, offCfg);
|
||||||
hard.Add(ShapingOracle.NorthLocked("a3b", $"stretch {maxStretch:G3} ON: north of the band bit-identical to task-03 dump (terrain-curve-v1); changes only in/below the band", pTop.Height, t03, calibSize, bandRowC));
|
hard.Add(ShapingOracle.NorthLocked("a3c",
|
||||||
|
$"stretch {maxStretch:G3} ON: north of the band bit-identical to the SAME-RUN unstretched field; changes only in/below the band",
|
||||||
|
pTop.Height, pUnstretched.Height, calibSize, bandRowC));
|
||||||
foreach (var c in hard) GD.Print(" " + c);
|
foreach (var c in hard) GD.Print(" " + c);
|
||||||
|
|
||||||
if (!skip8k)
|
// ⚑ RETIRED at rivers/01 — a3 (`03_mountain_restore`) and a4b (`04_seed_gallery`).
|
||||||
{
|
// a3 was a curve-development intermediate, subsumed by the terrain-shape-v1 acceptance.
|
||||||
string t04Dump = Path.Combine(ToolingPaths.BatchesRoot, t04Source, $"{plate}", "height.f32");
|
// a4b asserted the north-lock against the PRE-FAMILY 8192 gallery — but the north-lock is
|
||||||
if (File.Exists(t04Dump))
|
// now proven scale-free against a same-run field (a3c above), so the external anchor bought
|
||||||
{
|
// nothing except an 8192 generation on every run.
|
||||||
GD.Print($" a4b: generating {plate} at {GallerySize}, stretch {maxStretch:G3} …");
|
// The dump is NOT deleted (file-safety; regenerable, and the record of what was judged);
|
||||||
var g = Cfg(GallerySize, plate, "top", maxStretch, stretchSinker);
|
// its `INDEX.md` is marked superseded. → XX_Human/output/rivers/01_*.report.md §A4.
|
||||||
Pass2Result pG = Shaping.Shape(Topography.Generate(g), g);
|
|
||||||
var a4b = ShapingOracle.NorthLocked("a4b", $"stretch {maxStretch:G3} ON at {GallerySize}: north of the band bit-identical to terrain-curve-v1's 04 gallery dump",
|
|
||||||
pG.Height, HeightField.Load(t04Dump, GallerySize), GallerySize, (int)(bandStart * GallerySize));
|
|
||||||
hard.Add(a4b); GD.Print(" " + a4b);
|
|
||||||
}
|
|
||||||
else GD.Print($" a4b: ⚠ skipped — no 04 gallery dump at {t04Dump}");
|
|
||||||
}
|
|
||||||
else GD.Print(" a4b: skipped (ISLA_SKIP_8K)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ 4. THE LADDER — 5 levels × 2 seeds ═══
|
// ═══ 4. THE LADDER — 5 levels × 2 seeds ═══
|
||||||
|
|
@ -420,7 +426,7 @@ namespace IslaApocalypse.Tools
|
||||||
var pass1 = new Dictionary<int, Pass1Result>();
|
var pass1 = new Dictionary<int, Pass1Result>();
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s }); // bare default: offshore / revert / stretch OFF
|
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s)); // family-off PINNED (rivers/01), not defaulted
|
||||||
pass1[s] = p1;
|
pass1[s] = p1;
|
||||||
rawPool.Accumulate(p1.Height, calibSize);
|
rawPool.Accumulate(p1.Height, calibSize);
|
||||||
}
|
}
|
||||||
|
|
@ -433,11 +439,14 @@ namespace IslaApocalypse.Tools
|
||||||
var outAbove = new LandHistogram(sea);
|
var outAbove = new LandHistogram(sea);
|
||||||
foreach (int s in CalibrationSeeds)
|
foreach (int s in CalibrationSeeds)
|
||||||
{
|
{
|
||||||
|
// ⭐ rivers/01: family-off PINNED, like the pool it shapes. (The family acts in pass 1 and
|
||||||
|
// `Shaping.Shape` never reads it, so this is inert today — pinned anyway so "the whole
|
||||||
|
// calibration is family-off" is a total claim rather than a field-by-field one.)
|
||||||
var scfg = new TerrainGenConfig
|
var scfg = new TerrainGenConfig
|
||||||
{
|
{
|
||||||
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
|
||||||
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
|
||||||
};
|
}.WithFamilyOff();
|
||||||
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
Pass2Result st = Shaping.Shape(pass1[s], scfg);
|
||||||
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
outAbove.AccumulateWhere(st.Height, pass1[s].Height, calibSize, ceilingRaw);
|
||||||
|
|
|
||||||
|
|
@ -242,14 +242,20 @@ namespace IslaApocalypse.Tools
|
||||||
|
|
||||||
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
|
// ---- PASS 1b — the coast shelf + offshore islets (chat2/05) ----------
|
||||||
//
|
//
|
||||||
// ⚠⚠ BOTH DEFAULT OFF, DELIBERATELY — and that is a decision to revisit, not an oversight.
|
// ⚠⚠ BOTH STAY OFF AFTER THE rivers/01 RE-BASELINE — and each for its own reason.
|
||||||
//
|
//
|
||||||
// Every oracle in this phase holds pass 1 against Phase 1's `.f32` dumps (curve-off ==
|
// SHELF OFF because the LOCKED SHAPE has no shelf. `terrain-shape-v1` (a59e52f) was
|
||||||
// `02_pass1_port`), and the curve tools hold it against task 01/03's. The shelf changes every
|
// judged with `CoastShelf = false`, so turning it on here would produce terrain
|
||||||
// below-sea cell and the islets ADD LAND, so the moment either defaults ON, every one of
|
// the developer never approved and would break the bit-identity this default set
|
||||||
// those regression anchors goes stale at once. The batch tools that want them turn them on
|
// exists to guarantee. The shelf is below-sea only and invisible until water
|
||||||
// explicitly. FLIPPING THESE DEFAULTS IS THE ACT THAT RETIRES THE PHASE-1 REGRESSION DUMPS —
|
// renders — EVALUATING IT IS ITS OWN LATER TASK (→ D-041), once water renders.
|
||||||
// do it deliberately, in a task that re-baselines the oracles, not as a side effect here.
|
// (The rivers kickoff's "set shelf ON" was a mis-statement; corrected by the
|
||||||
|
// developer via master before rivers/01 ran.)
|
||||||
|
// ISLETS OFF because it is the DROPPED mechanism (→ D-063): islands are ORGANIC-ONLY,
|
||||||
|
// produced by the southern stretch + coastal fragmentation and then IDENTIFIED by
|
||||||
|
// the region layer — never placed. The pass is kept, not deleted, because
|
||||||
|
// `OffshoreSettings.Faithful` is a live port-fidelity control and `Pass1Result`
|
||||||
|
// carries the offshore seam. ⚠ NEVER RE-ENABLE IT IN THE BARE DEFAULT.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
|
/// The submarine coast shelf (<c>IslandFalloff.CoastShelf</c>). Below-sea only,
|
||||||
|
|
@ -289,16 +295,27 @@ namespace IslaApocalypse.Tools
|
||||||
/// <see cref="MinLandComponentFrac"/> of the map to seabed. Origin-blind; lower-only and
|
/// <see cref="MinLandComponentFrac"/> of the map to seabed. Origin-blind; lower-only and
|
||||||
/// component-only, asserted; mainland never a candidate.
|
/// component-only, asserted; mainland never a candidate.
|
||||||
///
|
///
|
||||||
/// ⚠ DEFAULT OFF IN THE BARE CONFIG, for exactly the reason the shelf and the islets are: the
|
/// ⭐ ON BY DEFAULT since rivers/01 — it is part of the LOCKED SHAPE (`terrain-shape-v1`).
|
||||||
/// raw field has small natural nubs, so with this ON the calibration pool's land histogram, the
|
///
|
||||||
/// curve knots and every Phase-1 / task-03 / task-04 regression dump would move at once.
|
/// ⚠ It was default-OFF through chat 2 because turning it on moves the calibration pool's land
|
||||||
/// The region batch turns it on explicitly (its preset is ON); flipping the bare default is
|
/// histogram, the curve knots and every pre-family regression dump at once. rivers/01 is the
|
||||||
/// the act that re-baselines the oracles — own task, not a side effect.
|
/// task that owned that flip: the calibration pool is now pinned FAMILY-OFF
|
||||||
|
/// (<see cref="WithFamilyOff"/>), so the knots are unmoved and the flip is terrain-only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SpeckRevert = false;
|
public bool SpeckRevert = true;
|
||||||
|
|
||||||
/// <summary>The revert threshold, as a fraction of the map's AREA (scale-free). → <see cref="RegionPass.ThresholdMidFrac"/>.</summary>
|
/// <summary>
|
||||||
public float MinLandComponentFrac = RegionPass.ThresholdMidFrac;
|
/// The revert threshold, as a fraction of the map's AREA (scale-free).
|
||||||
|
///
|
||||||
|
/// ⚠⚠ THE LOCKED-SHAPE VALUE, PINNED AS A LITERAL — 2.5e-7 ≈ 4 cells at 4096, ≈ 17 at 8192.
|
||||||
|
/// It is deliberately NOT <see cref="RegionPass.ThresholdMidFrac"/> (3e-5, ~2,013 cells at
|
||||||
|
/// 8192), which was the pre-re-baseline default and is 120× larger: at that threshold the
|
||||||
|
/// revert eats real islands rather than specks. The three named `RegionPass.Threshold*Frac`
|
||||||
|
/// values are the chat2/07 exploration ladder; this is the value chat2/09–10 froze and the
|
||||||
|
/// developer judged. Changing it changes `terrain-shape-v1`. (rivers/01, from
|
||||||
|
/// `FragGalleryTool.FrozenSpeckFrac`.)
|
||||||
|
/// </summary>
|
||||||
|
public float MinLandComponentFrac = 2.5e-7f;
|
||||||
|
|
||||||
// ---- PASS 1 — THE SOUTHERN STRETCH (chat2/08, exploration) ----------------
|
// ---- PASS 1 — THE SOUTHERN STRETCH (chat2/08, exploration) ----------------
|
||||||
//
|
//
|
||||||
|
|
@ -311,14 +328,30 @@ namespace IslaApocalypse.Tools
|
||||||
// organically. Cells north of the band take the UNTOUCHED code path, so the classify field
|
// organically. Cells north of the band take the UNTOUCHED code path, so the classify field
|
||||||
// there is bit-identical by construction (asserted). Nothing is stamped.
|
// there is bit-identical by construction (asserted). Nothing is stamped.
|
||||||
|
|
||||||
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical to the unstretched field everywhere). Stretch factor inside the band: 1 ⇒ the southward distance is halved, 3 ⇒ quartered.</summary>
|
/// <summary>
|
||||||
public float SouthStretch = 0f;
|
/// ⭐ THE SWEPT AXIS. 0 = off (bit-identical to the unstretched field everywhere). Stretch
|
||||||
|
/// factor inside the band: 1 ⇒ the southward distance is halved, 3 ⇒ quartered.
|
||||||
|
///
|
||||||
|
/// ⭐ 2 IS THE LOCKED-SHAPE VALUE since rivers/01 (→ D-065). With coastal fragmentation it is
|
||||||
|
/// one of the two mechanisms that MAKE the islands (→ D-063).
|
||||||
|
/// </summary>
|
||||||
|
public float SouthStretch = 2f;
|
||||||
|
|
||||||
/// <summary>The band's FIXED latitude line, fraction of the map (y runs south). Sea identity is hard above it. A constant for a whole batch.</summary>
|
/// <summary>
|
||||||
public float SouthBandStartFrac = SouthernStretch.DefaultBandStartFrac;
|
/// The band's FIXED latitude line, fraction of the map (y runs south). Sea identity is hard
|
||||||
|
/// above it. A constant for a whole batch.
|
||||||
|
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultBandStartFrac`
|
||||||
|
/// today). The literal is the pin: retuning that constant must not silently move
|
||||||
|
/// `terrain-shape-v1`. (rivers/01.)
|
||||||
|
/// </summary>
|
||||||
|
public float SouthBandStartFrac = 0.70f;
|
||||||
|
|
||||||
/// <summary>The feather width across which the stretch ramps 0 → 1 (smoothstep), fraction of the map. A constant for a whole batch.</summary>
|
/// <summary>
|
||||||
public float SouthBandFeatherFrac = SouthernStretch.DefaultBandFeatherFrac;
|
/// The feather width across which the stretch ramps 0 → 1 (smoothstep), fraction of the map.
|
||||||
|
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultBandFeatherFrac`
|
||||||
|
/// today) — same reason as <see cref="SouthBandStartFrac"/>. (rivers/01.)
|
||||||
|
/// </summary>
|
||||||
|
public float SouthBandFeatherFrac = 0.05f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Does the SOUTHERN SINKER ride the stretched distance (true — it is part of the southern
|
/// Does the SOUTHERN SINKER ride the stretched distance (true — it is part of the southern
|
||||||
|
|
@ -326,7 +359,8 @@ namespace IslaApocalypse.Tools
|
||||||
/// it keeps pulling the extended mass down where it always did)? The chat2/08 diagnostic
|
/// it keeps pulling the extended mass down where it always did)? The chat2/08 diagnostic
|
||||||
/// measured both; → <see cref="SouthernStretch"/>.
|
/// measured both; → <see cref="SouthernStretch"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool StretchSinker = SouthernStretch.DefaultStretchSinker;
|
/// <remarks>⚠ Pinned as a LITERAL at the locked-shape value (equals `SouthernStretch.DefaultStretchSinker` today). rivers/01.</remarks>
|
||||||
|
public bool StretchSinker = true;
|
||||||
|
|
||||||
// ---- PASS 1 — COASTAL FRAGMENTATION (chat2/09, exploration) ------------------
|
// ---- PASS 1 — COASTAL FRAGMENTATION (chat2/09, exploration) ------------------
|
||||||
//
|
//
|
||||||
|
|
@ -336,34 +370,59 @@ namespace IslaApocalypse.Tools
|
||||||
// into islands while the interior — window weight exactly zero — is bit-identical by
|
// into islands while the interior — window weight exactly zero — is bit-identical by
|
||||||
// construction. Nothing is detected, nothing is stamped. → CoastalFragment.
|
// construction. Nothing is detected, nothing is stamped. → CoastalFragment.
|
||||||
|
|
||||||
/// <summary>⭐ THE SWEPT AXIS. 0 = off (bit-identical everywhere). Peak |Δfalloff| (pre-power) at the window's centre.</summary>
|
/// <summary>
|
||||||
public float FragmentAmp = 0f;
|
/// ⭐ THE SWEPT AXIS. 0 = off (bit-identical everywhere). Peak |Δfalloff| (pre-power) at the
|
||||||
|
/// window's centre.
|
||||||
|
///
|
||||||
|
/// ⭐ 0.5 IS THE LOCKED-SHAPE VALUE since rivers/01 — chat2/09's `frag_4`, frozen by chat2/10
|
||||||
|
/// across an 8-seed gallery and tagged `terrain-shape-v1`. With the southern stretch it is one
|
||||||
|
/// of the two mechanisms that MAKE the islands (→ D-063).
|
||||||
|
/// </summary>
|
||||||
|
public float FragmentAmp = 0.5f;
|
||||||
|
|
||||||
/// <summary>The fragmentation noise's frequency, periods per map width — the neck/lobe scale. The secondary dial (fixed this round). → <see cref="CoastalFragment.DefaultFreqPerMapWidth"/>.</summary>
|
/// <summary>
|
||||||
public float FragmentFreqPerMapWidth = CoastalFragment.DefaultFreqPerMapWidth;
|
/// The fragmentation noise's frequency, periods per map width — the neck/lobe scale.
|
||||||
|
/// ⚠ Pinned as a LITERAL at the locked-shape value (equals `CoastalFragment.DefaultFreqPerMapWidth`
|
||||||
|
/// today); the literal is the pin. (rivers/01.)
|
||||||
|
/// </summary>
|
||||||
|
public float FragmentFreqPerMapWidth = 12f;
|
||||||
|
|
||||||
/// <summary>The coastal window's centre and half-width in PRE-power falloff units. Weight 1 at the centre, smooth to 0 at ± half-width; exactly 0 beyond.</summary>
|
/// <summary>
|
||||||
public float FragmentBandCentre = CoastalFragment.DefaultBandCentre;
|
/// The coastal window's centre and half-width in PRE-power falloff units. Weight 1 at the
|
||||||
public float FragmentBandHalfWidth = CoastalFragment.DefaultBandHalfWidth;
|
/// centre, smooth to 0 at ± half-width; exactly 0 beyond.
|
||||||
|
/// ⚠ Pinned as LITERALS at the locked-shape values (equal `CoastalFragment.DefaultBandCentre` /
|
||||||
|
/// `DefaultBandHalfWidth` today). (rivers/01.)
|
||||||
|
/// </summary>
|
||||||
|
public float FragmentBandCentre = 0.66f;
|
||||||
|
public float FragmentBandHalfWidth = 0.18f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// false (default) ⇒ zero-mean noise: the margin is redrawn — bites AND builds (which can also
|
/// false (default) ⇒ zero-mean noise: the margin is redrawn — bites AND builds (which can also
|
||||||
/// bridge an island back onto the mainland). true ⇒ bites only ((noise+1)/2 ≥ 0): land can only
|
/// bridge an island back onto the mainland). true ⇒ bites only ((noise+1)/2 ≥ 0): land can only
|
||||||
/// recede, necks are cut, nothing is bridged, the coast net-recedes. → <see cref="CoastalFragment"/>.
|
/// recede, necks are cut, nothing is bridged, the coast net-recedes. → <see cref="CoastalFragment"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FragmentBitesOnly = CoastalFragment.DefaultBitesOnly;
|
/// <remarks>⚠ Pinned as a LITERAL at the locked-shape value (equals `CoastalFragment.DefaultBitesOnly` today). rivers/01.</remarks>
|
||||||
|
public bool FragmentBitesOnly = false;
|
||||||
|
|
||||||
// ---- PASS 2b — HYDRAULIC EROSION (chat2/11) — RENDER MAP ONLY ------------------
|
// ---- PASS 2b — HYDRAULIC EROSION (chat2/11) — RENDER MAP ONLY ------------------
|
||||||
//
|
//
|
||||||
// The reference's droplet erosion, ported verbatim (Core.HydraulicErosion), run on the render
|
// The reference's droplet erosion, ported verbatim (Core.HydraulicErosion), run on the render
|
||||||
// field AFTER shaping (after detail, before the crater carve — which does not exist yet). The
|
// field AFTER shaping (after detail, before the crater carve — which does not exist yet). The
|
||||||
// classify field never sees it (D-046); the caller's flood guard proves no waterline moved.
|
// classify field never sees it (D-046); the caller's flood guard proves no waterline moved.
|
||||||
// ⚠ DEFAULT OFF in the bare config for the usual reason (regression anchors); the batch turns
|
// ⭐ DEFAULT ON since rivers/01 — the locked baseline the rivers epic routes on is the ERODED
|
||||||
// it on. The governors + physics are the reference ConfigManager's declared defaults, clamped
|
// render field. The governors + physics are the reference ConfigManager's declared defaults,
|
||||||
// as it clamped them (→ ErosionPass).
|
// clamped as it clamped them (→ ErosionPass).
|
||||||
|
//
|
||||||
|
// ⚠ This flag is INERT unless a caller explicitly runs `ErosionPass.Apply` — nothing in
|
||||||
|
// `Topography.Generate` or `Shaping.Shape` reads it. So flipping it moves no field on its own;
|
||||||
|
// it makes "erode by default" the answer for the callers that DO ask.
|
||||||
|
|
||||||
/// <summary>⭐ Erosion on/off. Render only. Default OFF (see above).</summary>
|
/// <summary>
|
||||||
public bool Erosion = false;
|
/// ⭐ Erosion on/off. RENDER MAP ONLY — the classify field never sees it (→ D-046), proven by
|
||||||
|
/// `ErosionPass`'s flood guard. Default ON since rivers/01 (see above); the erosion A/B sets
|
||||||
|
/// it to false explicitly for its OFF half.
|
||||||
|
/// </summary>
|
||||||
|
public bool Erosion = true;
|
||||||
|
|
||||||
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
|
/// <summary>Governor 1 — droplet count. Reference 250000, clamp [0, 50,000,000].</summary>
|
||||||
public int ErosionDropletCount = 250000;
|
public int ErosionDropletCount = 250000;
|
||||||
|
|
@ -401,6 +460,75 @@ namespace IslaApocalypse.Tools
|
||||||
/// <summary>The scale object every distance and frequency in the generator derives from.</summary>
|
/// <summary>The scale object every distance and frequency in the generator derives from.</summary>
|
||||||
public GenerationScale Scale => new GenerationScale(MapSize);
|
public GenerationScale Scale => new GenerationScale(MapSize);
|
||||||
|
|
||||||
|
// ═══ ⭐⭐ THE FAMILY-OFF PIN (rivers/01) ═══════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// ═══ WHY THIS EXISTS — the preserve mechanism for `terrain-shape-v1` ═══
|
||||||
|
//
|
||||||
|
// The locked shape's curve knots are PERCENTILES OF THE FAMILY-OFF LAND DISTRIBUTION, measured
|
||||||
|
// by chat2/01 over a 6-seed pool at 2048 and applied to FAMILY-ON generation. That was not a
|
||||||
|
// choice at the time — it was simply what the bare defaults produced, because the shape family
|
||||||
|
// defaulted off.
|
||||||
|
//
|
||||||
|
// rivers/01 flipped those defaults ON. Left alone, every calibration pool would have moved
|
||||||
|
// with them (fragmentation removes coastal land, the stretch adds southern land — both change
|
||||||
|
// the land CDF), the six knots would have moved, and with them the render field of EVERY
|
||||||
|
// batch, including `10_frag4_seed_gallery` (= `terrain-shape-v1`) and `11_erosion`. The
|
||||||
|
// developer's ruling was to PRESERVE the locked terrain bit-identically, so the pool is pinned
|
||||||
|
// here instead of the knots being baked: CALIBRATION STAYS LIVE, its INPUT DISTRIBUTION is
|
||||||
|
// what is held still.
|
||||||
|
//
|
||||||
|
// ⚠ At the moment it was introduced this was a NO-OP BY CONSTRUCTION: it sets exactly the
|
||||||
|
// values the bare defaults carried the instant before the flip. That is what made the flip
|
||||||
|
// provably terrain-only — and what the rivers/01 acceptance confirmed byte-for-byte at 8192².
|
||||||
|
//
|
||||||
|
// > ### ⚑ THE JUDGED-AND-PARKED PROPERTY (recorded for the vault, rivers/01)
|
||||||
|
// > The curve knots are percentiles of the FAMILY-OFF land distribution, applied to FAMILY-ON
|
||||||
|
// > terrain. That is a real asymmetry and it is DELIBERATE, not an oversight: re-pooling on
|
||||||
|
// > family-on land would move the locked shape the developer judged. Same disposition as the
|
||||||
|
// > mid-slope feather — documented, revisit only at the final palette / in the mesher if it
|
||||||
|
// > ever visibly bothers. → `Vision - Threads - Open Questions.md`.
|
||||||
|
//
|
||||||
|
// ═══ WHAT IT IS FOR, AND WHAT IT IS NOT FOR ═══
|
||||||
|
//
|
||||||
|
// USE IT for a config whose job is to REPRODUCE A PRE-FAMILY FIELD: a curve-calibration
|
||||||
|
// pool, or the "off" half of a regression check against a family-off `.f32` dump.
|
||||||
|
// DO NOT use it for generation — the locked shape IS the family, and the bare defaults now
|
||||||
|
// carry it.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ Pin the SHAPE FAMILY and erosion OFF on this config, independent of this class's
|
||||||
|
/// evolving defaults, and return it for chaining. → the block above for why.
|
||||||
|
///
|
||||||
|
/// Sets: <see cref="CoastShelf"/> false · <see cref="Offshore"/> Off ·
|
||||||
|
/// <see cref="SpeckRevert"/> false · <see cref="SouthStretch"/> 0 ·
|
||||||
|
/// <see cref="FragmentAmp"/> 0 · <see cref="Erosion"/> false.
|
||||||
|
///
|
||||||
|
/// ⚠ It deliberately does NOT touch <see cref="MinLandComponentFrac"/> or the band/window
|
||||||
|
/// shape dials: with the revert off and the amplitudes at zero those are unread, so pinning
|
||||||
|
/// them would assert an independence that does not exist. It also does not touch the CURVE
|
||||||
|
/// (knots, anchors, calibration, climb knobs) — the family and the curve are separate axes,
|
||||||
|
/// and a calibration pool is pass-1 only.
|
||||||
|
/// </summary>
|
||||||
|
public TerrainGenConfig WithFamilyOff()
|
||||||
|
{
|
||||||
|
CoastShelf = false;
|
||||||
|
Offshore = new OffshoreSettings(); // Mode = Off
|
||||||
|
SpeckRevert = false;
|
||||||
|
SouthStretch = 0f;
|
||||||
|
FragmentAmp = 0f;
|
||||||
|
Erosion = false;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ A bare pass-1 config with the shape family pinned OFF — THE CALIBRATION POOL'S CONFIG.
|
||||||
|
/// Every curve-calibration pool in `Tools/` builds its fields through this, so there is one
|
||||||
|
/// place where "what distribution were the knots measured on?" is answered.
|
||||||
|
/// → <see cref="WithFamilyOff"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static TerrainGenConfig CalibrationPool(int mapSize, int seed) =>
|
||||||
|
new TerrainGenConfig { MapSize = mapSize, Seed = seed }.WithFamilyOff();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ⚠ DEEP on <see cref="Anchors"/>. <c>MemberwiseClone</c> is shallow, so two configs cloned
|
/// ⚠ DEEP on <see cref="Anchors"/>. <c>MemberwiseClone</c> is shallow, so two configs cloned
|
||||||
/// from one parent would share a single mutable anchor object and an A/B that edited one
|
/// from one parent would share a single mutable anchor object and an A/B that edited one
|
||||||
|
|
@ -421,6 +549,10 @@ namespace IslaApocalypse.Tools
|
||||||
$"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " +
|
$"[base={BaseNoise} falloff={IslandFalloff} edge={EdgeNoise} sinker={SouthernSinker} " +
|
||||||
$"trench={Trench} spine={MountainSpine}] " +
|
$"trench={Trench} spine={MountainSpine}] " +
|
||||||
$"[curve={Curve} detail={ShelfDetail} relief={ShelfReliefAmpM:F1}m edge={ShelfEdgeVariationM:F1}m " +
|
$"[curve={Curve} detail={ShelfDetail} relief={ShelfReliefAmpM:F1}m edge={ShelfEdgeVariationM:F1}m " +
|
||||||
$"knots={(Knots == null ? "-" : Knots.Name)}]";
|
$"knots={(Knots == null ? "-" : Knots.Name)}] " +
|
||||||
|
// rivers/01: the shape family is now a DEFAULT, so a run header must state it — otherwise
|
||||||
|
// "the defaults" stops being a readable claim the moment anyone asks which defaults.
|
||||||
|
$"[stretch={SouthStretch:G3} frag={FragmentAmp:G3} speck={(SpeckRevert ? $"{MinLandComponentFrac:G3}" : "off")} " +
|
||||||
|
$"shelf={CoastShelf} islets={Offshore?.Mode} erosion={Erosion}]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ namespace IslaApocalypse.Tools
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
ToolingPaths.Configure(OS.GetUserDataDir());
|
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||||
|
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
|
||||||
|
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
|
||||||
|
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
|
||||||
|
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat1"));
|
||||||
|
|
||||||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||||
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
|
||||||
|
|
@ -81,7 +85,11 @@ namespace IslaApocalypse.Tools
|
||||||
GD.Print("\n--- ABLATION LADDER (seed " + seeds[0] + ") ---");
|
GD.Print("\n--- ABLATION LADDER (seed " + seeds[0] + ") ---");
|
||||||
foreach (var (label, mutate) in Ladder())
|
foreach (var (label, mutate) in Ladder())
|
||||||
{
|
{
|
||||||
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label };
|
// ⭐⭐ rivers/01 — FAMILY-OFF PINNED. This tool AUTHORED `02_pass1_port`, the Phase-1
|
||||||
|
// regression anchor that survived the re-baseline. If it picked up the family-on
|
||||||
|
// defaults it could no longer regenerate its own dump, and the last link between
|
||||||
|
// today's generator and the Phase-1 port would break silently.
|
||||||
|
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seeds[0], VariantLabel = label }.WithFamilyOff();
|
||||||
mutate(cfg);
|
mutate(cfg);
|
||||||
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +100,7 @@ namespace IslaApocalypse.Tools
|
||||||
GD.Print("\n--- SEED BATCH (full pass-1) ---");
|
GD.Print("\n--- SEED BATCH (full pass-1) ---");
|
||||||
foreach (int seed in seeds)
|
foreach (int seed in seeds)
|
||||||
{
|
{
|
||||||
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" };
|
var cfg = new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "full" }.WithFamilyOff(); // ⭐ see the ladder above
|
||||||
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
rows.Add(RunOne(cfg, batchRoot, skipRaw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
116
Tools/Scripts/TerrainShapeV1.cs
Normal file
116
Tools/Scripts/TerrainShapeV1.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using System;
|
||||||
|
using IslaApocalypse.Core;
|
||||||
|
|
||||||
|
namespace IslaApocalypse.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐⭐ THE LOCKED SHAPE — `terrain-shape-v1` (commit <c>a59e52f</c>), AS AN ASSERTION.
|
||||||
|
///
|
||||||
|
/// ═══ ⚠⚠ THIS TYPE INVERTED AT rivers/01. READ THIS BEFORE USING IT. ═══
|
||||||
|
///
|
||||||
|
/// It used to be a PRESET: <c>Apply(cfg)</c> stamped the locked shape onto a bare config, because
|
||||||
|
/// the bare defaults did not reproduce the terrain the developer had judged. That was the single
|
||||||
|
/// most expensive fact in the codebase — *"run the default generator" ≠ "the terrain the developer
|
||||||
|
/// locked"* — and a fresh chat comparing bare-default output against the locked renders would see
|
||||||
|
/// differences that were CONFIGURATION, not regression.
|
||||||
|
///
|
||||||
|
/// **rivers/01 re-baselined the defaults so that `new TerrainGenConfig()` IS the locked shape.**
|
||||||
|
/// So <c>Apply</c> is GONE — there is nothing left to apply, and re-stamping the values on top of
|
||||||
|
/// the defaults would mask a default drift instead of catching it.
|
||||||
|
///
|
||||||
|
/// > ### What survives is the OPPOSITE job: these constants are now the ASSERTION TARGET.
|
||||||
|
/// > They are the values the developer judged, written down once, and <see cref="Assert"/> holds
|
||||||
|
/// > the live defaults against them. If a default is ever edited, the batch tools that claim to
|
||||||
|
/// > render the locked shape REFUSE TO RUN rather than quietly rendering something else.
|
||||||
|
///
|
||||||
|
/// ⚠ DO NOT "fix" a drift by editing these constants — they are the record of what was approved,
|
||||||
|
/// and `10_frag4_seed_gallery` / `11_erosion` are its pixels. A deliberate shape change moves the
|
||||||
|
/// defaults AND these constants AND re-runs the acceptance, in one task, as rivers/01 did.
|
||||||
|
///
|
||||||
|
/// ⚠ THE SHELF IS OFF, DELIBERATELY. The locked shape has no coast shelf; evaluating it (→ D-041)
|
||||||
|
/// is its own later task, once water renders. ⚠ THE ISLETS ARE OFF, PERMANENTLY (→ D-063): islands
|
||||||
|
/// are organic-only, made by the stretch + fragmentation and identified by the region layer.
|
||||||
|
///
|
||||||
|
/// ⚠ EROSION IS NOT PART OF THIS SHAPE. `terrain-shape-v1` is the pass-1/1c/2a field; erosion is
|
||||||
|
/// pass 2b, on top, render-only (`11_erosion` = `ea291ea`). It is asserted separately.
|
||||||
|
/// </summary>
|
||||||
|
public static class TerrainShapeV1
|
||||||
|
{
|
||||||
|
/// <summary>The tagged commit this shape is defined by. ⚠ Reference the COMMIT — the tag is annotated and local-only until the developer pushes it.</summary>
|
||||||
|
public const string Commit = "a59e52f";
|
||||||
|
|
||||||
|
public const float FragmentAmp = 0.5f, FragmentFreq = 12f, BandCentre = 0.66f, BandHalfWidth = 0.18f;
|
||||||
|
public const bool BitesOnly = false;
|
||||||
|
public const float Stretch = 2f, BandStart = 0.70f, BandFeather = 0.05f;
|
||||||
|
public const bool StretchSinker = true;
|
||||||
|
public const float SpeckFrac = 2.5e-7f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ⭐ THE DEFAULT-DRIFT GUARD. Throws unless a bare <see cref="TerrainGenConfig"/> carries the
|
||||||
|
/// locked shape exactly. Every batch tool that renders or asserts `terrain-shape-v1` calls this
|
||||||
|
/// before it generates anything.
|
||||||
|
///
|
||||||
|
/// ⚠ It is a THROW, not a warning, for the same reason <see cref="FileSafety"/> is: a batch
|
||||||
|
/// that renders the wrong terrain still produces beautiful, browsable, wrong PNGs, and a human
|
||||||
|
/// gate cannot see a default from a picture.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="who">The calling tool, for the message.</param>
|
||||||
|
public static void Assert(string who)
|
||||||
|
{
|
||||||
|
var d = new TerrainGenConfig();
|
||||||
|
string bad = null;
|
||||||
|
void Want(string name, object got, object want)
|
||||||
|
{
|
||||||
|
if (!Equals(got, want)) bad = (bad == null ? "" : bad + "; ") + $"{name} = {got}, expected {want}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Want(nameof(d.SouthStretch), d.SouthStretch, Stretch);
|
||||||
|
Want(nameof(d.SouthBandStartFrac), d.SouthBandStartFrac, BandStart);
|
||||||
|
Want(nameof(d.SouthBandFeatherFrac), d.SouthBandFeatherFrac, BandFeather);
|
||||||
|
Want(nameof(d.StretchSinker), d.StretchSinker, StretchSinker);
|
||||||
|
Want(nameof(d.FragmentAmp), d.FragmentAmp, FragmentAmp);
|
||||||
|
Want(nameof(d.FragmentFreqPerMapWidth), d.FragmentFreqPerMapWidth, FragmentFreq);
|
||||||
|
Want(nameof(d.FragmentBandCentre), d.FragmentBandCentre, BandCentre);
|
||||||
|
Want(nameof(d.FragmentBandHalfWidth), d.FragmentBandHalfWidth, BandHalfWidth);
|
||||||
|
Want(nameof(d.FragmentBitesOnly), d.FragmentBitesOnly, BitesOnly);
|
||||||
|
Want(nameof(d.SpeckRevert), d.SpeckRevert, true);
|
||||||
|
Want(nameof(d.MinLandComponentFrac), d.MinLandComponentFrac, SpeckFrac);
|
||||||
|
Want(nameof(d.CoastShelf), d.CoastShelf, false);
|
||||||
|
Want(nameof(d.RegionLabeling), d.RegionLabeling, true);
|
||||||
|
Want("Offshore.Mode", d.Offshore.Mode, OffshoreMode.Off);
|
||||||
|
|
||||||
|
if (bad != null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[{who}] LOCKED-SHAPE DRIFT: the bare TerrainGenConfig no longer reproduces " +
|
||||||
|
$"terrain-shape-v1 ({Commit}) — {bad}. Refusing to run: this tool's output is only " +
|
||||||
|
"meaningful if the defaults ARE the locked shape. → Tools/Scripts/TerrainShapeV1.cs (rivers/01).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same guard for the EROSION default, kept separate because erosion is pass 2b and is not
|
||||||
|
/// part of the shape. Tools that render the erosion A/B set the flag per variant and call this
|
||||||
|
/// only if they rely on the default.
|
||||||
|
/// </summary>
|
||||||
|
public static void AssertErosionDefaultOn(string who)
|
||||||
|
{
|
||||||
|
if (!new TerrainGenConfig().Erosion)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"[{who}] EROSION DEFAULT DRIFT: bare TerrainGenConfig.Erosion is false; rivers/01 " +
|
||||||
|
"made it true (the rivers baseline routes on the eroded render field). Refusing to run.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line for a run header, READ FROM THE LIVE DEFAULTS rather than from the constants —
|
||||||
|
/// so the header states what actually ran, and <see cref="Assert"/> states whether that is
|
||||||
|
/// still the locked shape.
|
||||||
|
/// </summary>
|
||||||
|
public static string Describe()
|
||||||
|
{
|
||||||
|
var d = new TerrainGenConfig();
|
||||||
|
return $"terrain-shape-v1 ({Commit}) from the BARE DEFAULTS: frag amp {d.FragmentAmp} freq {d.FragmentFreqPerMapWidth} " +
|
||||||
|
$"window {d.FragmentBandCentre}±{d.FragmentBandHalfWidth} · stretch {d.SouthStretch} " +
|
||||||
|
$"(band {d.SouthBandStartFrac}/{d.SouthBandFeatherFrac}, sinker {(d.StretchSinker ? "stretched" : "real-y")}) · " +
|
||||||
|
$"speck revert {d.MinLandComponentFrac:G2} · offshore {d.Offshore.Mode} · shelf {(d.CoastShelf ? "ON" : "OFF")} · labeling {(d.RegionLabeling ? "ON" : "OFF")}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,33 @@ Real batch output is written under `ISLA_OUTPUT_DIR` (default `user://output/bat
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
batches/NN_<name>/ NN = the task number that ran it
|
batches/<chat>/NN_<name>/ <chat> = the chat namespace · NN = the task number that ran it
|
||||||
├── INDEX.md what varied, what to look at, what was concluded
|
├── INDEX.md what varied, what to look at, what was concluded
|
||||||
├── scratch/ intermediates — PERSISTENT, never cleaned
|
├── scratch/ intermediates — PERSISTENT, never cleaned
|
||||||
└── <seed>_<variant>/ one directory per generated world
|
└── <seed>_<variant>/ one directory per generated world
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ⭐ The `<chat>` segment (rivers/01)
|
||||||
|
|
||||||
|
**`NN` is the AUTHORING TASK NUMBER, and task numbers restart at 00 in every build chat** — so a flat
|
||||||
|
`batches/` collides the moment a second chat exists. It did: chat 1's `02_pass1_port` and chat 2's
|
||||||
|
`02_curve_continuous` are both "batch 02", and nothing in either name says which chat made it. On the
|
||||||
|
real pile there were **four colliding prefixes (02, 03, 04, 06)** across 25 batches, separable only by
|
||||||
|
SLUG.
|
||||||
|
|
||||||
|
The slug is set by `ToolingPaths.ConfigureChat(...)` and is **required** — with none set, `BatchRoot`
|
||||||
|
throws rather than writing to the un-namespaced root. Each tool defaults to its own authoring chat, so
|
||||||
|
re-running it reproduces its batch in place; **`ISLA_CHAT` redirects a run to another namespace**, which
|
||||||
|
is what keeps an acceptance run from overwriting the very anchor it is checking against.
|
||||||
|
|
||||||
|
> ### ⚠ Writes are namespaced; historical READS carry the prefix themselves.
|
||||||
|
> `BatchRoot(task, descriptor)` inserts `<chat>`. `BatchesRoot` is the plain root, and every
|
||||||
|
> `ISLA_*_SOURCE` anchor composes against it — so an anchor default is written out in full, e.g.
|
||||||
|
> `"chat1/02_pass1_port"`. Namespacing only the writes would silently orphan every historical read.
|
||||||
|
> **That is why a missing anchor now THROWS** (`ShapingOracle.LoadAnchor`): a moved anchor used to make
|
||||||
|
> its oracle not RUN, and a batch with a silently-skipped check prints an all-PASS table that reads
|
||||||
|
> exactly like a clean one.
|
||||||
|
|
||||||
`INDEX.md` is not optional. A/B comparisons are browsed by a human, and a flat directory of
|
`INDEX.md` is not optional. A/B comparisons are browsed by a human, and a flat directory of
|
||||||
same-named PNGs is not browsable.
|
same-named PNGs is not browsable.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue