rivers/02b: composition gate — N=12 pure ranking vs a gameplay sea-river floor of K=3
A re-selection over rivers/02's candidates, not a new analysis: DrainageAnalysis is reused untouched and the 8-seed distribution sweep is not re-run. - RiverPromotionTool: ISLA_PROMOTE_MODE=composition builds both compositions at one fixed total — PURE (top N by drainage, terminus irrelevant) and QUOTA (the K largest sea-reaching forced in + the N-K largest endorheic). Deterministic; shortfall is handled and flagged rather than back-filled. BindCourses now takes an explicit candidate set, because a forced sea river can sit far below rank N (measured: #29). - DrainageRenderer.RiverComposition: the composition plate, at ONE ABSOLUTE width->drainage constant (1/180 px per sqrt(drainage px)) shared across both plates and all seeds — per-plate normalisation cannot answer "is this river thin?" — plus per-river drainage/rank labels with collision-avoided placement. - ToolingPaths.BatchRoot: additive overload for a lettered sub-task, so 02b writes to 02b_composition instead of claiming task 03's number. Taste gate: no N, no K, no default set anywhere. Nothing carved, no routing, and Giant.ProvisionalRoute is still never drawn.
This commit is contained in:
parent
ea301f7a8c
commit
559306ca73
3 changed files with 693 additions and 21 deletions
|
|
@ -179,8 +179,31 @@ namespace IslaApocalypse.Core
|
|||
/// with digits, so passing `"chat2/12_drainage"` as a descriptor would be a different kind
|
||||
/// of wrong.
|
||||
/// </summary>
|
||||
public static string BatchRoot(int taskNumber, string descriptor)
|
||||
public static string BatchRoot(int taskNumber, string descriptor) => BatchRoot(taskNumber, "", descriptor);
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ The same, for a LETTERED SUB-TASK: <c>batches/<chat>/<task><suffix>_<descriptor>/</c>,
|
||||
/// e.g. <c>02b_composition</c> (rivers/02b).
|
||||
///
|
||||
/// ═══ WHY A SUFFIX RATHER THAN A NEW TASK NUMBER ═══
|
||||
///
|
||||
/// The prefix is the AUTHORING TASK's identity, and a task numbered "02b" — a follow-up that
|
||||
/// re-renders 02's material under one changed choice — has exactly that identity. Giving it a
|
||||
/// fresh number (03) would claim it is the next task in the sequence and collide with the one
|
||||
/// that actually is; folding the letter into the descriptor (<c>"02b_composition"</c>) would
|
||||
/// smuggle a prefix past the guard below, which is the drift that guard exists to stop.
|
||||
///
|
||||
/// ⚠ Letters only, and lowercase — a suffix that could be read as part of a number would
|
||||
/// reintroduce the ambiguity. Refused rather than sanitised.
|
||||
/// </summary>
|
||||
public static string BatchRoot(int taskNumber, string suffix, string descriptor)
|
||||
{
|
||||
string sfx = (suffix ?? "").Trim();
|
||||
foreach (char c in sfx)
|
||||
if (c < 'a' || c > 'z')
|
||||
throw new ArgumentException(
|
||||
$"Task suffix '{sfx}' must be lowercase letters only (e.g. \"b\" for task 02b). A suffix that " +
|
||||
"could be read as part of the task number is exactly the ambiguity the prefix rule removes.", nameof(suffix));
|
||||
if (taskNumber < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber,
|
||||
"A batch is named for the task that authored it; there is no negative task.");
|
||||
|
|
@ -198,7 +221,7 @@ namespace IslaApocalypse.Core
|
|||
$"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " +
|
||||
"the prefix is composed here so it cannot drift.", nameof(descriptor));
|
||||
|
||||
return Path.Combine(BatchesRoot, ChatSlug, $"{taskNumber:D2}_{d}");
|
||||
return Path.Combine(BatchesRoot, ChatSlug, $"{taskNumber:D2}{sfx}_{d}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,189 @@ namespace IslaApocalypse.Tools
|
|||
return img;
|
||||
}
|
||||
|
||||
// ═══ ⭐⭐ THE COMPOSITION PLATE (rivers/02b) — pure ranking vs a gameplay sea-river floor ═══
|
||||
//
|
||||
// rivers/02 established that the count is a DESIGN choice (the distribution is a power law) and
|
||||
// that this terrain's honest top-of-distribution is INLAND-DOMINANT. rivers/02b keeps the total
|
||||
// fixed and asks one question the developer stated: **do 3 FORCED sea rivers read as real
|
||||
// rivers, just smaller — or as sad thin threads beside the big inland ones?**
|
||||
//
|
||||
// ⚠⚠ THAT QUESTION CANNOT BE ASKED ON A PER-PLATE-NORMALISED PLATE, and `PromotedRivers` above
|
||||
// normalises to the widest river ON ITS OWN PLATE. Under that law the quota plate would rescale
|
||||
// itself around whatever it happens to contain, so a forced sea river drawn "thin" would be
|
||||
// reporting the plate's contents, not the river's size — and drawn beside a plate that rescaled
|
||||
// differently, the comparison is meaningless. THE ONE THING THIS PLATE MUST NOT DO.
|
||||
//
|
||||
// So the composition plates use ONE ABSOLUTE width→drainage constant, below, shared by both
|
||||
// compositions and all four seeds. A thin river is thin because it IS smaller. The constant is
|
||||
// printed on every plate and reported in the INDEX, so a reader can check the claim.
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ THE FIXED WIDTH→DRAINAGE CONSTANT — stem width in px per √(drainage px).
|
||||
///
|
||||
/// <c>1/180</c>. Chosen once, from the measured population rather than per plate: the largest
|
||||
/// candidate on ANY of the eight gallery seeds is 4,474,342 px (seed `17320508`), whose √ is
|
||||
/// 2,115 — so <c>2115/180 ≈ 11.8</c> lands the biggest drainage the terrain produces just under
|
||||
/// the 16 px ceiling, with no clipping anywhere in the population and headroom left over.
|
||||
///
|
||||
/// ⭐ THE LAW IS SCALE-FREE WITH NO MAP-SIZE TERM IN IT, and that is not an oversight. Drainage
|
||||
/// area scales as n², so √(drainage) scales as n — meaning <c>k·√area</c> already draws a stem
|
||||
/// at the same FRACTION of the map at any size. Multiplying by n/8192 on top would make width
|
||||
/// scale as n² and collapse every river onto the floor on a smaller smoke.
|
||||
/// *(rivers/02 established this analysis is only valid at 8192 regardless — the params are
|
||||
/// absolute pixel counts — so a smaller render is a pipeline check, never a comparison.)*
|
||||
/// </summary>
|
||||
public const float StemWidthPerSqrtPx = 1f / 180f;
|
||||
|
||||
/// <summary>
|
||||
/// Legibility clamp on the fixed law. ⚠ The MINIMUM is a deliberate, reported distortion: a
|
||||
/// 1 px line at 8192 is invisible at any zoom a person actually looks at a plate with, so the
|
||||
/// smallest rivers are drawn at 2 px rather than truthfully thinner. Any river AT the floor is
|
||||
/// therefore "at least this thin, possibly thinner" — which matters here, because the floor is
|
||||
/// exactly where the "thread" verdict lives. <see cref="StemWidthAtFloor"/> reports whether any
|
||||
/// drawn river hit it, so the plate never quietly flatters a thread.
|
||||
/// </summary>
|
||||
public const int StemWidthMinPx = 2;
|
||||
public const int StemWidthMaxPx = 16;
|
||||
|
||||
/// <summary>The fixed law, evaluated. NEVER normalised against the plate's own contents.</summary>
|
||||
public static int StemWidthFixed(long drainagePx)
|
||||
{
|
||||
int w = (int)MathF.Round(MathF.Sqrt(MathF.Max(0f, drainagePx)) * StemWidthPerSqrtPx);
|
||||
return Math.Clamp(w, StemWidthMinPx, StemWidthMaxPx);
|
||||
}
|
||||
|
||||
/// <summary>True when this river is drawn at the legibility floor, i.e. no thinner than shown.</summary>
|
||||
public static bool StemWidthAtFloor(long drainagePx) => StemWidthFixed(drainagePx) <= StemWidthMinPx;
|
||||
|
||||
/// <summary>The law as printed on the plate and in the INDEX — the constant is auditable, not implied.</summary>
|
||||
public static string StemWidthLaw() =>
|
||||
$"W PX = CLAMP(ROUND(SQRT(DRAINAGE PX) X {StemWidthPerSqrtPx:F6}), {StemWidthMinPx}, {StemWidthMaxPx})";
|
||||
|
||||
/// <summary>Compact drainage label: 2.33M / 736K / 4210 — the font has no lowercase.</summary>
|
||||
public static string DrainageLabel(long px) =>
|
||||
px >= 1_000_000 ? $"{px / 1e6:F2}M" : px >= 1_000 ? $"{(long)Math.Round(px / 1000.0)}K" : px.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ ONE COMPOSITION OF N RIVERS, on the shared faint base, at the FIXED width scale, with
|
||||
/// per-river size labels.
|
||||
///
|
||||
/// Identical in style to <see cref="PromotedRivers"/> — same colours (cyan sea / orange
|
||||
/// endorheic), same real upland stems, same terminus markers, `Giant.ProvisionalRoute` still
|
||||
/// never drawn — and differs in exactly the two ways rivers/02b needs:
|
||||
///
|
||||
/// 1. THE FIXED WIDTH SCALE above, instead of per-plate normalisation.
|
||||
/// 2. PER-RIVER LABELS: drainage area and rank in the FULL candidate distribution, so
|
||||
/// "real river vs thin thread" has numbers behind the eyeball. A forced sea river reading
|
||||
/// `272K R29` beside an inland `2.33M R1` tells the story before the eye does.
|
||||
///
|
||||
/// ⚠ Labels are placed with greedy collision avoidance against already-placed labels, on a dark
|
||||
/// backing box so they are legible over both bright terrain and dark ocean. A label that cannot
|
||||
/// be placed clear of the others is DROPPED rather than drawn illegibly on top of one — and the
|
||||
/// legend says how many were dropped, so a missing number is never silent.
|
||||
/// </summary>
|
||||
public static Image RiverComposition(List<RiverCandidate> promoted, Image img, int n,
|
||||
string title, string compositionLine, int candidateCount, long floorPx, bool labelAll)
|
||||
{
|
||||
if (promoted.Count == 0) return img;
|
||||
|
||||
int mark = n >= 4096 ? 18 : 10;
|
||||
|
||||
// Smallest first, so the biggest rivers finish on top.
|
||||
var byArea = new List<RiverCandidate>(promoted);
|
||||
byArea.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||||
for (int i = byArea.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var c = byArea[i];
|
||||
if (c.Course == null || c.Course.Count < 2) continue;
|
||||
Polyline(img, c.Course, n, c.IsSea ? Trunk : Giant, StemWidthFixed(c.DrainagePx));
|
||||
}
|
||||
// ⚠ Marked at the RIVER's terminus (where its stem pools), NOT the basin's deepest cell —
|
||||
// they differ on a flat basin floor. → RiverCandidate.TermX.
|
||||
foreach (var c in byArea)
|
||||
{
|
||||
if (c.IsSea) Square(img, c.TermX, c.TermY, mark, n, Trunk);
|
||||
else { Disc(img, c.TermX, c.TermY, mark, n, Giant); Ring(img, c.TermX, c.TermY, mark + 8, n, Ink, 3); }
|
||||
}
|
||||
|
||||
// ---- the labels ----
|
||||
// Which rivers get one: all of them when the plate can carry it, otherwise the 3 the
|
||||
// judgment turns on — every sea river — plus the largest inland, for scale.
|
||||
var toLabel = new List<RiverCandidate>();
|
||||
if (labelAll) toLabel.AddRange(byArea);
|
||||
else
|
||||
{
|
||||
foreach (var c in byArea) if (c.IsSea) toLabel.Add(c);
|
||||
foreach (var c in byArea) if (!c.IsSea) { toLabel.Add(c); break; }
|
||||
}
|
||||
|
||||
int ls = n >= 4096 ? 4 : 3;
|
||||
int lineH = TinyFont.Height(ls);
|
||||
var placed = new List<Rect2I>();
|
||||
// Reserve the legend block so a river label never lands under the header text.
|
||||
placed.Add(new Rect2I(0, 0, n, 12 + (TinyFont.Height(ls) + 6) * 7));
|
||||
int dropped = 0;
|
||||
foreach (var c in toLabel)
|
||||
{
|
||||
string txt = $"{DrainageLabel(c.DrainagePx)} R{c.Rank}";
|
||||
int w = TinyFont.Width(txt, ls), h = lineH;
|
||||
int pad = 4 * (ls >= 4 ? 2 : 1);
|
||||
int gap = mark + 10;
|
||||
// right, left, below, above, then pushed further out — first clear slot wins.
|
||||
var tries = new (int x, int y)[]
|
||||
{
|
||||
(c.TermX + gap, c.TermY - h / 2),
|
||||
(c.TermX - gap - w, c.TermY - h / 2),
|
||||
(c.TermX - w / 2, c.TermY + gap),
|
||||
(c.TermX - w / 2, c.TermY - gap - h),
|
||||
(c.TermX + gap * 2 + w / 2, c.TermY - h / 2),
|
||||
(c.TermX - gap * 2 - w - w / 2, c.TermY - h / 2),
|
||||
(c.TermX - w / 2, c.TermY + gap * 2 + h),
|
||||
(c.TermX - w / 2, c.TermY - gap * 2 - h * 2),
|
||||
};
|
||||
bool ok = false;
|
||||
foreach (var (tx, ty) in tries)
|
||||
{
|
||||
int bx = Math.Clamp(tx - pad, 0, Math.Max(0, n - (w + pad * 2)));
|
||||
int by = Math.Clamp(ty - pad, 0, Math.Max(0, n - (h + pad * 2)));
|
||||
var box = new Rect2I(bx, by, w + pad * 2, h + pad * 2);
|
||||
bool hit = false;
|
||||
foreach (var q in placed) if (box.Intersects(q)) { hit = true; break; }
|
||||
if (hit) continue;
|
||||
FillRect(img, box, new Color(0.04f, 0.05f, 0.07f), n);
|
||||
TinyFont.Draw(img, txt, bx + pad, by + pad, ls, c.IsSea ? Trunk : Giant);
|
||||
placed.Add(box);
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
if (!ok) dropped++;
|
||||
}
|
||||
|
||||
int s = n >= 4096 ? 4 : 3; int lh = TinyFont.Height(s) + 6;
|
||||
int nSea = 0; long seaPx = 0, endoPx = 0;
|
||||
int atFloor = 0;
|
||||
foreach (var c in byArea)
|
||||
{
|
||||
if (c.IsSea) { nSea++; seaPx += c.DrainagePx; } else endoPx += c.DrainagePx;
|
||||
if (StemWidthAtFloor(c.DrainagePx)) atFloor++;
|
||||
}
|
||||
TinyFont.Draw(img, title, 12, 12, s, Ink);
|
||||
TinyFont.Draw(img, compositionLine, 12, 12 + lh, s, Ink);
|
||||
TinyFont.Draw(img, $"CYAN SQUARE: SEA OUTLET ({nSea}, {seaPx:N0} PX) ORANGE DISC: ENDORHEIC TERMINUS ({byArea.Count - nSea}, {endoPx:N0} PX)", 12, 12 + lh * 2, s, Ink);
|
||||
TinyFont.Draw(img, $"FIXED SHARED WIDTH SCALE: {StemWidthLaw()} - THE SAME CONSTANT ON EVERY PLATE AND EVERY SEED, NEVER PER-PLATE", 12, 12 + lh * 3, s, Ink);
|
||||
TinyFont.Draw(img, $"SO A THIN RIVER IS THIN BECAUSE IT IS SMALLER" + (atFloor > 0 ? $" - {atFloor} RIVER(S) AT THE {StemWidthMinPx} PX LEGIBILITY FLOOR: NO THINNER THAN DRAWN" : ""), 12, 12 + lh * 4, s, Ink);
|
||||
TinyFont.Draw(img, $"LABEL: DRAINAGE AREA THEN R = RANK AMONG ALL {candidateCount} CANDIDATES ABOVE THE {floorPx:N0} PX FLOOR" + (dropped > 0 ? $" ({dropped} LABEL(S) DROPPED, NO CLEAR SPACE)" : ""), 12, 12 + lh * 5, s, Ink);
|
||||
TinyFont.Draw(img, "REAL UPLAND STEMS ONLY - GIANT.PROVISIONALROUTE (THE COMB) NOT DRAWN - NO ROUTING, NO WATER, NOTHING CARVED", 12, 12 + lh * 6, s, Ink);
|
||||
return img;
|
||||
}
|
||||
|
||||
private static void FillRect(Image img, Rect2I r, Color c, int n)
|
||||
{
|
||||
for (int x = r.Position.X; x < r.Position.X + r.Size.X; x++)
|
||||
for (int y = r.Position.Y; y < r.Position.Y + r.Size.Y; y++)
|
||||
if (x >= 0 && y >= 0 && x < n && y < n) img.SetPixel(x, y, c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ THE DISTRIBUTION PLOT — drainage area (log y) against rank (linear x), with the ladder
|
||||
/// counts marked vertically and the analysis's own thresholds marked horizontally.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,32 @@ namespace IslaApocalypse.Tools
|
|||
/// ISLA_PROMOTE_FLOOR_PX significance floor for the DISTRIBUTION (default 5000)
|
||||
/// ISLA_PROMOTE_N_LADDER the A/B counts (default 8,12,16)
|
||||
/// ISLA_PROMOTE_MAX cap raised on the analysis so stems exist (default 24)
|
||||
///
|
||||
/// ═══ ⭐⭐ THE COMPOSITION MODE (rivers/02b) — ISLA_PROMOTE_MODE=composition ═══
|
||||
///
|
||||
/// rivers/02 (above) answered "how many?" and found the terrain has no natural count, and that its
|
||||
/// honest top-of-distribution is INLAND-DOMINANT — 6 of 8 seeds have zero sea-reaching rivers in
|
||||
/// their top 8. The developer wants to keep the total at a comfortable N but GUARANTEE coastal
|
||||
/// presence for gameplay (the southern shipwreck, boats, coastal freshwater).
|
||||
///
|
||||
/// > ### That is a CONSCIOUS, MOTIVATED OVERRIDE of the pure unified ranking, not a correction of it.
|
||||
/// > The unified ranking stays the mechanism. A **gameplay-motivated sea-river floor of K** is
|
||||
/// > layered on top: the K largest SEA-REACHING candidates are forced in, and N−K inland ones fill
|
||||
/// > the rest. It deliberately reopens the "no sea quota" call from rivers/02 — now that the honest
|
||||
/// > distribution has been seen, and for a stated reason that is about the game, not the terrain.
|
||||
///
|
||||
/// This mode renders the two compositions side by side at the same total so the developer can judge
|
||||
/// the one thing that decides it: **do the forced sea rivers read as real rivers, just smaller — or
|
||||
/// as sad thin threads beside the big inland ones?**
|
||||
///
|
||||
/// ISLA_PROMOTE_MODE=composition pure-vs-quota instead of the count ladder
|
||||
/// ISLA_PROMOTE_N the fixed total (default 12)
|
||||
/// ISLA_PROMOTE_QUOTA_K the sea-river floor (default 3)
|
||||
///
|
||||
/// ⚠ It re-runs NOTHING it does not need: the 8-seed distribution sweep, `candidates_all` and
|
||||
/// `distribution` are unchanged by a re-selection and are NOT regenerated. `DrainageAnalysis` is
|
||||
/// again reused untouched — the quota is a re-selection over the candidates rivers/02 already
|
||||
/// enumerated, not a new analysis. **And it locks nothing: no N, no K, no default.**
|
||||
/// </summary>
|
||||
public partial class RiverPromotionTool : Node
|
||||
{
|
||||
|
|
@ -110,6 +136,35 @@ namespace IslaApocalypse.Tools
|
|||
public int BreakRankAbs; public long BreakAbs; // the largest absolute gap
|
||||
public Dictionary<int, (int sea, int endo, long areaAtN, bool enough)> AtN = new();
|
||||
public ulong Ms;
|
||||
/// <summary>rivers/02b only — the two compositions at the fixed total. Null in ladder mode.</summary>
|
||||
public Composition Comp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ THE TWO COMPOSITIONS AT ONE FIXED TOTAL (rivers/02b) — same N, different mix.
|
||||
///
|
||||
/// PURE the top N candidates by drainage area, terminus irrelevant. What rivers/02 produces.
|
||||
/// QUOTA the K largest SEA-REACHING candidates forced in, plus the N−K largest endorheic.
|
||||
///
|
||||
/// ⚠ Both are selections over the SAME unified ranking, computed by the SAME metric. The quota
|
||||
/// does not re-rank anything and does not touch the analysis — it re-picks from the list.
|
||||
/// </summary>
|
||||
private sealed class Composition
|
||||
{
|
||||
public int N, K;
|
||||
public List<RiverCandidate> Pure = new();
|
||||
public List<RiverCandidate> Quota = new();
|
||||
/// <summary>The K (or fewer) forced sea rivers, descending.</summary>
|
||||
public List<RiverCandidate> ForcedSea = new();
|
||||
/// <summary>The N−K (or fewer) inland rivers kept beside them, descending.</summary>
|
||||
public List<RiverCandidate> KeptInland = new();
|
||||
/// <summary>In PURE but not in QUOTA — the inland basins the floor displaced to make room.</summary>
|
||||
public List<RiverCandidate> Displaced = new();
|
||||
/// <summary>In QUOTA but not in PURE — the sea rivers the floor promoted.</summary>
|
||||
public List<RiverCandidate> Added = new();
|
||||
public int SeaAvailable, EndoAvailable;
|
||||
/// <summary>⚠ Fewer than K sea / N−K inland candidates existed above the floor. Should not fire.</summary>
|
||||
public bool SeaShort, EndoShort;
|
||||
}
|
||||
|
||||
private void Run()
|
||||
|
|
@ -117,17 +172,44 @@ namespace IslaApocalypse.Tools
|
|||
ToolingPaths.Configure(OS.GetUserDataDir());
|
||||
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "rivers"));
|
||||
|
||||
// ⭐ rivers/02b: "composition" swaps the count ladder for one fixed total in two mixes.
|
||||
bool composition = EnvStr("ISLA_PROMOTE_MODE", "ladder").Trim().ToLowerInvariant() == "composition";
|
||||
int compN = EnvInt("ISLA_PROMOTE_N", 12);
|
||||
int compK = EnvInt("ISLA_PROMOTE_QUOTA_K", 3);
|
||||
|
||||
int task = EnvInt("ISLA_TASK", 2);
|
||||
string descr = EnvStr("ISLA_BATCH", "promotion");
|
||||
// ⭐ rivers/02b is a LETTERED SUB-TASK of 02 — same authoring task, one changed choice — so it
|
||||
// writes to `02b_composition`, not to a fresh task number that would claim to be task 03.
|
||||
string taskSfx = EnvStr("ISLA_TASK_SUFFIX", composition ? "b" : "");
|
||||
string descr = EnvStr("ISLA_BATCH", composition ? "composition" : "promotion");
|
||||
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
|
||||
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
|
||||
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
|
||||
// ⚠ In composition mode the DEFAULT seed set is the 4 RENDER seeds, not the 8 gallery seeds:
|
||||
// the 8-seed distribution sweep is unchanged by a re-selection and re-running it would be
|
||||
// ~2x the work for byte-identical numbers. rivers/02's sweep stands as the distribution of
|
||||
// record. (Override with ISLA_SEEDS if that is ever wanted.)
|
||||
int[] seeds = EnvSeeds("ISLA_SEEDS", composition ? DefaultRenderSeeds : GallerySeeds);
|
||||
int[] renderSe = EnvSeeds("ISLA_RENDER_SEEDS", DefaultRenderSeeds);
|
||||
long floorPx = EnvInt("ISLA_PROMOTE_FLOOR_PX", 5000);
|
||||
int[] ladder = EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
|
||||
int[] ladder = composition ? new[] { compN } : EnvSeeds("ISLA_PROMOTE_N_LADDER", new[] { 8, 12, 16 });
|
||||
int promoteMax = EnvInt("ISLA_PROMOTE_MAX", 24);
|
||||
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
|
||||
|
||||
if (composition)
|
||||
{
|
||||
if (compK < 0 || compK > compN)
|
||||
throw new InvalidOperationException(
|
||||
$"ISLA_PROMOTE_QUOTA_K ({compK}) must be between 0 and ISLA_PROMOTE_N ({compN}) — the quota is a FLOOR " +
|
||||
"inside a fixed total, not a second budget on top of it.");
|
||||
// The forced sea rivers come from Plan.Trunks and the kept inland from Plan.Giants, both
|
||||
// truncated at the raised cap — so the cap must cover BOTH halves of the quota as well
|
||||
// as a pure top-N that happens to be all inland. Refuse rather than draw a bare marker.
|
||||
if (promoteMax < compK || promoteMax < compN - compK)
|
||||
throw new InvalidOperationException(
|
||||
$"ISLA_PROMOTE_MAX ({promoteMax}) is below the quota's halves (K={compK} sea, N-K={compN - compK} inland). The cap is " +
|
||||
"what makes the analysis trace a real upland stem for each; below it, a quota'd river would have no course to draw.");
|
||||
}
|
||||
|
||||
int maxLadder = 0; foreach (int v in ladder) if (v > maxLadder) maxLadder = v;
|
||||
if (promoteMax < maxLadder)
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -140,7 +222,7 @@ namespace IslaApocalypse.Tools
|
|||
TerrainShapeV1.Assert("RiverPromotion");
|
||||
TerrainShapeV1.AssertErosionDefaultOn("RiverPromotion");
|
||||
|
||||
string batchRoot = ToolingPaths.BatchRoot(task, descr);
|
||||
string batchRoot = ToolingPaths.BatchRoot(task, taskSfx, descr);
|
||||
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
|
||||
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
|
||||
|
||||
|
|
@ -162,16 +244,31 @@ namespace IslaApocalypse.Tools
|
|||
var dpDefaults = new DrainageAnalysis.Params();
|
||||
|
||||
GD.Print("==================================================================");
|
||||
GD.Print(" RIVER PROMOTION (rivers/02) — measure the candidate distribution, THEN show the count ladder");
|
||||
GD.Print(composition
|
||||
? $" RIVER COMPOSITION (rivers/02b) — N={compN} PURE vs N={compN} with a gameplay sea-river floor of K={compK}"
|
||||
: " RIVER PROMOTION (rivers/02) — measure the candidate distribution, THEN show the count ladder");
|
||||
GD.Print("==================================================================");
|
||||
GD.Print($"MapSize : {mapSize} curve calibrated at {calibSize}");
|
||||
GD.Print($"terrain : {TerrainShapeV1.Describe()} + erosion ON by default");
|
||||
GD.Print($"seeds : distribution {seeds.Length} — {string.Join(", ", seeds)}");
|
||||
GD.Print($" : rendered {renderSe.Length} — {string.Join(", ", renderSe)}");
|
||||
GD.Print($"ranking : UNIFIED — every major drainage by contributing-cell count, both termini in ONE list.");
|
||||
if (composition)
|
||||
{
|
||||
GD.Print($" ⭐ and then, ON PURPOSE, a GAMEPLAY SEA-RIVER FLOOR of K={compK} layered over it:");
|
||||
GD.Print($" PURE = top {compN} by drainage, terminus irrelevant (the mechanism, unaltered)");
|
||||
GD.Print($" QUOTA = the {compK} largest SEA-REACHING forced + the {compN - compK} largest endorheic ({compK} sea + {compN - compK} inland = {compN})");
|
||||
GD.Print($" The floor is motivated by GAMEPLAY (shipwreck / boats / coastal freshwater), not by the terrain,");
|
||||
GD.Print($" and it deliberately reopens rivers/02's \"no sea quota\" call now the honest distribution is known.");
|
||||
GD.Print($" width : ONE fixed constant, shared across both plates and all seeds — {DrainageRenderer.StemWidthLaw()}");
|
||||
}
|
||||
else
|
||||
GD.Print($" the sea/endorheic split FALLS OUT; it is never quota'd. (departs from the reference's two lists)");
|
||||
GD.Print($"floor : {floorPx:N0} px (diagnostic significance floor — NOT the promotion threshold)");
|
||||
if (!composition)
|
||||
GD.Print($"ladder : N = {string.Join(", ", ladder)} caps raised to {promoteMax} so every promotable river has a traced stem");
|
||||
else
|
||||
GD.Print($"fixed N : {compN} caps raised to {promoteMax} so every promotable river has a traced stem");
|
||||
GD.Print($"UNCHANGED : EndorheicMinDepthM {dpDefaults.EndorheicMinDepthM} m · EndorheicMinAreaPx {dpDefaults.EndorheicMinAreaPx:N0} · MinOutletSeparationPx {dpDefaults.MinOutletSeparationPx} · StemMinAccPx {dpDefaults.StemMinAccPx}");
|
||||
// ⚠⚠ THE PARAMS ARE ABSOLUTE PIXEL COUNTS, SO THIS ANALYSIS IS SCALE-DEPENDENT.
|
||||
// Measured at rivers/02: at 1024 a 10,000-cell basin is ~1 % of the map and NOTHING qualifies as
|
||||
|
|
@ -227,20 +324,42 @@ namespace IslaApocalypse.Tools
|
|||
var r = Enumerate(plan, p2.Height, mapSize, floorPx, dpDefaults.MinOutletSeparationPx, seed, p1.Regions);
|
||||
r.Ms = Time.GetTicksMsec() - t0;
|
||||
Analyse(r, ladder);
|
||||
BindCourses(r, plan, mapSize, maxLadder);
|
||||
|
||||
if (composition)
|
||||
{
|
||||
// ⚠ Bind the UNION of both compositions, not the top N: a forced sea river can sit far
|
||||
// down the unified ranking (measured at rivers/02: rank 29 on the primary seed), so
|
||||
// "top maxLadder" would leave it with no traced stem.
|
||||
r.Comp = Compose(r, compN, compK);
|
||||
var need = new List<RiverCandidate>(r.Comp.Pure);
|
||||
foreach (var c in r.Comp.Quota) if (!need.Contains(c)) need.Add(c);
|
||||
BindCourses(r, plan, mapSize, need, $"the union of the N={compN} pure and K={compK} quota compositions");
|
||||
ReportComposition(r);
|
||||
WriteCompositionCsv(batchRoot, r);
|
||||
if (renderSet.Contains(seed))
|
||||
RenderComposition(batchRoot, r, isOcean, p2, mapSize, sea, floorPx, skipRaw);
|
||||
}
|
||||
else
|
||||
{
|
||||
BindCourses(r, plan, mapSize, r.Ranked.GetRange(0, Math.Min(maxLadder, r.Ranked.Count)), $"the top {maxLadder} candidates");
|
||||
Report(r, ladder);
|
||||
WriteCsv(batchRoot, r);
|
||||
|
||||
if (renderSet.Contains(seed))
|
||||
RenderSeed(batchRoot, r, plan, isOcean, p2, mapSize, sea, ladder, floorPx, dpDefaults, skipRaw);
|
||||
}
|
||||
|
||||
results.Add(r);
|
||||
}
|
||||
|
||||
if (composition)
|
||||
WriteCompositionIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, compN, compK, floorPx, promoteMax, dpDefaults, skipRaw);
|
||||
else
|
||||
WriteIndex(batchRoot, mapSize, calibSize, seeds, renderSe, results, ladder, floorPx, promoteMax, dpDefaults, skipRaw);
|
||||
GD.Print("\n==================================================================");
|
||||
GD.Print($" DONE — {batchRoot}");
|
||||
GD.Print(" ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
|
||||
GD.Print(composition
|
||||
? $" ⛔ TASTE GATE: pure and quota K={compK} are PRESENTED, not decided. No N, no K, no default was set."
|
||||
: " ⛔ TASTE GATE: 8 / 12 / 16 are PRESENTED, not decided. The developer picks N.");
|
||||
GD.Print("==================================================================");
|
||||
GetTree().Quit(0);
|
||||
}
|
||||
|
|
@ -432,7 +551,7 @@ namespace IslaApocalypse.Tools
|
|||
/// BASIN ID — not by terminal coordinates, because a flat basin floor can have several cells
|
||||
/// at the minimum height and the analysis's DFS tie-break need not match a row-major scan.
|
||||
/// </summary>
|
||||
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, int maxLadder)
|
||||
private static void BindCourses(SeedResult r, DrainageAnalysis.Plan plan, int n, List<RiverCandidate> need, string what)
|
||||
{
|
||||
var byOutlet = new Dictionary<int, DrainageAnalysis.Trunk>();
|
||||
foreach (var t in plan.Trunks) byOutlet[(int)t.Outlet.x * n + (int)t.Outlet.y] = t;
|
||||
|
|
@ -445,9 +564,9 @@ namespace IslaApocalypse.Tools
|
|||
}
|
||||
|
||||
int missing = 0;
|
||||
for (int i = 0; i < Math.Min(maxLadder, r.Ranked.Count); i++)
|
||||
foreach (var c in need)
|
||||
{
|
||||
var c = r.Ranked[i];
|
||||
if (c.Course != null) continue;
|
||||
if (c.IsSea)
|
||||
{
|
||||
if (byOutlet.TryGetValue(c.Cell, out var t)) { c.Course = t.Course; c.TermX = (int)t.Outlet.x; c.TermY = (int)t.Outlet.y; }
|
||||
|
|
@ -463,11 +582,166 @@ namespace IslaApocalypse.Tools
|
|||
}
|
||||
if (missing > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"[RiverPromotion] {missing} of the top {maxLadder} candidates have no traced stem. The analysis's " +
|
||||
"reporting caps are what produce the courses, so they must be at least the largest ladder count — " +
|
||||
$"[RiverPromotion] {missing} of {need.Count} candidates in {what} have no traced stem. The analysis's " +
|
||||
"reporting caps are what produce the courses, so they must cover every candidate being drawn — " +
|
||||
"raise ISLA_PROMOTE_MAX. Refusing to render a plate with rivers drawn as bare markers.");
|
||||
}
|
||||
|
||||
// ═══ ⭐⭐ THE COMPOSITION (rivers/02b) — the same N, two mixes ════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// ⭐⭐ Build both compositions at the fixed total N.
|
||||
///
|
||||
/// PURE <c>Ranked[0..N)</c> — the unified ranking, untouched. Terminus irrelevant.
|
||||
/// QUOTA the K largest SEA-REACHING candidates + the N−K largest ENDORHEIC ones.
|
||||
///
|
||||
/// The ranking is already descending, so both halves of the quota are just the first K / N−K of
|
||||
/// each terminus class. Fully deterministic — no tie-break, no randomness, no re-ranking. **The
|
||||
/// metric is the same on both sides and the analysis is not consulted again: this is a
|
||||
/// re-selection over candidates that were already enumerated.**
|
||||
///
|
||||
/// ⚠ Shortfall is handled rather than crashed: if fewer than K sea (or N−K inland) candidates
|
||||
/// exist above the floor, ALL available are taken and the plate carries fewer than N rivers,
|
||||
/// flagged into the INDEX. It is NOT back-filled from the other class — that would silently
|
||||
/// turn "the terrain cannot supply K sea rivers" into a plate that looks like it can. rivers/02
|
||||
/// measured 64–78 sea and 45–61 endorheic candidates per seed, so this should not fire.
|
||||
/// </summary>
|
||||
private static Composition Compose(SeedResult r, int N, int K)
|
||||
{
|
||||
var v = r.Ranked;
|
||||
var comp = new Composition { N = N, K = K };
|
||||
comp.Pure = v.GetRange(0, Math.Min(N, v.Count));
|
||||
|
||||
var sea = new List<RiverCandidate>();
|
||||
var endo = new List<RiverCandidate>();
|
||||
foreach (var c in v) { if (c.IsSea) sea.Add(c); else endo.Add(c); }
|
||||
comp.SeaAvailable = sea.Count;
|
||||
comp.EndoAvailable = endo.Count;
|
||||
comp.SeaShort = sea.Count < K;
|
||||
comp.EndoShort = endo.Count < N - K;
|
||||
|
||||
for (int i = 0; i < Math.Min(K, sea.Count); i++) comp.ForcedSea.Add(sea[i]);
|
||||
for (int i = 0; i < Math.Min(N - K, endo.Count); i++) comp.KeptInland.Add(endo[i]);
|
||||
comp.Quota.AddRange(comp.ForcedSea);
|
||||
comp.Quota.AddRange(comp.KeptInland);
|
||||
comp.Quota.Sort((a, b) => b.DrainagePx.CompareTo(a.DrainagePx));
|
||||
|
||||
foreach (var c in comp.Pure) if (!comp.Quota.Contains(c)) comp.Displaced.Add(c);
|
||||
foreach (var c in comp.Quota) if (!comp.Pure.Contains(c)) comp.Added.Add(c);
|
||||
return comp;
|
||||
}
|
||||
|
||||
/// <summary>The largest / smallest inland river the quota keeps — the yardsticks for "thread".</summary>
|
||||
private static long LargestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[0].DrainagePx : 0;
|
||||
private static long SmallestInland(Composition c) => c.KeptInland.Count > 0 ? c.KeptInland[c.KeptInland.Count - 1].DrainagePx : 0;
|
||||
|
||||
private static string Compact(List<RiverCandidate> v)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var c in v) parts.Add($"#{c.Rank}{(c.IsSea ? "S" : "E")} {c.DrainagePx:N0}");
|
||||
return parts.Count == 0 ? "(none)" : string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static string Ordinal(int zeroBased)
|
||||
{
|
||||
int i = zeroBased + 1;
|
||||
if (i == 1) return "largest";
|
||||
string suffix = (i % 100 is >= 11 and <= 13) ? "th" : (i % 10) switch { 1 => "st", 2 => "nd", 3 => "rd", _ => "th" };
|
||||
return $"{i}{suffix}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coarse compass label for an outlet, from its offset relative to the map centre. ⚠ In this
|
||||
/// codebase a cell is <c>x*n + y</c> and the image is drawn <c>SetPixel(x, y)</c>, so +y is
|
||||
/// SOUTH on the plate — the sign is easy to get backwards and the label would then be a lie.
|
||||
/// </summary>
|
||||
private static string Compass(int x, int y, int n)
|
||||
{
|
||||
float half = n / 2f, dx = (x - half) / half, dy = (y - half) / half; // dy > 0 = south
|
||||
const float band = 0.35f; // "central" deadband
|
||||
string ns = dy < -band ? "N" : dy > band ? "S" : "";
|
||||
string ew = dx < -band ? "W" : dx > band ? "E" : "";
|
||||
return ns + ew == "" ? "centre" : ns + ew;
|
||||
}
|
||||
|
||||
private static void ReportComposition(SeedResult r)
|
||||
{
|
||||
var c = r.Comp;
|
||||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||||
GD.Print($" candidates above the floor: {r.Ranked.Count} ({c.SeaAvailable} sea / {c.EndoAvailable} endorheic)");
|
||||
GD.Print($" PURE N={c.N} sea {pureSea} / inland {c.Pure.Count - pureSea}");
|
||||
GD.Print($" {Compact(c.Pure)}");
|
||||
GD.Print($" QUOTA N={c.N} K={c.K} sea {c.ForcedSea.Count} / inland {c.KeptInland.Count}");
|
||||
GD.Print($" {Compact(c.Quota)}");
|
||||
if (c.SeaShort) GD.PrintErr($" ⚠⚠ ONLY {c.SeaAvailable} SEA CANDIDATES EXIST above the floor — fewer than K={c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
|
||||
if (c.EndoShort) GD.PrintErr($" ⚠⚠ ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES EXIST above the floor — fewer than N-K={c.N - c.K}. All available taken; the quota plate carries {c.Quota.Count} rivers, not {c.N}.");
|
||||
long lo = SmallestInland(c), hi = LargestInland(c);
|
||||
GD.Print($" ⭐ the thread question, quantified — inland yardsticks: smallest kept {lo:N0} px (the {c.KeptInland.Count}th), largest kept {hi:N0} px");
|
||||
foreach (var f in c.ForcedSea)
|
||||
GD.Print($" forced sea #{f.Rank,-4} {f.DrainagePx,10:N0} px {(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}x the smallest kept inland " +
|
||||
$"{(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}x the largest stem {DrainageRenderer.StemWidthFixed(f.DrainagePx)} px wide" +
|
||||
(DrainageRenderer.StemWidthAtFloor(f.DrainagePx) ? " (AT THE LEGIBILITY FLOOR)" : ""));
|
||||
GD.Print($" displaced by the floor (in PURE, not in QUOTA): {Compact(c.Displaced)}");
|
||||
GD.Print($" promoted by the floor (in QUOTA, not in PURE): {Compact(c.Added)}");
|
||||
}
|
||||
|
||||
private static void WriteCompositionCsv(string batchRoot, SeedResult r)
|
||||
{
|
||||
var c = r.Comp;
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("composition,slot,terminus,drainage_px,unified_rank,forced,river_term_x,river_term_y,basin_id,stem_width_px,in_other_composition");
|
||||
void Rows(string name, List<RiverCandidate> v, List<RiverCandidate> other)
|
||||
{
|
||||
for (int i = 0; i < v.Count; i++)
|
||||
{
|
||||
var x = v[i];
|
||||
bool forced = name == "quota" && x.IsSea;
|
||||
sb.AppendLine($"{name},{i + 1},{x.TerminusName},{x.DrainagePx},{x.Rank},{(forced ? "yes" : "no")},{x.TermX},{x.TermY}," +
|
||||
$"{(x.IsSea ? "" : x.BasinId.ToString())},{DrainageRenderer.StemWidthFixed(x.DrainagePx)},{(other.Contains(x) ? "yes" : "no")}");
|
||||
}
|
||||
}
|
||||
Rows("pure", c.Pure, c.Quota);
|
||||
Rows("quota", c.Quota, c.Pure);
|
||||
WriteText(Path.Combine(batchRoot, $"composition_{r.Seed}.csv"), sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two plates, on ONE shared faint-terrain base, at the ONE shared width scale.
|
||||
///
|
||||
/// ⚠ `candidates_all` and `distribution` are NOT re-rendered: a re-selection does not change the
|
||||
/// candidate set or its distribution, and rivers/02's plates are the ones of record.
|
||||
/// </summary>
|
||||
private static void RenderComposition(string batchRoot, SeedResult r, bool[] isOcean,
|
||||
Pass2Result p2, int n, float sea, long floorPx, bool skipRaw)
|
||||
{
|
||||
var c = r.Comp;
|
||||
string dir = Path.Combine(batchRoot, $"{r.Seed}");
|
||||
DirAccess.MakeDirRecursiveAbsolute(dir);
|
||||
|
||||
// One base, duplicated — it is 1 SetPixel per cell (67 M at 8192) and both plates share it.
|
||||
Image baseImg = DrainageRenderer.TerrainBase(isOcean, p2.Height, n, sea, p2.HMax);
|
||||
|
||||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||||
DrainageRenderer.RiverComposition(c.Pure, baseImg.Duplicate() as Image, n,
|
||||
$"SEED {r.Seed} - N{c.N} PURE UNIFIED RANKING",
|
||||
$"PURE: THE TOP {c.N} BY DRAINAGE AREA, TERMINUS IRRELEVANT - {pureSea} SEA / {c.Pure.Count - pureSea} INLAND, A SPLIT THAT FELL OUT AND WAS NEVER QUOTA'D",
|
||||
r.Ranked.Count, floorPx, true)
|
||||
.SavePng(Path.Combine(dir, $"N{c.N}_pure.png"));
|
||||
|
||||
long lo = SmallestInland(c);
|
||||
DrainageRenderer.RiverComposition(c.Quota, baseImg.Duplicate() as Image, n,
|
||||
$"SEED {r.Seed} - N{c.N} WITH A GAMEPLAY SEA-RIVER FLOOR OF K={c.K}",
|
||||
$"QUOTA: THE {c.ForcedSea.Count} LARGEST SEA-REACHING FORCED IN + THE {c.KeptInland.Count} LARGEST INLAND - SAME TOTAL, DIFFERENT MIX. SMALLEST KEPT INLAND {lo:N0} PX",
|
||||
r.Ranked.Count, floorPx, true)
|
||||
.SavePng(Path.Combine(dir, $"N{c.N}_quota_K{c.K}.png"));
|
||||
|
||||
// Grayscale beside the pretty plates — the house rule: the field must be inspectable
|
||||
// without the palette in the way. Ranges printed so the plate is readable as data.
|
||||
var (gmin, gmax) = GrayscaleRenderer.SavePng(p2.Height, n, Path.Combine(dir, "grayscale.png"));
|
||||
GD.Print($" grayscale: render field range {gmin:F4} .. {gmax:F4} raw = {WorldScale.MetresFromRaw(gmin):F1} .. {WorldScale.MetresFromRaw(gmax):F1} m");
|
||||
if (!skipRaw) HeightField.Save(p2.Height, n, Path.Combine(dir, "height.f32"));
|
||||
}
|
||||
|
||||
// ═══ OUTPUT ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private static void Report(SeedResult r, int[] ladder)
|
||||
|
|
@ -696,6 +970,198 @@ namespace IslaApocalypse.Tools
|
|||
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⭐ THE COMPOSITION INDEX (rivers/02b) — it foregrounds the ONE question the developer stated,
|
||||
/// and puts the numbers that predict the answer above the plates that confirm it.
|
||||
/// </summary>
|
||||
private static void WriteCompositionIndex(string batchRoot, int mapSize, int calibSize, int[] seeds, int[] renderSe,
|
||||
List<SeedResult> rows, int compN, int compK, long floorPx, int promoteMax, DrainageAnalysis.Params def, bool skipRaw)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int primary = renderSe.Length > 0 ? renderSe[0] : seeds[0];
|
||||
string pure = $"N{compN}_pure.png", quota = $"N{compN}_quota_K{compK}.png";
|
||||
|
||||
sb.AppendLine($"# Batch 02b — composition: N={compN} pure ranking vs N={compN} with a gameplay sea-river floor of K={compK}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**⛔ THIS IS A TASTE GATE. Nothing is locked — no count, no K, no default in `TerrainGenConfig` or");
|
||||
sb.AppendLine("`DrainageAnalysis.Params`.** Two compositions of the same total are presented; the developer picks.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## 👉 The pick");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Open these two **side by side** for seed `{primary}`:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **`{primary}/{pure}`** — the top {compN} by drainage area, terminus irrelevant. rivers/02's mechanism, unaltered.");
|
||||
sb.AppendLine($"- **`{primary}/{quota}`** — the same {compN}, re-mixed: the **{compK} largest sea-reaching forced in**, plus the {compN - compK} largest inland.");
|
||||
sb.AppendLine();
|
||||
var others = new List<string>();
|
||||
foreach (int x in renderSe) if (x != primary) others.Add($"`{x}`");
|
||||
sb.AppendLine($"Then confirm the read generalizes on the other {others.Count}: " + string.Join(", ", others) + ".");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### ⭐⭐ THE JUDGMENT, STATED");
|
||||
sb.AppendLine($"> **Do the {compK} forced sea rivers hold up as REAL rivers — just smaller — or do they read as sad thin");
|
||||
sb.AppendLine("> threads beside the big inland ones?**");
|
||||
sb.AppendLine(">");
|
||||
sb.AppendLine($"> - **Real → the quota is in at K={compK}.** Graduate as *\"unified ranking + a gameplay-motivated sea-river floor of K\"*.");
|
||||
sb.AppendLine($"> - **Thin → drop to K=2** (a one-value re-run: `ISLA_PROMOTE_QUOTA_K=2`) **or accept inland-dominant** and keep the pure ranking.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Why this override exists, and what it costs");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("rivers/02 established two things. The count is a **design choice** — the drainage-area distribution is a");
|
||||
sb.AppendLine("power law with no natural break (the knee wanders rank 3→19 at ~1.4×). And this terrain's honest");
|
||||
sb.AppendLine($"top-of-distribution is **inland-dominant**: 6 of 8 seeds have zero sea-reaching rivers in their top 8.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"The floor of K={compK} is a **conscious, motivated override of the pure ranking**, for gameplay — the southern");
|
||||
sb.AppendLine("shipwreck, boats, coastal freshwater. **The unified ranking stays the mechanism**; the quota is layered");
|
||||
sb.AppendLine("over it, and it deliberately reopens rivers/02's \"no sea quota\" call *on purpose*, now that the honest");
|
||||
sb.AppendLine("distribution has been seen. It is not a correction of that call — it is a decision to overrule it for a");
|
||||
sb.AppendLine("reason that is about the game rather than the terrain.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**What it costs is visible and bounded:** the displaced-basins column below names exactly which inland");
|
||||
sb.AppendLine("drainages step aside, and the ratio columns say how much smaller the rivers taking their place are.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## ⭐⭐ The thread question, quantified — read this BEFORE opening the plates");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The ratio of a forced sea river to the inland rivers it sits beside predicts \"thread\" before the eye");
|
||||
sb.AppendLine($"is involved. Yardsticks: the **smallest inland kept** (the {compN - compK}th, the weakest thing the quota did NOT displace) and");
|
||||
sb.AppendLine("the **largest inland** on the plate. A forced river near 1.0× the smallest is simply another river of the");
|
||||
sb.AppendLine("set; one near 0.1× the largest is a thread next to it whatever the plate looks like.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Seed | forced sea river | drainage px | unified rank | ÷ smallest inland kept | ÷ largest inland | stem px | ⚠ where it reaches the sea |");
|
||||
sb.AppendLine("|---|---|---|---|---|---|---|---|");
|
||||
int atFloorRows = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var c = r.Comp;
|
||||
long lo = SmallestInland(c), hi = LargestInland(c);
|
||||
for (int i = 0; i < c.ForcedSea.Count; i++)
|
||||
{
|
||||
var f = c.ForcedSea[i];
|
||||
string bold = i == c.ForcedSea.Count - 1 ? "**" : "";
|
||||
bool floor = DrainageRenderer.StemWidthAtFloor(f.DrainagePx);
|
||||
if (floor) atFloorRows++;
|
||||
sb.AppendLine($"| `{r.Seed}` | {Ordinal(i)} | {bold}{f.DrainagePx:N0}{bold} | #{f.Rank} | " +
|
||||
$"{bold}{(lo > 0 ? f.DrainagePx / (double)lo : 0):F3}×{bold} | {(hi > 0 ? f.DrainagePx / (double)hi : 0):F3}× | " +
|
||||
$"{DrainageRenderer.StemWidthFixed(f.DrainagePx)}{(floor ? " ⚠floor" : "")} | {Compass(f.TermX, f.TermY, mapSize)} ({f.TermX},{f.TermY}) |");
|
||||
}
|
||||
sb.AppendLine($"| `{r.Seed}` | *(yardsticks)* | smallest inland kept **{lo:N0}** · largest inland **{hi:N0}** | | | | | |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### ⚠⚠ THE FLOOR GUARANTEES SEA PRESENCE, NOT *WHERE* — read the last column.");
|
||||
sb.AppendLine("> \"The K largest sea-reaching\" has **no spatial term in it**. The gameplay motive names the **southern**");
|
||||
sb.AppendLine("> shipwreck specifically, and nothing in this rule promises a river anywhere near it: the K outlets can");
|
||||
sb.AppendLine("> all land on one coast. Measured above per seed — check the coast spread before reading the count as");
|
||||
sb.AppendLine("> \"coastal presence solved\". If the shipwreck needs a river, that is a placement constraint and a");
|
||||
sb.AppendLine("> separate decision from K. *(Not designed in here: this gate was scoped to pure-vs-quota only.)*");
|
||||
sb.AppendLine();
|
||||
if (atFloorRows > 0)
|
||||
{
|
||||
sb.AppendLine($"> ⚠ `⚠floor` marks a stem drawn at the **{DrainageRenderer.StemWidthMinPx} px legibility floor** — a 1 px line at {mapSize} is invisible at any");
|
||||
sb.AppendLine("> zoom a person actually reads a plate at, so such a river is **no thinner than drawn, possibly thinner**.");
|
||||
sb.AppendLine("> It is flagged because the floor sits exactly where the \"thread\" verdict lives.");
|
||||
}
|
||||
else
|
||||
sb.AppendLine($"> ✅ **No river on any plate hit the {DrainageRenderer.StemWidthMinPx} px legibility floor** (thinnest drawn stem is above it), so every width on");
|
||||
if (atFloorRows == 0)
|
||||
sb.AppendLine("> these plates is the fixed law's honest output — nothing was widened to stay visible.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## The composition delta — what the floor swapped");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("`#nS` / `#nE` is the candidate's rank in the **full** unified distribution (S = sea, E = endorheic).");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Seed | candidates (sea / endo) | PURE sea/inland | QUOTA sea/inland | ⭐ inland basins DISPLACED | sea rivers PROMOTED |");
|
||||
sb.AppendLine("|---|---|---|---|---|---|");
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var c = r.Comp;
|
||||
int pureSea = 0; foreach (var x in c.Pure) if (x.IsSea) pureSea++;
|
||||
sb.AppendLine($"| `{r.Seed}` | {r.Ranked.Count} ({c.SeaAvailable} / {c.EndoAvailable}) | {pureSea} / {c.Pure.Count - pureSea} | " +
|
||||
$"{c.ForcedSea.Count} / {c.KeptInland.Count} | {Compact(c.Displaced)} | {Compact(c.Added)} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var c = r.Comp;
|
||||
sb.AppendLine($"**`{r.Seed}`** — every river, both compositions:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- PURE : {Compact(c.Pure)}");
|
||||
sb.AppendLine($"- QUOTA: {Compact(c.Quota)}");
|
||||
if (c.SeaShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.SeaAvailable} SEA CANDIDATES exist above the floor — fewer than K={compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}. Not back-filled from the inland list: that would hide the fact that the terrain cannot supply K.");
|
||||
if (c.EndoShort) sb.AppendLine($"- ⚠⚠ **ONLY {c.EndoAvailable} ENDORHEIC CANDIDATES exist above the floor — fewer than N−K={compN - compK}.** All available were taken; this seed's quota plate carries {c.Quota.Count} rivers, not {compN}.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine("## ⭐ The fixed shared width scale — the one thing these plates must get right");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("> ### The question is \"is this river thin?\", so a plate that rescales itself cannot answer it.");
|
||||
sb.AppendLine($"> rivers/02's `promoted_N*.png` normalise stem width to the widest river **on their own plate**. Under");
|
||||
sb.AppendLine("> that law a forced sea river drawn thin would be reporting the plate's contents, not the river's size.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"So both plates, on all {renderSe.Length} seeds, use **one absolute constant**:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine($"stem width px = clamp( round( sqrt(drainage px) × {DrainageRenderer.StemWidthPerSqrtPx:F6} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
|
||||
sb.AppendLine($" = clamp( round( sqrt(drainage px) / {1f / DrainageRenderer.StemWidthPerSqrtPx:F0} ), {DrainageRenderer.StemWidthMinPx}, {DrainageRenderer.StemWidthMaxPx} )");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**The constant is `{DrainageRenderer.StemWidthPerSqrtPx:F6}` px of stem width per √(drainage px)** — never per-plate,");
|
||||
sb.AppendLine("never per-seed. Width ∝ √area, so the *visual weight* of a stem tracks its drainage rather than");
|
||||
sb.AppendLine("exaggerating it quadratically. It was chosen once from the measured population: the largest candidate");
|
||||
sb.AppendLine("on any of the eight gallery seeds is **4,474,342 px** (seed `17320508`), √ = 2,115, so the biggest");
|
||||
sb.AppendLine($"drainage the terrain produces draws at ~{(int)Math.Round(Math.Sqrt(4474342.0) * DrainageRenderer.StemWidthPerSqrtPx)} px — inside the {DrainageRenderer.StemWidthMaxPx} px ceiling, with nothing clipped anywhere.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Per-river labels read `<drainage> R<rank>` — e.g. `272K R29` beside `2.33M R1`. The rank is in the");
|
||||
sb.AppendLine("**full** candidate distribution, not in the plate, so it says how far down the list the floor reached.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## What was run, and what was NOT");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Chain + analysis re-run at **{mapSize}** on **{seeds.Length} seeds** (`{string.Join(", ", seeds)}`), all of them rendered.");
|
||||
sb.AppendLine($"Curve calibrated at {calibSize} on the family-off pinned pool; terrain {TerrainShapeV1.Describe()} + erosion ON, bare defaults (rivers/01).");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**⚠ NOT re-run, because a re-selection cannot change them:**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("- the **8-seed distribution sweep** — rivers/02's is the distribution of record;");
|
||||
sb.AppendLine("- **`candidates_all.png`** and **`distribution.png`** — same candidate set, same curve;");
|
||||
sb.AppendLine("- **`DrainageAnalysis`** — not rebuilt, not edited. The quota is a re-selection over candidates it had");
|
||||
sb.AppendLine(" already enumerated. The metric-comparability identity `Σ sea Acc + Σ BasinInflow + unrouted ==");
|
||||
sb.AppendLine(" LandCells` was re-asserted per seed anyway, and held.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Reporting caps raised** to `{promoteMax}` (`TrunkCount` / `GiantCount` / `EndorheicMaxCount`) purely so the analysis");
|
||||
sb.AppendLine("traces a real upland stem for every river on either plate. ⚠ The union is bound, not the top N: a forced");
|
||||
sb.AppendLine("sea river can sit far down the unified ranking, so \"top N\" would leave it with no course to draw.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**⚠ NOT touched:** `EndorheicMinDepthM` {def.EndorheicMinDepthM} m and `EndorheicMinAreaPx` {def.EndorheicMinAreaPx:N0} — they decide which depressions");
|
||||
sb.AppendLine("BECOME terminal basins, i.e. they define the routing surface itself. Also unchanged:");
|
||||
sb.AppendLine($"`MinOutletSeparationPx` {def.MinOutletSeparationPx}, `StemMinAccPx` {def.StemMinAccPx}. The terminus is classified by `RegionLabeling.OceanMask`");
|
||||
sb.AppendLine("(`Dir == D_SEA`) — **there is no bare `h < sea` test anywhere in this tool.**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Files");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| File | What it is |");
|
||||
sb.AppendLine("|---|---|");
|
||||
sb.AppendLine($"| `<seed>/{pure}` | the pure unified top {compN} — fixed width scale, per-river labels |");
|
||||
sb.AppendLine($"| `<seed>/{quota}` | the same {compN} with the K={compK} sea floor — **same width scale**, so thin means smaller |");
|
||||
sb.AppendLine("| `<seed>/grayscale.png` | the eroded render field, no palette |");
|
||||
sb.AppendLine("| `composition_<seed>.csv` | both compositions row by row: rank, drainage, forced?, stem px, in-the-other-composition? |");
|
||||
if (skipRaw)
|
||||
sb.AppendLine("| ~~`<seed>/height.f32`~~ | **deliberately not written** — rivers/01 proved this field byte-identical to `chat2/11_erosion`, the anchor of record. `ISLA_SKIP_RAW=0` regenerates it. |");
|
||||
else
|
||||
sb.AppendLine("| `<seed>/height.f32` | the eroded render field this analysis ran on |");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Not here:** `Giant.ProvisionalRoute` (the \"comb\") is **never drawn** — rivers/03 replaces it, and drawing");
|
||||
sb.AppendLine("it would make a composition judgment look like a finished network. No lowland routing, no water, nothing carved.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## When the developer picks");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Graduate as ONE unit — **\"unified ranking + a gameplay-motivated sea-river floor of K\"**. Candidate decision:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"> *\"the river count for the reshaped terrain is {compN}, composed as the top inland drainages plus a gameplay");
|
||||
sb.AppendLine("> floor of K sea-reaching rivers, superseding the M3 count of 3.\"* (K filled in on confirmation.)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Ranges: sea level `{def.SeaLevel}` raw = `{WorldScale.MetresFromRaw(def.SeaLevel):F2} m`; {WorldScale.Describe()}.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("→ `XX_Human/output/rivers/02b_composition_pure_vs_quota.report.md`");
|
||||
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
|
||||
}
|
||||
|
||||
// ---- the curve (the house pattern; pool pinned family-off per rivers/01) -------------------
|
||||
|
||||
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
|
||||
|
|
|
|||
Loading…
Reference in a new issue