feat: shared water predicates + water-bodies stage + priority-flood diagnostics (terrain-water task 03)

IsWaterPixel/IsOceanPixel/IsLakePixel are the single per-pixel water
truth; AssignBiomesAndDraw's water branch now calls them (verbatim
rules — behavior-preserving, proven by the bit-identical-biomes oracle
at acceptance). New IdentifyWaterBodies stage between the masks and
biomes: ocean = body 1, lakes labeled 2..N with CalculateTrueOcean's
4-connectivity in deterministic scan order; one transitional surface
level per body (GetSeaLevel at the body centroid; ocean at map
centre). 0_water snapshot painted from the stage's own outputs.

Priority-flood pit-fill (heap + pit-queue variant) runs as validated
diagnostics only — serializes nothing, asserts filled>=original and
full-map non-ascending drainage (reverse BFS, no sampling), reports
closed-basin statistics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-07 01:29:51 -04:00
parent 81f7030140
commit d296ef30c4
2 changed files with 333 additions and 3 deletions

View file

@ -36,6 +36,28 @@ namespace IslaApocalypse.Core
public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown
}
/// <summary>
/// One water body from the blueprint's WBTB section: the ocean (exactly one, id 1)
/// or a lake (ids 2..N). SurfaceLevel is the documented TRANSITIONAL rule — one flat
/// level per body, GetSeaLevel at the body's pixel centroid under the still-live
/// latitude field; superseded when the flat-scalar sea model lands. Salinity is a
/// provisional placeholder for the future fresh/salt mechanic (ocean salt, lake fresh).
/// </summary>
public class WaterBodyInfo
{
public const byte TYPE_OCEAN = 0;
public const byte TYPE_LAKE = 1;
public const byte SALINITY_FRESH = 0;
public const byte SALINITY_SALT = 1;
public ushort Id;
public byte Type;
public byte Salinity;
public float SurfaceLevel; // raw blueprint height units
public int PixelCount;
public Vector2 Centroid; // map pixels
}
public class WorldBlueprint
{
public int MapSize;

View file

@ -32,6 +32,12 @@ public partial class MapGenerator : TextureRect
private bool[,] _isMainland;
internal List<TownData> _towns = new List<TownData>();
// Water-bodies stage outputs (terrain-water task 03): 0 = no water, 1 = the
// ocean, 2..N = lakes. Filled by IdentifyWaterBodies, serialized by the v2
// writer, consumed by nothing at runtime yet.
internal ushort[,] _waterBodyIds;
internal List<WaterBodyInfo> _waterBodies;
internal List<Vector2[]> _highwayPaths = new List<Vector2[]>();
internal List<Vector2[]> _branchPaths = new List<Vector2[]>();
internal List<Vector2[]> _ruggedPaths = new List<Vector2[]>();
@ -84,6 +90,10 @@ public partial class MapGenerator : TextureRect
CalculateMainland();
GD.Print($"{T()} Ocean and mainland masks done.");
IdentifyWaterBodies();
DrawWaterStageTexture();
await CaptureStage("0_water");
AssignBiomesAndDraw();
GD.Print($"{T()} Biomes done.");
await CaptureStage("1_biomes");
@ -441,6 +451,299 @@ public partial class MapGenerator : TextureRect
}
}
// =====================================================================
// SHARED WATER PREDICATES (terrain-water task 03)
// The single source of per-pixel water truth. AssignBiomesAndDraw and
// IdentifyWaterBodies both call these; correspondence between the biome
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel
// implementations agreeing.
// =====================================================================
private bool IsWaterPixel(int x, int y) => _heightMap[x, y] < GetSeaLevel(_tempMap[x, y]);
private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
/// <summary>
/// The water-bodies stage (terrain-water task 03). Promotes the world's EXISTING
/// water into explicit data: body 1 = the ocean (all IsOceanPixel pixels), bodies
/// 2..N = lakes, connected-component labeled with the SAME 4-connectivity as
/// CalculateTrueOcean's flood fill (Up/Down/Left/Right), in deterministic scan
/// order (X outer, Y inner; a body's id is fixed by its first-encountered pixel).
/// Membership comes only from the shared predicates — this stage groups pixels,
/// it never adds or removes any.
///
/// Each body carries ONE surface level: GetSeaLevel at the body's pixel centroid
/// (ocean: at the map centre). This is the documented TRANSITIONAL rule — see
/// BLUEPRINT_FORMAT.md (WBTB) — superseded when the flat-scalar sea model lands.
/// </summary>
private void IdentifyWaterBodies()
{
ulong t0 = Time.GetTicksMsec();
_waterBodyIds = new ushort[MapSize, MapSize];
_waterBodies = new List<WaterBodyInfo>();
// --- Body 1: the ocean, one body, first-class ---
long oceanCount = 0;
double oceanCx = 0, oceanCy = 0;
for (int x = 0; x < MapSize; x++)
{
for (int y = 0; y < MapSize; y++)
{
if (IsOceanPixel(x, y))
{
_waterBodyIds[x, y] = 1;
oceanCount++;
oceanCx += x; oceanCy += y;
}
}
}
Vector2 oceanCentroid = oceanCount > 0
? new Vector2((float)(oceanCx / oceanCount), (float)(oceanCy / oceanCount))
: Vector2.Zero;
_waterBodies.Add(new WaterBodyInfo
{
Id = 1,
Type = WaterBodyInfo.TYPE_OCEAN,
Salinity = WaterBodyInfo.SALINITY_SALT, // provisional default
SurfaceLevel = GetSeaLevel(_tempMap[MapSize / 2, MapSize / 2]), // ocean: map centre
PixelCount = (int)oceanCount,
Centroid = oceanCentroid
});
// --- Bodies 2..N: lakes, 4-connected like CalculateTrueOcean ---
Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right };
Queue<Vector2I> queue = new Queue<Vector2I>();
int nextId = 2;
long lakePixels = 0;
for (int x = 0; x < MapSize; x++)
{
for (int y = 0; y < MapSize; y++)
{
if (_waterBodyIds[x, y] != 0 || !IsLakePixel(x, y)) continue;
if (nextId > ushort.MaxValue)
{
GD.PrintErr($"[WaterBodies] ⚠⚠ More than {ushort.MaxValue - 1} water bodies — u16 id space exhausted. Remaining lakes left unlabeled.");
x = MapSize; break;
}
ushort id = (ushort)nextId++;
long count = 0;
double cx = 0, cy = 0;
_waterBodyIds[x, y] = id;
queue.Enqueue(new Vector2I(x, y));
while (queue.Count > 0)
{
Vector2I current = queue.Dequeue();
count++; cx += current.X; cy += current.Y;
foreach (var dir in directions)
{
Vector2I nb = current + dir;
if (nb.X < 0 || nb.X >= MapSize || nb.Y < 0 || nb.Y >= MapSize) continue;
if (_waterBodyIds[nb.X, nb.Y] != 0 || !IsLakePixel(nb.X, nb.Y)) continue;
_waterBodyIds[nb.X, nb.Y] = id;
queue.Enqueue(nb);
}
}
lakePixels += count;
int centX = Mathf.Clamp((int)Mathf.Round((float)(cx / count)), 0, MapSize - 1);
int centY = Mathf.Clamp((int)Mathf.Round((float)(cy / count)), 0, MapSize - 1);
_waterBodies.Add(new WaterBodyInfo
{
Id = id,
Type = WaterBodyInfo.TYPE_LAKE,
Salinity = WaterBodyInfo.SALINITY_FRESH, // provisional default
SurfaceLevel = GetSeaLevel(_tempMap[centX, centY]),
PixelCount = (int)count,
Centroid = new Vector2((float)(cx / count), (float)(cy / count))
});
}
}
double seconds = (Time.GetTicksMsec() - t0) / 1000.0;
GD.Print($"{T()} [WaterBodies] {_waterBodies.Count} bodies in {seconds:F1}s: ocean {oceanCount} px, {_waterBodies.Count - 1} lakes totalling {lakePixels} px.");
RunPriorityFloodDiagnostics();
}
/// <summary>
/// Priority-flood pit-filling over the post-topography heightmap — VALIDATED
/// DIAGNOSTICS ONLY. Serializes nothing (deliberately no BSIN section: basin data
/// goes stale the moment coast smoothing changes terrain; the rivers stage
/// recomputes fresh — see BLUEPRINT_FORMAT.md). Reports closed-basin statistics
/// and asserts two invariants: (a) filled ≥ original everywhere; (b) on the filled
/// surface every pixel has a non-ascending 8-neighbour path to the map border
/// (checked in full via a reverse BFS, not a sample).
/// </summary>
private void RunPriorityFloodDiagnostics()
{
ulong t0 = Time.GetTicksMsec();
int n = MapSize;
int total = n * n;
// 1-D row-major copies (idx = x * n + y) for speed.
float[] original = new float[total];
for (int x = 0; x < n; x++)
for (int y = 0; y < n; y++)
original[x * n + y] = _heightMap[x, y];
float[] filled = (float[])original.Clone();
// --- Priority-flood (Barnes et al. variant: heap + plain pit queue) ---
bool[] visited = new bool[total];
var heap = new PriorityQueue<int, float>();
var pit = new Queue<int>();
void Seed(int idx) { if (!visited[idx]) { visited[idx] = true; heap.Enqueue(idx, filled[idx]); } }
for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); }
for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); }
while (heap.Count > 0 || pit.Count > 0)
{
int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue();
float fc = filled[c];
int cx = c / n, cy = c % n;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (visited[ni]) continue;
visited[ni] = true;
if (filled[ni] <= fc) { filled[ni] = fc; pit.Enqueue(ni); }
else heap.Enqueue(ni, filled[ni]);
}
}
}
double floodSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
// --- Invariant (a): filled ≥ original everywhere ---
long invariantAViolations = 0;
for (int i = 0; i < total; i++)
if (filled[i] < original[i]) invariantAViolations++;
// --- Invariant (b): full reverse BFS from the border over non-descending
// edges; a pixel is reachable iff it has a non-ascending 8-neighbour path
// down to the border on the filled surface. ---
bool[] reachable = new bool[total];
var bfs = new Queue<int>();
void SeedB(int idx) { if (!reachable[idx]) { reachable[idx] = true; bfs.Enqueue(idx); } }
for (int x = 0; x < n; x++) { SeedB(x * n); SeedB(x * n + (n - 1)); }
for (int y = 0; y < n; y++) { SeedB(y); SeedB((n - 1) * n + y); }
while (bfs.Count > 0)
{
int c = bfs.Dequeue();
float fc = filled[c];
int cx = c / n, cy = c % n;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (reachable[ni] || filled[ni] < fc) continue;
reachable[ni] = true;
bfs.Enqueue(ni);
}
}
}
long invariantBViolations = 0;
for (int i = 0; i < total; i++)
if (!reachable[i]) invariantBViolations++;
if (invariantAViolations > 0)
GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (a) VIOLATED: {invariantAViolations} pixels have filled < original.");
if (invariantBViolations > 0)
GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (b) VIOLATED: {invariantBViolations} pixels lack a non-ascending path to the border.");
// --- Closed-basin statistics on LAND (shared predicate), 8-connected ---
long landPixels = 0, basinLandPixels = 0;
var basinAreas = new List<long>();
var basinDepths = new List<float>();
bool[] counted = new bool[total];
var comp = new Queue<int>();
for (int x = 0; x < n; x++)
{
for (int y = 0; y < n; y++)
{
int i = x * n + y;
bool land = !IsWaterPixel(x, y);
if (land) landPixels++;
if (!land || counted[i] || filled[i] <= original[i]) continue;
long area = 0; float maxDepth = 0f;
counted[i] = true;
comp.Enqueue(i);
while (comp.Count > 0)
{
int c = comp.Dequeue();
area++;
float d = filled[c] - original[c];
if (d > maxDepth) maxDepth = d;
int cx2 = c / n, cy2 = c % n;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int nx = cx2 + dx, ny = cy2 + dy;
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
int ni = nx * n + ny;
if (counted[ni] || filled[ni] <= original[ni] || IsWaterPixel(nx, ny)) continue;
counted[ni] = true;
comp.Enqueue(ni);
}
}
}
basinLandPixels += area;
basinAreas.Add(area);
basinDepths.Add(maxDepth);
}
}
basinAreas.Sort();
basinDepths.Sort();
long P(List<long> s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)];
float Pf(List<float> s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)];
double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
GD.Print($"{T()} [PriorityFlood] flood {floodSeconds:F1}s, total (with invariants+stats) {totalSeconds:F1}s.");
GD.Print($"{T()} [PriorityFlood] invariants: (a) {(invariantAViolations == 0 ? "PASS" : "FAIL")}, (b) {(invariantBViolations == 0 ? "PASS" : "FAIL")} (full check, no sampling).");
GD.Print($"{T()} [PriorityFlood] closed basins on land: {basinAreas.Count}; land px in basins {basinLandPixels}/{landPixels} ({(landPixels > 0 ? 100.0 * basinLandPixels / landPixels : 0):F1}%).");
GD.Print($"{T()} [PriorityFlood] area px: p50 {P(basinAreas, 0.5)}, p90 {P(basinAreas, 0.9)}, max {(basinAreas.Count > 0 ? basinAreas[basinAreas.Count - 1] : 0)}; " +
$"count ≥100px {basinAreas.FindAll(a => a >= 100).Count}, ≥1000px {basinAreas.FindAll(a => a >= 1000).Count}, ≥10000px {basinAreas.FindAll(a => a >= 10000).Count}.");
GD.Print($"{T()} [PriorityFlood] max depth (raw h): p50 {Pf(basinDepths, 0.5):F4}, p90 {Pf(basinDepths, 0.9):F4}, max {(basinDepths.Count > 0 ? basinDepths[basinDepths.Count - 1] : 0):F4}; " +
$"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}.");
}
/// <summary>
/// Paints the water-stage snapshot from the stage's own outputs (the biome grid
/// does not exist yet at this point in the pipeline): ocean deep blue, lakes a
/// distinct lighter blue, land neutral grey.
/// </summary>
private void DrawWaterStageTexture()
{
Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
Color land = new Color(0.45f, 0.45f, 0.42f);
Color ocean = new Color(0.05f, 0.2f, 0.45f);
Color lake = new Color(0.35f, 0.7f, 0.9f);
for (int x = 0; x < MapSize; x++)
{
for (int y = 0; y < MapSize; y++)
{
ushort id = _waterBodyIds[x, y];
img.SetPixel(x, y, id == 0 ? land : (id == 1 ? ocean : lake));
}
}
Texture = ImageTexture.CreateFromImage(img);
}
private void AssignBiomesAndDraw()
{
Image mapImage = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8);
@ -454,7 +757,12 @@ public partial class MapGenerator : TextureRect
float currentSeaLevel = GetSeaLevel(t);
if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake;
// Water classification comes from the SHARED predicates (task 03), so the
// water-bodies stage and the biome classifier cannot disagree. Same rules
// as before, verbatim: below local sea level -> Ocean if true-ocean
// connected, else Lake.
if (IsOceanPixel(x, y)) b = Biome.Ocean;
else if (IsLakePixel(x, y)) b = Biome.Lake;
else
{
float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);