diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index 35e77c7..cd5d861 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,6 +26,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static string SeaLevelModel = "flat"; public static float SeaLevelValue = 0.15f; + // Height-redistribution curve (task 05, graduation M-7): "v1" applies the + // calibrated storm-ladder curve to above-sea terrain (see HeightCurve.cs); + // "off" is the raw legacy profile. Biome classification is curve-invariant + // by construction either way. Default: v1. + public static string TerrainCurve = "v1"; + public static void LoadConfig() { string path = "res://ServerConfig.json"; @@ -95,6 +101,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different SeaLevelValue = (float)data["SeaLevelValue"]; } + // Extract the terrain-curve gate + if (data.ContainsKey("TerrainCurve")) + { + string curve = (string)data["TerrainCurve"]; + if (curve == "off" || curve == "v1") + TerrainCurve = curve; + else + GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); + } + switch (profile) { case "4K": diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs new file mode 100644 index 0000000..7bc2043 --- /dev/null +++ b/Tools/Scripts/HeightCurve.cs @@ -0,0 +1,104 @@ +using Godot; + +/// +/// The height-redistribution curve (terrain-water task 05, graduation M-7) — a pure, +/// static, monotonic piecewise map over raw blueprint heights (D-035: numbers in, +/// numbers out, no lifecycle). +/// +/// OUTPUT bands are fixed by design (the storm ladder; developer-approved +/// 75 % orange coverage / plateau 50 m above sea / peaks 220 m above sea). +/// INPUT knots were calibrated ONCE from measured data — the pooled CDF of +/// above-sea land heights across batch 04's ten flat-sea seeds (340,618,126 +/// samples, 2026-08-07): P75 / P90 / P93 / P96 / max. The same knots apply to +/// every seed; per-seed band proportions vary a few points by design. +/// +/// Shape, monotonic by construction (every segment's normalized slope is bounded +/// below by a positive constant) and asserted numerically at startup: +/// h ≤ sea (0.15) identity — water and the below-sea world untouched +/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat) +/// t1 → t2 linear rise into the red band +/// t2 → t3 smooth shoulder up to the plateau shelf +/// t3 → t4 near-flat plateau step (small positive slope) +/// t4 → hmaxCal accelerating spike to the peak cap +/// h > hmaxCal linear tail (keeps strict monotonicity, no clamp) +/// +public static class HeightCurve +{ + public const ushort VERSION = 1; + + // Input knots — calibrated from batch 04 B-flat pooled land CDF (see report). + public const float T1 = 0.628736f; // P75 — orange coverage boundary + public const float T2 = 0.819152f; // P90 + public const float T3 = 0.879340f; // P93 + public const float T4 = 0.962922f; // P96 + public const float HMAX_CAL = 1.452219f; // pooled max + + // Output bands — the storm ladder. + public const float SEA = 0.15f; + public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling + public const float RED_CEIL = 0.27f; // biblical ceiling + public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea) + public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief) + public const float PEAK_CAP = SEA + 220f / 251f; // ≈ 1.02649 (220 m above sea) + public const float TAIL_SLOPE = 0.25f; // above HMAX_CAL + + public static float Apply(float h) + { + if (h <= SEA) return h; + + float u, s; + if (h < T1) + { + u = (h - SEA) / (T1 - SEA); + s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3 + return SEA + s * (ORANGE_CEIL - SEA); + } + if (h < T2) + { + u = (h - T1) / (T2 - T1); + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear + } + if (h < T3) + { + u = (h - T2) / (T3 - T2); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2 + return RED_CEIL + s * (PLATEAU_LO - RED_CEIL); + } + if (h < T4) + { + u = (h - T3) / (T4 - T3); + return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope + } + if (h < HMAX_CAL) + { + u = (h - T4) / (HMAX_CAL - T4); + s = 0.2f * u + 0.8f * (u * u * u); // ease-in spike, slope ≥ 0.2 + return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI); + } + return PEAK_CAP + (h - HMAX_CAL) * TAIL_SLOPE; + } + + /// + /// Numeric strict-monotonicity check across the whole plausible domain. + /// Cheap (runs once at generator start); a violation is a build bug, not a + /// data condition — fail loudly and refuse to generate. + /// + public static void AssertMonotonic() + { + float prev = Apply(-7f); + // Coarse below the identity region, fine through every knot, out past the tail. + for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) prev = Step(prev, (float)h); + for (double h = 0.10; h <= 2.0; h += 0.0001) prev = Step(prev, (float)h); + for (double h = 2.0; h <= 8.0; h += 0.05) prev = Step(prev, (float)h); + GD.Print("[HeightCurve] Monotonicity assertion passed (v" + VERSION + ")."); + } + + private static float Step(float prev, float h) + { + float v = Apply(h); + if (v <= prev) + throw new System.InvalidOperationException( + $"[HeightCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev}. Refusing to generate."); + return v; + } +} diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 7f721b9..b622ca7 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -26,6 +26,17 @@ public partial class MapGenerator : TextureRect private float _impactRadius; private float[,] _heightMap; + + // Classification heightmap (task 05): the UNCURVED heights (plus the crater + // carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the + // two flood fills, and the shared water predicates read THIS map, so biome and + // water output is identical with the curve on or off — the bit-identical-biomes + // oracle holds by construction. Towns, roads, diagnostics, and the exported + // heights use the curved _heightMap (they live in the 3D world). When the curve + // is off this is the SAME array as _heightMap (aliased, no copy). + private float[,] _heightMapClassify; + private bool _curveOn; + private float[,] _tempMap; private Biome[,] _biomeMap; private bool[,] _isTrueOcean; @@ -59,6 +70,9 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; + _curveOn = ConfigManager.TerrainCurve == "v1"; + if (_curveOn) HeightCurve.AssertMonotonic(); + _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; _tempMap = new float[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize]; _isTrueOcean = new bool[MapSize, MapSize]; @@ -378,20 +392,31 @@ public partial class MapGenerator : TextureRect float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); + // --- 4b. THE REDISTRIBUTION CURVE (task 05) --- + // Applied AFTER noise + falloff + Trench, BEFORE the crater carve, so + // the carve cuts into curved terrain and the rim/bowl shape is + // untouched by the curve. Identity at and below sea + this ordering + // preserve the Trench/ocean-border guarantee and the crater by + // construction. classifyH stays uncurved — see _heightMapClassify. + float classifyH = finalH; + float curvedH = _curveOn ? HeightCurve.Apply(finalH) : finalH; // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); - + // We only carve the physical hole at 80% of the radius to guarantee a landbridge! - float physicalCraterRadius = _impactRadius * 0.80f; + float physicalCraterRadius = _impactRadius * 0.80f; if (distToCrater < physicalCraterRadius) { - float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); + float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! - finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); + float carveTarget = GetSeaLevel(temperature) - 0.15f; + classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); + curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); } - - _heightMap[x, y] = finalH; + + _heightMapClassify[x, y] = classifyH; + _heightMap[x, y] = curvedH; } } } @@ -412,7 +437,7 @@ public partial class MapGenerator : TextureRect Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { - if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isTrueOcean[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -427,7 +452,7 @@ public partial class MapGenerator : TextureRect Queue queue = new Queue(); Vector2I center = new Vector2I(MapSize / 2, MapSize / 2); - if (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) + if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) { queue.Enqueue(center); _isMainland[center.X, center.Y] = true; @@ -443,7 +468,7 @@ public partial class MapGenerator : TextureRect Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { - if (!_isMainland[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) + if (!_isMainland[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isMainland[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); @@ -460,7 +485,7 @@ public partial class MapGenerator : TextureRect // 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 IsWaterPixel(int x, int y) => _heightMapClassify[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]; @@ -753,7 +778,10 @@ public partial class MapGenerator : TextureRect { for (int y = 0; y < MapSize; y++) { - float h = _heightMap[x, y]; + // Biome rules classify against the UNCURVED heights (task 05) — the + // bit-identical-biomes oracle. Beach/mountain/snow bands and the + // water split all read the classify map. + float h = _heightMapClassify[x, y]; float t = _tempMap[x, y]; Biome b;