Still PURE ANALYSIS — zero terrain change, zero water; the source blueprint is never written (md5-verified). Extends task 21 per the developer's b+c decision: promote ~5-6 rivers, the natural ocean trunks PLUS the top endorheic giants. Giants (top GiantCount terminal basins by per-basin total inflow) get their main stem anchored on the STRONGEST FEEDER into the basin, not the basin's deepest cell — on a flat basin floor the deepest cell sees only local trickles (the task-21 lesson applied to stems). Classification is by what the terminal BASIN holds, not the stem's single pooling cell (a stem can pool on dry ground a few hundred px short of its lagoon and still be a lagoon river): basin holds a classify lake -> LAKE-ENDER; dry pan -> ROUTED; the giant pooling nearest the southernmost town is the SOUTHERN CANDIDATE and always ROUTED (shown, not forced). Routed giants carry a PROVISIONAL route: steepest descent on the FULL (no-terminal) epsilon fill, so the basin overtops at its spill and the walk follows the terrain's own drainage to the ocean — the technique part 2 carves with, here only drawn and flagged provisionalRoute_NOT_WATER in the JSON. Seed 1280587109 result (defaults, 6 rivers): 3 ocean trunks (unchanged from task 21) + GIANT 1 [ROUTED - SOUTHERN CANDIDATE] 2.27M px pooling in the SE lagoon, 932-px route via spill (6598,5866) to the ocean — the island's biggest river serving the south; GIANT 2 [LAKE-ENDER] 1.82M px ending at the E lagoon; GIANT 3 [ROUTED] 1.76M px SW dry-pan system, 1716-px route to the SW coast. All provisional routes reach the ocean. Analysis 22 s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
13 KiB
C#
286 lines
13 KiB
C#
using Godot;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using IslaApocalypse.Core;
|
|
|
|
/// <summary>
|
|
/// The river-plan tool (C0b part 1, terrain-water task 21). Headless, harness-style:
|
|
///
|
|
/// 1. load an EROSION-ON blueprint through the real parser,
|
|
/// 2. run DrainageAnalysis over its (eroded) heightmap — pure analysis,
|
|
/// 3. print the full plan report to the console,
|
|
/// 4. write the plan as a JSON SIDECAR next to the source file.
|
|
///
|
|
/// It never writes the blueprint. The sidecar is deliberately NOT a blueprint
|
|
/// section: sections are for realized world data, and this is a PLAN the developer
|
|
/// gates before part 2 carves anything — a plan that read as actual water would be
|
|
/// exactly the masquerade task 21 forbids. Part 2 owns the durable representation.
|
|
///
|
|
/// Run: Godot --headless --path <repo> res://Tools/Scenes/RiverPlanTool.tscn
|
|
/// Env: RIVERPLAN_SRC (source .dat; default user://MapData_Seed_1280587109.dat),
|
|
/// RIVERPLAN_OUT (sidecar path; default <src dir>/RiverPlan_Seed_<seed>.json),
|
|
/// RIVERPLAN_* dial overrides (see ReadParams).
|
|
/// Exit 0 = plan written, 1 = failure.
|
|
/// </summary>
|
|
public partial class RiverPlanTool : Node
|
|
{
|
|
public override void _Ready()
|
|
{
|
|
bool ok = false;
|
|
try { ok = RunPlan(); }
|
|
catch (System.Exception e) { GD.PrintErr($"[RiverPlan] EXCEPTION: {e}"); }
|
|
GD.Print(ok ? "[RiverPlan] RESULT: PLAN WRITTEN" : "[RiverPlan] RESULT: FAIL");
|
|
GetTree().Quit(ok ? 0 : 1);
|
|
}
|
|
|
|
private static float EnvF(string k, float d) =>
|
|
float.TryParse(OS.GetEnvironment(k), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : d;
|
|
private static int EnvI(string k, int d) =>
|
|
int.TryParse(OS.GetEnvironment(k), out var v) ? v : d;
|
|
|
|
private static DrainageAnalysis.Params ReadParams()
|
|
{
|
|
var p = new DrainageAnalysis.Params();
|
|
p.EndorheicMinDepthM = EnvF("RIVERPLAN_ENDO_MIN_DEPTH_M", p.EndorheicMinDepthM);
|
|
p.EndorheicMinAreaPx = EnvI("RIVERPLAN_ENDO_MIN_AREA_PX", p.EndorheicMinAreaPx);
|
|
p.EndorheicMinInflowPx = EnvI("RIVERPLAN_ENDO_MIN_INFLOW_PX", p.EndorheicMinInflowPx);
|
|
p.EndorheicMaxCount = EnvI("RIVERPLAN_ENDO_MAX_COUNT", p.EndorheicMaxCount);
|
|
p.TrunkCount = EnvI("RIVERPLAN_TRUNK_COUNT", p.TrunkCount);
|
|
p.TrunkCount = EnvI("RIVERPLAN_OCEAN_N", p.TrunkCount); // 21b alias
|
|
p.GiantCount = EnvI("RIVERPLAN_GIANT_N", p.GiantCount);
|
|
p.MinOutletSeparationPx = EnvI("RIVERPLAN_OUTLET_SEPARATION_PX", p.MinOutletSeparationPx);
|
|
p.StemMinAccPx = EnvI("RIVERPLAN_STEM_MIN_ACC_PX", p.StemMinAccPx);
|
|
p.TributaryMinAccPx = EnvI("RIVERPLAN_TRIB_MIN_ACC_PX", p.TributaryMinAccPx);
|
|
p.TributaryMaxPerTrunk = EnvI("RIVERPLAN_TRIB_MAX_PER_TRUNK", p.TributaryMaxPerTrunk);
|
|
p.ExitGradeMin = EnvF("RIVERPLAN_EXIT_GRADE_MIN", p.ExitGradeMin);
|
|
p.ExitWindowPx = EnvI("RIVERPLAN_EXIT_WINDOW_PX", p.ExitWindowPx);
|
|
p.SeaLevel = EnvF("RIVERPLAN_SEA_LEVEL", p.SeaLevel);
|
|
return p;
|
|
}
|
|
|
|
private bool RunPlan()
|
|
{
|
|
string src = OS.GetEnvironment("RIVERPLAN_SRC");
|
|
if (string.IsNullOrEmpty(src))
|
|
src = ProjectSettings.GlobalizePath("user://MapData_Seed_1280587109.dat");
|
|
GD.Print($"[RiverPlan] source blueprint: {src}");
|
|
|
|
ulong t0 = Time.GetTicksMsec();
|
|
WorldBlueprint bp = MapDataParser.LoadMapDataFromPath(src);
|
|
if (bp == null) { GD.PrintErr("[RiverPlan] blueprint load failed."); return false; }
|
|
if (bp.Erosion == null)
|
|
GD.PrintErr("[RiverPlan] ⚠ source carries no EROS section — analysing an UNERODED " +
|
|
"surface; the plan will still compute but is not the C0b input the task means.");
|
|
ulong t1 = Time.GetTicksMsec();
|
|
GD.Print($"[RiverPlan] loaded in {(t1 - t0) / 1000.0:F1}s " +
|
|
$"(seed {bp.Params?.WorldSeed}, {bp.MapSize}², erosion {(bp.Erosion != null ? $"v{bp.Erosion.Version}" : "ABSENT")}).");
|
|
|
|
if (bp.WaterBodyIds == null)
|
|
{ GD.PrintErr("[RiverPlan] source carries no WBID — cannot identify THE OCEAN; refusing."); return false; }
|
|
// THE OCEAN body (WBID == 1) is the only water that counts as "the sea":
|
|
// enclosed lagoons are depressions a river may legitimately END in, not
|
|
// destinations that make a trunk "sea-reaching".
|
|
int nn = bp.MapSize;
|
|
bool[] isOcean = new bool[nn * nn];
|
|
bool[] isClassifyWater = new bool[nn * nn];
|
|
for (int x = 0; x < nn; x++)
|
|
for (int y = 0; y < nn; y++)
|
|
{
|
|
ushort wb = bp.WaterBodyIds[x, y];
|
|
isOcean[x * nn + y] = wb == 1;
|
|
isClassifyWater[x * nn + y] = wb != 0;
|
|
}
|
|
|
|
// Southernmost town — the 21b SOUTHERN CANDIDATE anchor (shown, not forced).
|
|
float southX = -1f, southY = -1f;
|
|
foreach (var t in bp.Towns)
|
|
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
|
|
|
|
var p = ReadParams();
|
|
var plan = DrainageAnalysis.Run(bp.HeightMap, bp.MapSize, isOcean, isClassifyWater, southX, southY, p);
|
|
ulong t2 = Time.GetTicksMsec();
|
|
GD.Print($"[RiverPlan] analysis in {(t2 - t1) / 1000.0:F1}s.");
|
|
|
|
// ---- console report ----
|
|
GD.Print($"[RiverPlan] routing: {plan.LandCells} land cells; " +
|
|
$"{plan.SeaReachingCells} drain to sea ({100.0 * plan.SeaReachingCells / plan.LandCells:F1}%), " +
|
|
$"{plan.EndorheicCells} endorheic ({100.0 * plan.EndorheicCells / plan.LandCells:F1}%), " +
|
|
$"{plan.UnroutedCells} unrouted (should be ~0).");
|
|
GD.Print($"[RiverPlan] depressions: {plan.PitsFilledCount} pits filled through for routing, " +
|
|
$"{plan.TerminalBasinCount} qualified as terminal basins " +
|
|
$"(depth ≥ {p.EndorheicMinDepthM} m and area ≥ {p.EndorheicMinAreaPx} px).");
|
|
GD.Print("[RiverPlan] top outlets by drainage area (pre-separation):");
|
|
foreach (var (x, y, a) in plan.AllOutletsTop)
|
|
GD.Print($"[RiverPlan] ({x},{y}) {a} px");
|
|
int ti = 0;
|
|
foreach (var t in plan.Trunks)
|
|
{
|
|
ti++;
|
|
GD.Print($"[RiverPlan] TRUNK {ti}: outlet ({t.Outlet.x:F0},{t.Outlet.y:F0}), " +
|
|
$"drainage {t.DrainageAreaPx} px, stem {t.Course.Count * 4} px, " +
|
|
(t.ExitFound
|
|
? $"mountain-exit ({t.MountainExit.x:F0},{t.MountainExit.y:F0}) at {t.MountainExitElevM:F0} m"
|
|
: "mountain-exit NOT FOUND (stem never sustains the exit grade)") +
|
|
$", {t.Tributaries.Count} tributaries.");
|
|
foreach (var tr in t.Tributaries)
|
|
GD.Print($"[RiverPlan] trib: joins near head ({tr.Course[0].x:F0},{tr.Course[0].y:F0}), " +
|
|
$"drainage {tr.DrainageAreaPx} px");
|
|
}
|
|
foreach (var e in plan.Endorheics)
|
|
{
|
|
ushort wb = bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y];
|
|
GD.Print($"[RiverPlan] ENDORHEIC terminal ({e.Terminal.x:F0},{e.Terminal.y:F0}): " +
|
|
$"drainage {e.DrainageAreaPx} px into a basin {e.BasinDepthM:F1} m deep, {e.BasinAreaPx} px" +
|
|
(wb > 1 ? $" — terminates IN classify lake/lagoon WBID {wb} (river-feeds-lake)" : " — dry closed basin") + ".");
|
|
}
|
|
|
|
// ---- 21b: the promoted giants ----
|
|
int gi = 0;
|
|
foreach (var g in plan.Giants)
|
|
{
|
|
gi++;
|
|
GD.Print($"[RiverPlan] GIANT {gi} [{g.Kind.ToUpper()}{(g.SouthernCandidate ? " — SOUTHERN CANDIDATE" : "")}]: " +
|
|
$"drainage {g.DrainageAreaPx} px, pools at ({g.Terminal.x:F0},{g.Terminal.y:F0}) " +
|
|
$"({(g.TerminalInClassifyWater ? "in classify water" : "dry pan")}, basin {g.BasinDepthM:F1} m / {g.BasinAreaPx} px), " +
|
|
(g.ExitFound ? $"mountain-exit ({g.MountainExit.x:F0},{g.MountainExit.y:F0}) at {g.MountainExitElevM:F0} m, " : "") +
|
|
$"{g.Tributaries.Count} tributaries" +
|
|
(g.ProvisionalRoute != null
|
|
? $"; PROVISIONAL route {g.ProvisionalRoute.Count * 4} px via spill ({g.Spill.x:F0},{g.Spill.y:F0}) " +
|
|
(g.RouteReachedOcean ? "-> reaches the OCEAN" : "-> DID NOT reach the ocean (walk stuck — report)")
|
|
: "; ends at its lake") + ".");
|
|
}
|
|
|
|
// ---- the southern-town report (filed fact, not a constraint) ----
|
|
if (bp.Towns.Count > 0)
|
|
{
|
|
TownLocation south = bp.Towns[0];
|
|
foreach (var t in bp.Towns)
|
|
if (t.Position.Y > south.Position.Y) south = t;
|
|
GD.Print($"[RiverPlan] southernmost town: tier {south.Tier} at " +
|
|
$"({south.Position.X:F0},{south.Position.Y:F0}).");
|
|
ti = 0;
|
|
foreach (var t in plan.Trunks)
|
|
{
|
|
ti++;
|
|
float best = float.MaxValue;
|
|
foreach (var (x, y) in t.Course)
|
|
{
|
|
float dx = x - south.Position.X, dy = y - south.Position.Y;
|
|
float d2 = dx * dx + dy * dy;
|
|
if (d2 < best) best = d2;
|
|
}
|
|
GD.Print($"[RiverPlan] SOUTH REPORT trunk {ti}: outlet y={t.Outlet.y:F0} " +
|
|
$"({(t.Outlet.y > bp.MapSize * 0.55f ? "southern" : t.Outlet.y < bp.MapSize * 0.45f ? "northern" : "central")} coast); " +
|
|
$"course passes {Mathf.Sqrt(best):F0} px from the southernmost town.");
|
|
}
|
|
}
|
|
|
|
// ---- JSON sidecar ----
|
|
string outPath = OS.GetEnvironment("RIVERPLAN_OUT");
|
|
if (string.IsNullOrEmpty(outPath))
|
|
outPath = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(src) ?? ".",
|
|
$"RiverPlan_Seed_{bp.Params?.WorldSeed}.json");
|
|
System.IO.File.WriteAllText(outPath, ToJson(bp, plan));
|
|
GD.Print($"[RiverPlan] plan sidecar written: {outPath}");
|
|
return true;
|
|
}
|
|
|
|
// Hand-rolled, invariant-culture JSON for a fixed schema — deterministic output,
|
|
// no serializer reflection surprises.
|
|
private static string ToJson(WorldBlueprint bp, DrainageAnalysis.Plan plan)
|
|
{
|
|
var ci = CultureInfo.InvariantCulture;
|
|
var sb = new StringBuilder(1 << 20);
|
|
void Pt(StringBuilder b, (float x, float y) v) =>
|
|
b.Append('[').Append(v.x.ToString("F1", ci)).Append(',').Append(v.y.ToString("F1", ci)).Append(']');
|
|
void Course(List<(float x, float y)> c)
|
|
{
|
|
sb.Append('[');
|
|
for (int i = 0; i < c.Count; i++) { if (i > 0) sb.Append(','); Pt(sb, c[i]); }
|
|
sb.Append(']');
|
|
}
|
|
sb.Append("{\n\"_WARNING\": \"RIVER *PLAN* — analysis output for the task-21 gate. ");
|
|
sb.Append("Nothing here is realized water or terrain. Part 2 (task 22) consumes this; ");
|
|
sb.Append("nothing at runtime may read it as water.\",\n");
|
|
sb.Append($"\"seed\": {bp.Params?.WorldSeed ?? 0}, \"mapSize\": {bp.MapSize},\n");
|
|
var p = plan.P;
|
|
sb.Append($"\"params\": {{\"endoMinDepthM\": {p.EndorheicMinDepthM.ToString(ci)}, ");
|
|
sb.Append($"\"endoMinAreaPx\": {p.EndorheicMinAreaPx}, \"endoMinInflowPx\": {p.EndorheicMinInflowPx}, ");
|
|
sb.Append($"\"endoMaxCount\": {p.EndorheicMaxCount}, \"trunkCount\": {p.TrunkCount}, ");
|
|
sb.Append($"\"minOutletSeparationPx\": {p.MinOutletSeparationPx}, \"stemMinAccPx\": {p.StemMinAccPx}, ");
|
|
sb.Append($"\"tribMinAccPx\": {p.TributaryMinAccPx}, \"tribMaxPerTrunk\": {p.TributaryMaxPerTrunk}, ");
|
|
sb.Append($"\"exitGradeMin\": {p.ExitGradeMin.ToString(ci)}, \"exitWindowPx\": {p.ExitWindowPx}, ");
|
|
sb.Append($"\"seaLevel\": {p.SeaLevel.ToString(ci)}}},\n");
|
|
sb.Append($"\"routing\": {{\"landCells\": {plan.LandCells}, \"seaReaching\": {plan.SeaReachingCells}, ");
|
|
sb.Append($"\"endorheic\": {plan.EndorheicCells}, \"unrouted\": {plan.UnroutedCells}, ");
|
|
sb.Append($"\"pitsFilled\": {plan.PitsFilledCount}, \"terminalBasins\": {plan.TerminalBasinCount}}},\n");
|
|
sb.Append("\"trunks\": [\n");
|
|
for (int i = 0; i < plan.Trunks.Count; i++)
|
|
{
|
|
var t = plan.Trunks[i];
|
|
sb.Append(" {\"outlet\": "); Pt(sb, t.Outlet);
|
|
sb.Append($", \"drainageAreaPx\": {t.DrainageAreaPx}, \"exitFound\": {(t.ExitFound ? "true" : "false")}, ");
|
|
sb.Append("\"mountainExit\": "); Pt(sb, t.MountainExit);
|
|
sb.Append($", \"mountainExitElevM\": {t.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
|
|
Course(t.Course);
|
|
sb.Append(",\n \"tributaries\": [");
|
|
for (int j = 0; j < t.Tributaries.Count; j++)
|
|
{
|
|
var tr = t.Tributaries[j];
|
|
if (j > 0) sb.Append(',');
|
|
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
|
|
Course(tr.Course);
|
|
sb.Append('}');
|
|
}
|
|
sb.Append("]\n }");
|
|
if (i < plan.Trunks.Count - 1) sb.Append(',');
|
|
sb.Append('\n');
|
|
}
|
|
sb.Append("],\n\"giants\": [\n");
|
|
for (int i = 0; i < plan.Giants.Count; i++)
|
|
{
|
|
var g = plan.Giants[i];
|
|
sb.Append(" {\"kind\": \"").Append(g.Kind).Append("\", ");
|
|
sb.Append($"\"southernCandidate\": {(g.SouthernCandidate ? "true" : "false")}, ");
|
|
sb.Append($"\"drainageAreaPx\": {g.DrainageAreaPx}, ");
|
|
sb.Append("\"terminal\": "); Pt(sb, g.Terminal);
|
|
sb.Append($", \"terminalInClassifyWater\": {(g.TerminalInClassifyWater ? "true" : "false")}, ");
|
|
sb.Append($"\"basinDepthM\": {g.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {g.BasinAreaPx}, ");
|
|
sb.Append($"\"exitFound\": {(g.ExitFound ? "true" : "false")}, \"mountainExit\": "); Pt(sb, g.MountainExit);
|
|
sb.Append($", \"mountainExitElevM\": {g.MountainExitElevM.ToString("F1", ci)},\n \"course\": ");
|
|
Course(g.Course);
|
|
if (g.ProvisionalRoute != null)
|
|
{
|
|
sb.Append(",\n \"spill\": "); Pt(sb, g.Spill);
|
|
sb.Append($", \"routeReachedOcean\": {(g.RouteReachedOcean ? "true" : "false")}");
|
|
sb.Append(",\n \"provisionalRoute_NOT_WATER\": ");
|
|
Course(g.ProvisionalRoute);
|
|
}
|
|
sb.Append(",\n \"tributaries\": [");
|
|
for (int j = 0; j < g.Tributaries.Count; j++)
|
|
{
|
|
var tr = g.Tributaries[j];
|
|
if (j > 0) sb.Append(',');
|
|
sb.Append($"\n {{\"drainageAreaPx\": {tr.DrainageAreaPx}, \"course\": ");
|
|
Course(tr.Course);
|
|
sb.Append('}');
|
|
}
|
|
sb.Append("]\n }");
|
|
if (i < plan.Giants.Count - 1) sb.Append(',');
|
|
sb.Append('\n');
|
|
}
|
|
sb.Append("],\n\"endorheics\": [");
|
|
for (int i = 0; i < plan.Endorheics.Count; i++)
|
|
{
|
|
var e = plan.Endorheics[i];
|
|
if (i > 0) sb.Append(',');
|
|
sb.Append("\n {\"terminal\": "); Pt(sb, e.Terminal);
|
|
sb.Append($", \"drainageAreaPx\": {e.DrainageAreaPx}, ");
|
|
sb.Append($"\"basinDepthM\": {e.BasinDepthM.ToString("F2", ci)}, \"basinAreaPx\": {e.BasinAreaPx}, ");
|
|
sb.Append($"\"terminalWbid\": {bp.WaterBodyIds[(int)e.Terminal.x, (int)e.Terminal.y]}}}");
|
|
}
|
|
sb.Append("\n]\n}\n");
|
|
return sb.ToString();
|
|
}
|
|
}
|