islaApocalypse/Core/Scripts/ConfigManager.cs
beezm 6960c2a2e8 feat: river bed carving with SHORT/LOWGROUND lowland routing A/B (terrain-water task 22, C0b part 2a)
Executes the frozen task-21b plan as real terrain — the first river pass to
modify the render map. NO WATER (part 2b waters the gated routing style).

RiverCarvePass (D-035 numeric): reruns DrainageAnalysis in-pipeline
(deterministic — same seed, same eroded surface, same plan), routes each routed
giant's lowland reach to the nearest ocean by deterministic Dijkstra under two
config-gated cost models — 'short' (distance + uphill penalty; heads direct) vs
'lowground' (cost ~ elevation above sea; follows the lowest ground and
meanders) — then carves every promoted course as a parabolic channel with a
smoothstep shoulder. Width/depth grow downstream with sqrt(drainage); bed
profile forced monotone non-increasing toward the outlet (water must flow) and
clamped to sea + RiverSeaMargin EVERYWHERE, with below-sea cells read-only and
the crater core excluded — the erosion flood-guard discipline, asserted per
generation by the water-pixel A/B (throws on any change).

Pipeline slot: AFTER GenerateTowns (towns read the render map for land/slope/
water checks — carving first would move towns and destabilise every A/B) and
BEFORE roads (a full run's A* should see the beds).

ISLA_SERVER_CONFIG env override added to ConfigManager: batch/A-B tooling loads
an alternate config file and the developer's live ServerConfig.json is never
written by tooling again (the task-19 restore near-miss class is retired).

Measured, seed 1280587109 (both variants): oracle md5-identical to baseline
(BIOM/WBID bitwise equal), 0 newly-below-sea cells, below-sea untouched, island
top 457.65 m exact, lowest carved cell exactly sea+margin (37.85 m), and
Rivers='off' is bitwise identical to the task-19 blueprint. All routed giants
reach the ocean in both styles. Wander (polyline/straight): SHORT 1.02-1.06 vs
LOWGROUND 1.24-1.38; SHORT carves 811k m3 (cuts through rises, max cumulative
cut 14.6 m), LOWGROUND 481k m3 (goes around, 14.3 m). Pass cost ~25 s (plan
21 s, routing 1.4-2.5 s, carve 0.3 s).

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

438 lines
20 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. 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}.");
}
}
}