islaApocalypse/Core/Scripts/ConfigManager.cs
beezm 148602b4c5 feat: lock lowground routing; smooth river courses with the road pass's RDP+Chaikin (terrain-water task 23)
LOWGROUND is the gate verdict and the locked default ('short' stays available
behind the dial for the record). Every river polyline (upland stem + lowland
reach) is now decimated with RDP(4.0) and rounded with 4 Chaikin passes —
numerically mirroring MapGenerator.SmoothPath, since this pass is Godot-free —
before the bed profile is built, so the carved centreline carries none of the
8-connected Dijkstra 45° kinks (task-22 §4 caveat a). The monotone-descent and
sea-clamp constraints are applied to the smoothed course, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 19:37:29 -04:00

440 lines
21 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).
// Task-18 defaults tune for a DRAINAGE HIERARCHY: long-lived, committed
// droplets (lifetime 384 at inertia 0.35, evaporation 0.004) travel far
// enough down a flank that their paths overlap and deepen shared low lines
// into trunk channels, instead of dying as independent 48-px scratches;
// the carve cap is raised to 15 m so trunks can separate from the fine
// rills instead of both piling up against the same ceiling. A modest
// erode rate keeps the total material moved in detailing range.
// ErosionDepositCap is governor 4 (task 18): brush-spread deposition alone
// does not bound a spike once droplets carry long-path loads.
public static string Erosion = "off";
public static int ErosionDropletCount = 250000;
public static int ErosionDropletLifetime = 384;
public static float ErosionCarveCap = 15.0f; // m per cell
public static float ErosionDepositCap = 6.0f; // m per cell; <= 0 = unbounded
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
public static int ErosionBrushRadius = 2; // px
public static float ErosionInertia = 0.35f;
public static float ErosionCapacity = 4.0f;
public static float ErosionMinSlope = 0.02f; // m per px, capacity floor
public static float ErosionErodeRate = 0.12f;
public static float ErosionDepositRate = 0.15f;
public static float ErosionEvaporation = 0.004f;
public static float ErosionGravity = 4.0f;
// How erosion treats the crater surrounds (task 19). The task-17 hard
// 1.2 × CraterRadius cutoff left a visible un-eroded disc: measured, the carve
// writes only inside 0.80 × and its displacement is exactly 0 beyond that, so
// 620 811 LAND cells of ordinary terrain were being held smooth for no
// geometric reason. Now only the deep strike core is protected.
// "full" — full-strength erosion right up to the core boundary. The
// crater formed after the terrain and has weathered since.
// "feather" — erosion ramps 0→full across CraterErosionCore →
// CraterErosionFeather (the detail pass's shape), so the crater
// reads as younger, less-weathered, and there is no seam at all.
// Radii are FACTORS of CraterRadius. The flooded bay and its sea connection do
// NOT depend on these: below-sea cells are read-only in both directions.
public static string CraterErosionMode = "feather";
public static float CraterErosionCore = CRATER_EROSION_CORE_DEFAULT;
public static float CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
// 0.80 = the carve's OWN extent (MapGenerator's physicalCraterRadius). Keeping the
// core at least this wide is what makes erosion and the carve touch DISJOINT cells,
// which is what keeps the post-carve flood guard exactly zero: the carve runs after
// erosion and scales height toward the sea target, so it AMPLIFIES any erosion delta
// inside its radius and can push a hair-above-sea cell across the waterline. Measured
// at a 0.50 core: 79 cells newly below the rendered sea, 113 310 below-sea cells
// disturbed. A smaller core reclaims nothing extra either — the over-protected annulus
// is 0.80x-1.2x, entirely outside the carve.
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
// Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's
// beds into the RENDER map — ocean trunks, routed giants (lowland reach to
// the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing-
// style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks
// the lowland routing for routed giants: "short" heads direct (lightly
// terrain-aware), "lowground" follows the lowest ground and wanders like a
// real river — the task-22 gate decides which ships; "lowground" is the
// provisional default pending that verdict. Width/depth scales are taste
// dials on the flow-proportional bed profile. LOWGROUND is the LOCKED
// default — the task-22/23 gate verdict ("short" stays available for the
// record). RiverSeaMargin is the bed's
// absolute floor above sea — the erosion flood-guard discipline: no river
// bed may create inland below-sea cells, so the rendered coastline cannot
// move even with rivers carved.
public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground";
public static float RiverWidthScale = 1.0f;
public static float RiverDepthScale = 1.0f;
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
// 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";
// Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate
// config FILE to load instead of the repo's ServerConfig.json. Batch and
// A/B runs point this at a scratch config, so the developer's live
// ServerConfig.json is never written by tooling again — the whole
// backup/restore dance (and its task-19 near-miss) goes away. Loud, so
// a forgotten env var cannot silently masquerade as the repo config.
string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG");
if (!string.IsNullOrEmpty(envPath))
{
if (FileAccess.FileExists(envPath))
{
path = envPath;
GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json).");
}
else
GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config.");
}
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("ErosionDepositCap")) ErosionDepositCap = (float)data["ErosionDepositCap"];
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.");
// Rivers gate + dials (task 22)
if (data.ContainsKey("Rivers"))
{
string rv = (string)data["Rivers"];
if (rv == "off" || rv == "v1")
Rivers = rv;
else
GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'.");
}
if (data.ContainsKey("RiverRoutingStyle"))
{
string st = (string)data["RiverRoutingStyle"];
if (st == "short" || st == "lowground")
RiverRoutingStyle = st;
else
GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'.");
}
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
// Crater erosion treatment (task 19)
if (data.ContainsKey("CraterErosionMode"))
{
string cm = (string)data["CraterErosionMode"];
if (cm == "full" || cm == "feather")
CraterErosionMode = cm;
else
GD.PrintErr($"[ConfigManager] Unknown CraterErosionMode '{cm}'. Keeping '{CraterErosionMode}'.");
}
if (data.ContainsKey("CraterErosionCore")) CraterErosionCore = (float)data["CraterErosionCore"];
if (data.ContainsKey("CraterErosionFeather")) CraterErosionFeather = (float)data["CraterErosionFeather"];
CraterErosionCore = Mathf.Clamp(CraterErosionCore, 0f, 3f);
CraterErosionFeather = Mathf.Clamp(CraterErosionFeather, 0f, 4f);
// A feather band that does not extend past the core is not a band; say so
// rather than silently behaving like "full".
if (CraterErosionMode == "feather" && CraterErosionFeather <= CraterErosionCore)
{
GD.PrintErr($"[ConfigManager] CraterErosionFeather {CraterErosionFeather:F2} must exceed " +
$"CraterErosionCore {CraterErosionCore:F2} — the ramp would have zero width. " +
$"Restoring {CRATER_EROSION_FEATHER_DEFAULT:F2}.");
CraterErosionFeather = CRATER_EROSION_FEATHER_DEFAULT;
}
// Negative is meaningless; 0 is the documented "unbounded" escape hatch.
ErosionDepositCap = Mathf.Clamp(ErosionDepositCap, 0f, 60f);
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}.");
}
}
}