islaApocalypse/Core/Scripts/ConfigManager.cs
beezm e1e1fe3227 feat: droplet hydraulic erosion — carve+deposit, three governors, sea clamp (terrain-water task 17, Phase C0)
Erosion: "off"|"v1" gate (default off, pending the developer's gate). Output-only:
applied to the render map after detail, before the crater carve; the classify path
reads pre-erosion heights, so biomes/water stay byte-identical (verified: OFF run
bit-identical to the task-14 reference; ON run BIOM/WBID identical to OFF).
Governors: droplet count / lifetime / per-cell carve cap (ledger-enforced, thrown
on violation). Sea clamp: below-sea cells read-only, land never carved below
sea+margin — rendered coastline provably fixed (asserted per generation). Crater
excluded at 1.2×R. Deterministic from resolvedSeed+9271 (PCG32). New EROS
blueprint section (writer/parser/harness/format doc). Supersedes the reverted D8
approach with the organic droplet model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 02:42:26 -04:00

316 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using Godot;
using Godot.Collections; // Required for Godot's built-in JSON parser
namespace IslaApocalypse.Core // Change this if your namespace is different
{
public static class ConfigManager
{
// Default Fallbacks
// public static int WorldSeed = 1063685222;
public static int WorldSeed = (int)GD.Randi(); // if 0 also randomizes
public static int MapSize = 8192;
public static float CraterRadius = 400f;
public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount
public static int ChunkRadius = 24; // Default chunk size, can be overridden by config
// Iteration toggle: skip the ~25-min A* road pass entirely. The blueprint is
// still exported (road sections present but empty) — an iteration artifact,
// not a shippable world. Default false.
public static bool SkipRoads = false;
// Sea-level model (D-033, terrain-water task 04): "flat" = one scalar sea
// level everywhere (SeaLevelValue); "field" = the legacy latitude Lerp
// (0.26 north .. 0.15 south). Generator-only — the runtime never computes
// sea level. Default: flat 0.15.
public static string SeaLevelModel = "flat";
public static float SeaLevelValue = 0.15f;
// Height-redistribution curve (tasks 0510, graduation M-7): "v5" is the
// task-09 gate's winner — the BALANCED terraced ascent with corner easing and
// modulated shelves; "off" is the raw legacy profile. "v1""v4" and the
// taste-batch tri-state ("v5-compact" lost, "v5-balanced" became plain "v5")
// are retired. Default: v5.
public static string TerrainCurve = "v5";
// Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
// variation as one judged unit (requires the curve; no-op when it is off);
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres
// of OUTPUT height. ShelfEdgeVariation is the shelf-edge warp amplitude in
// metres of INPUT height — how far the shelf/riser boundary contour is
// displaced, not an elevation change; it is clamped at load time to the
// largest shift the curve's bands can absorb. Defaults: v1, 3 m, 12 m.
public static string TerrainDetail = "v1";
public static float ShelfReliefAmp = 3.0f;
public static float ShelfEdgeVariation = 12.0f;
// Hydraulic erosion (task 17, Phase C0): droplet-based carve-and-deposit on
// the RENDER height map only — the classify path (biomes/water) never sees
// it. "off" until the developer's gate approves it; the batch that turns it
// on does so explicitly. The three GOVERNORS hard-bound the pass:
// DropletCount (cost/detail), DropletLifetime (max steps per droplet),
// CarveCap (max erosion depth per cell, metres — the runaway-trench guard
// and what keeps erosion a detailing pass). ErosionSeaMargin is the flood
// guard: no cell is ever carved below sea + margin, and below-sea cells are
// never touched at all, so the rendered coastline cannot move. The remaining
// dials are the standard droplet-model strength constants; slopes/amounts
// are in METRES (1 raw height unit = 251 m).
public static string Erosion = "off";
public static int ErosionDropletCount = 400000;
public static int ErosionDropletLifetime = 48;
public static float ErosionCarveCap = 8.0f; // m per cell
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
public static int ErosionBrushRadius = 2; // px
public static float ErosionInertia = 0.05f;
public static float ErosionCapacity = 4.0f;
public static float ErosionMinSlope = 0.01f; // m per px, capacity floor
public static float ErosionErodeRate = 0.3f;
public static float ErosionDepositRate = 0.3f;
public static float ErosionEvaporation = 0.02f;
public static float ErosionGravity = 4.0f;
// Island falloff shaping (task 11).
//
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
// at and below sea, so it never reached the seabed, which still dropped ~6.7x
// steeper than the land it meets. "steep" is the pre-task-11 seabed, kept for
// A/B. The shelf cannot move the waterline, so biomes and water are identical
// either way. Default: wide.
//
// IslandAxisX/Y: the falloff axis ratios. These MOVE THE COASTLINE and
// therefore move biomes, so the DEFAULT is the shape the gate approved.
// Task 11 shipped 1.30/0.78 as the default and the developer's gate REJECTED
// that elongation; the defaults are back to 1.15/0.90 so that omitting the
// keys can no longer silently produce the rejected island (task 12 §4).
// NOTE, measured in task 11: the island is already Trench-clamped in x at
// ~90% of the map width, so AxisX is a weak lever — aspect responds almost
// entirely to AxisY, which trades against land area.
//
// OffshoreIslandDensity: fraction of the ocean noise field above the islet
// threshold. 0 disables the layer. Islets never touch the Trench and are held
// off the mainland by a depth moat.
public static string CoastProfile = "wide";
public static float IslandAxisX = LEGACY_AXIS_X; // 1.15 — the gate's verdict
public static float IslandAxisY = LEGACY_AXIS_Y; // 0.90
public static float OffshoreIslandDensity = 0.02f;
// The gate-approved island shape, named so the defaults above and the bad-value
// restore below both point at one place. (`const`, so using it in a field
// initialiser declared earlier resolves at compile time.)
public const float LEGACY_AXIS_X = 1.15f;
public const float LEGACY_AXIS_Y = 0.90f;
public static void LoadConfig()
{
string path = "res://ServerConfig.json";
if (!FileAccess.FileExists(path))
{
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
return;
}
// Read the file
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
string content = file.GetAsText();
// Parse the JSON
var json = new Json();
var error = json.Parse(content);
if (error != Error.Ok)
{
GD.PrintErr($"[ConfigManager] JSON Parse Error: {json.GetErrorMessage()}");
return;
}
var data = (Dictionary)json.Data;
// Extract the Seed
if (data.ContainsKey("WorldSeed"))
{
WorldSeed = (int)data["WorldSeed"];
}
// Extract the MapProfile and run your Switch/Case logic!
string profile = "8K";
if (data.ContainsKey("MapProfile"))
{
profile = (string)data["MapProfile"];
}
// Extract the TownDensity and run your Switch/Case logic!
string townDensity = "Normal";
if (data.ContainsKey("TownDensity"))
{
townDensity = (string)data["TownDensity"];
}
// Extract the ChunkRadius
if (data.ContainsKey("ChunkRadius")) {
ChunkRadius = (int)data["ChunkRadius"];
}
// Extract the SkipRoads iteration toggle
if (data.ContainsKey("SkipRoads"))
{
SkipRoads = (bool)data["SkipRoads"];
}
// Extract the sea-level model
if (data.ContainsKey("SeaLevelModel"))
{
string model = (string)data["SeaLevelModel"];
if (model == "flat" || model == "field")
SeaLevelModel = model;
else
GD.PrintErr($"[ConfigManager] Unknown SeaLevelModel '{model}'. Keeping '{SeaLevelModel}'.");
}
if (data.ContainsKey("SeaLevelValue"))
{
SeaLevelValue = (float)data["SeaLevelValue"];
}
// Extract the terrain-curve gate
if (data.ContainsKey("TerrainCurve"))
{
string curve = (string)data["TerrainCurve"];
if (curve == "off" || curve == "v5")
TerrainCurve = curve;
else if (curve == "v5-balanced")
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-balanced' won the task-09 gate and is now plain \"v5\". Keeping '{TerrainCurve}'.");
else if (curve == "v5-compact")
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-compact' was retired by the task-09 verdict (BALANCED won). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
else if (curve == "v1" || curve == "v2" || curve == "v3" || curve == "v4")
GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, tasks 09/10). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
else
GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'.");
}
// Extract the terrain-detail gate + relief amplitude
if (data.ContainsKey("TerrainDetail"))
{
string detail = (string)data["TerrainDetail"];
if (detail == "off" || detail == "v1")
TerrainDetail = detail;
else
GD.PrintErr($"[ConfigManager] Unknown TerrainDetail '{detail}'. Keeping '{TerrainDetail}'.");
}
if (data.ContainsKey("ShelfReliefAmp"))
{
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
}
if (data.ContainsKey("ShelfEdgeVariation"))
{
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
}
// Extract the erosion gate + dials (task 17)
if (data.ContainsKey("Erosion"))
{
string erosion = (string)data["Erosion"];
if (erosion == "off" || erosion == "v1")
Erosion = erosion;
else
GD.PrintErr($"[ConfigManager] Unknown Erosion '{erosion}'. Keeping '{Erosion}'.");
}
if (data.ContainsKey("ErosionDropletCount")) ErosionDropletCount = (int)data["ErosionDropletCount"];
if (data.ContainsKey("ErosionDropletLifetime")) ErosionDropletLifetime = (int)data["ErosionDropletLifetime"];
if (data.ContainsKey("ErosionCarveCap")) ErosionCarveCap = (float)data["ErosionCarveCap"];
if (data.ContainsKey("ErosionSeaMargin")) ErosionSeaMargin = (float)data["ErosionSeaMargin"];
if (data.ContainsKey("ErosionBrushRadius")) ErosionBrushRadius = (int)data["ErosionBrushRadius"];
if (data.ContainsKey("ErosionInertia")) ErosionInertia = (float)data["ErosionInertia"];
if (data.ContainsKey("ErosionCapacity")) ErosionCapacity = (float)data["ErosionCapacity"];
if (data.ContainsKey("ErosionMinSlope")) ErosionMinSlope = (float)data["ErosionMinSlope"];
if (data.ContainsKey("ErosionErodeRate")) ErosionErodeRate = (float)data["ErosionErodeRate"];
if (data.ContainsKey("ErosionDepositRate")) ErosionDepositRate = (float)data["ErosionDepositRate"];
if (data.ContainsKey("ErosionEvaporation")) ErosionEvaporation = (float)data["ErosionEvaporation"];
if (data.ContainsKey("ErosionGravity")) ErosionGravity = (float)data["ErosionGravity"];
// Governor bounds are enforced HERE, loudly, so a bad dial is a refused
// dial rather than a silently absurd generation. The clamps are wide —
// they exist to catch typos (an extra zero), not to tune.
int rawCount = ErosionDropletCount; int rawLife = ErosionDropletLifetime;
float rawCap = ErosionCarveCap;
ErosionDropletCount = Mathf.Clamp(ErosionDropletCount, 0, 50_000_000);
ErosionDropletLifetime = Mathf.Clamp(ErosionDropletLifetime, 1, 4096);
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f);
ErosionBrushRadius = Mathf.Clamp(ErosionBrushRadius, 0, 8);
ErosionInertia = Mathf.Clamp(ErosionInertia, 0f, 0.99f);
ErosionCapacity = Mathf.Max(ErosionCapacity, 0f);
ErosionMinSlope = Mathf.Max(ErosionMinSlope, 0f);
ErosionErodeRate = Mathf.Clamp(ErosionErodeRate, 0f, 1f);
ErosionDepositRate = Mathf.Clamp(ErosionDepositRate, 0f, 1f);
ErosionEvaporation = Mathf.Clamp(ErosionEvaporation, 0f, 0.5f);
ErosionGravity = Mathf.Max(ErosionGravity, 0f);
// Extract the island-falloff dials (task 11)
if (data.ContainsKey("CoastProfile"))
{
string coast = (string)data["CoastProfile"];
if (coast == "steep" || coast == "wide")
CoastProfile = coast;
else
GD.PrintErr($"[ConfigManager] Unknown CoastProfile '{coast}'. Keeping '{CoastProfile}'.");
}
if (data.ContainsKey("IslandAxisX")) IslandAxisX = (float)data["IslandAxisX"];
if (data.ContainsKey("IslandAxisY")) IslandAxisY = (float)data["IslandAxisY"];
if (data.ContainsKey("OffshoreIslandDensity")) OffshoreIslandDensity = (float)data["OffshoreIslandDensity"];
// The axis ratios divide map extents; a zero or negative one is a divide-by-
// zero that would silently produce an all-ocean map. Refuse it loudly.
if (IslandAxisX <= 0.01f || IslandAxisY <= 0.01f)
{
GD.PrintErr($"[ConfigManager] IslandAxisX/Y must be > 0.01 (got {IslandAxisX}/{IslandAxisY}). Restoring legacy {LEGACY_AXIS_X}/{LEGACY_AXIS_Y}.");
IslandAxisX = LEGACY_AXIS_X;
IslandAxisY = LEGACY_AXIS_Y;
}
OffshoreIslandDensity = Mathf.Clamp(OffshoreIslandDensity, 0f, 0.5f);
switch (profile)
{
case "4K":
MapSize = 4096;
CraterRadius = 400f; // Scaled down crater
break;
case "6K":
MapSize = 6144;
CraterRadius = 600f;
break;
case "8K":
MapSize = 8192;
CraterRadius = 800f; // Your original crater size spread over an 8K map
break;
case "10K":
MapSize = 10240;
CraterRadius = 1000f;
break;
default:
GD.PrintErr($"[ConfigManager] Unknown MapProfile '{profile}'. Defaulting to 8K.");
MapSize = 8192;
CraterRadius = 800f;
break;
}
switch (townDensity)
{
case "Sparse":
DensityMultiplier = 0.5f;
break;
case "Normal":
DensityMultiplier = 1.0f;
break;
case "Dense":
DensityMultiplier = 2.0f;
break;
default:
GD.PrintErr($"[ConfigManager] Unknown TownDensity '{townDensity}'. Defaulting to Normal.");
DensityMultiplier = 1.0f;
break;
}
GD.Print($"[ConfigManager] Successfully loaded {profile} Profile. MapSize set to {MapSize}.");
}
}
}