using System; using System.Text; namespace IslaApocalypse.Core { /// /// ⭐⭐ THE CLIMB'S CONTROL POINTS, MEASURED FROM THE STAIRCASE (chat2/03) — "the staircase's /// mountain with the terraces melted out". /// /// ═══ THE MISTAKE THIS TYPE CORRECTS ═══ /// /// chat2/02 built the climb from ANALYTIC control points (a feather and a drama knob) and got a /// bottom-heavy curve: land above 100 m fell from ~15 % to ~4.8 %. That report concluded the loss /// was STRUCTURAL — that a no-magnet monotone curve must preserve the raw distribution's /// bottom-heavy shape, so only a Phase-1 noise change could restore the mountain. /// /// > ### ⚠ THAT CONCLUSION WAS WRONG, AND THIS TYPE IS THE PROOF. /// > /// > A monotone curve is a free reparametrization: it may be gentle in one place and steep in /// > another, and can LIFT bottom-heavy input into a substantial mid-massif without ever going /// > flat. **No-flats and lift-the-mass are compatible.** The area of land above a given height /// > is set by where the percentile→height mapping CROSSES that height, and that mapping is /// > entirely ours to choose. /// > /// > The 02 sweep that "proved" the loss structural varied climbFeather, which shapes the /// > JOIN, not the mass distribution. It was the wrong knob, and the conclusion generalized from /// > it was too strong. /// /// ═══ WHAT THE STAIRCASE'S BENCHES ACTUALLY DID ═══ /// /// They did not CREATE highland. They LIFTED land to 100 m and 220 m. The same ~27 % of land /// above the ceiling exists in both curves; 02's analytic climb simply placed it low. So the fix /// is not to make more high land — it is to put the land that is already there back where the /// staircase had it, as a smooth slope. /// /// ═══ THE METHOD — the same percentile idea as task 01's knots, one level up ═══ /// /// Task 01 measured percentiles of the raw distribution to place the curve's INPUT knots. This /// measures percentiles of the staircase's ABOVE-CEILING land to place the climb's OUTPUT /// heights: /// /// for each p in {10, 30, 50, 70, 85, 95}: /// u_p = normalized RAW position of above-ceiling land at percentile p /// v_p = normalized OUTPUT height of above-ceiling land at percentile p (staircase) /// /// PCHIP through (0,0), (u_p, v_p)…, (1,1) reproduces the staircase's elevation envelope — /// the same land ends up at the same heights, so the mountain mass returns — while the flat bench /// and plateau INTERIORS become smooth grade. /// /// ═══ ⚠ WHERE THE STAIRCASE WAS FLAT, WE MUST DEVIATE — AND THAT IS THE POINT ═══ /// /// A bench maps a wide input band onto a narrow output band, so two adjacent percentiles land at /// nearly the same height and their secant is near zero. Reproducing THAT would rebuild the /// bench. floors every segment's grade and renormalizes, so the /// curve passes THROUGH the bench height with slope instead of running ALONG it. The floor bites /// only where the staircase was flat; everywhere else the calibration is reproduced. /// public sealed class ClimbCalibration { /// /// The above-ceiling land percentiles sampled. Six is a handful — enough to carry the /// staircase's envelope, few enough that PCHIP interpolates smoothly between them rather /// than tracing every wobble of the bench. /// public static readonly double[] DefaultPercentiles = { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0 }; /// /// The no-bench floor: no segment's grade may fall below this fraction of the climb's average /// grade (1.0 = average). 0.35 is comfortably above 's own /// near-flat tripwire and well below the grades the calibration produces outside the benches, /// so it is a repair for the flats and a no-op everywhere else. /// public const float MinNormalizedSecant = 0.35f; /// Iterations of floor-then-renormalize. It converges in a few; 24 is free insurance. private const int RepairIterations = 24; /// Normalized control points, strictly increasing in both. Includes (0,0) and (1,1). public readonly float[] U, V; /// The percentiles sampled, and the raw/output heights measured at each — for the report. public readonly double[] Percentiles; public readonly float[] RawAt, TargetHeightAt; /// The knobs this calibration was shaped with. public readonly float MountainLift, PeakSharpness; /// /// Normalized u of the summit onset — the LAST measured percentile. Above it, /// reshapes; below it, nothing does. That is the decoupling. /// public readonly float SummitOnsetU; /// How many segments the no-bench floor had to lift. Zero means the staircase had no flats. public readonly int SegmentsFloored; private ClimbCalibration(float[] u, float[] v, double[] pcts, float[] rawAt, float[] targetAt, float lift, float sharp, float onsetU, int floored) { U = u; V = v; Percentiles = pcts; RawAt = rawAt; TargetHeightAt = targetAt; MountainLift = lift; PeakSharpness = sharp; SummitOnsetU = onsetU; SegmentsFloored = floored; } /// /// Build the calibration from measured quantiles. /// /// ⚠ Takes plain arrays, not a histogram: LandHistogram lives in Tools/ and Core /// depends on nothing above it. The caller measures; this shapes. /// /// The percentiles sampled, ascending. /// Above-ceiling RAW height at each percentile. /// Above-ceiling STAIRCASE OUTPUT height at each percentile. /// /// 1.0 = reproduce the staircase's mountain. >1 lifts the mid-massif higher; <1 lowers it /// toward chat2/02's bottom-heavy default. Applied as v ← v^(1/lift), which is monotone /// and fixes both endpoints, so it can move the massif without touching sea level or the cap. /// /// /// ⭐ ACTS ONLY ABOVE THE LAST MEASURED PERCENTILE. 1.0 = a straight run to the cap; higher /// defers the rise so the final approach steepens and the peak reads pointy. /// ⚠ Unlike chat2/02's summitDrama, it CANNOT lower the massif — the onset's height is /// fixed by the calibration before this is applied. That is the §3 fix. /// public static ClimbCalibration FromPercentiles( double[] percentiles, float[] rawQuantiles, float[] outQuantiles, float ceilingRaw, float spikeMax, float ceilingOut, float peakCap, float mountainLift, float peakSharpness) { int n = percentiles.Length; if (rawQuantiles.Length != n || outQuantiles.Length != n) throw new ArgumentException("[ClimbCalibration] percentile/raw/output arrays must be the same length."); if (mountainLift <= 0f) throw new ArgumentOutOfRangeException(nameof(mountainLift), mountainLift, "mountainLift must be positive."); if (peakSharpness < 1f) throw new ArgumentOutOfRangeException(nameof(peakSharpness), peakSharpness, "peakSharpness < 1 would make the summit's final approach SHALLOWER than its own average — a ramp, not a peak."); float spanRaw = spikeMax - ceilingRaw; float spanOut = peakCap - ceilingOut; if (spanRaw <= 0f || spanOut <= 0f) throw new InvalidOperationException("[ClimbCalibration] the climb has no room — ceiling meets the summit."); // ---- normalize the measured points, plus the two exact endpoints ---- var u = new float[n + 2]; var v = new float[n + 2]; u[0] = 0f; v[0] = 0f; u[n + 1] = 1f; v[n + 1] = 1f; for (int i = 0; i < n; i++) { u[i + 1] = Math.Clamp((rawQuantiles[i] - ceilingRaw) / spanRaw, 0f, 1f); v[i + 1] = Math.Clamp((outQuantiles[i] - ceilingOut) / spanOut, 0f, 1f); } // ⚠ u must be STRICTLY increasing for PCHIP. Percentiles of a continuous distribution // give that naturally; a degenerate seed (a plateau in the raw CDF) could not. Nudge // rather than throw — a hair of u-spacing is not a shape decision. const float minDu = 1e-4f; for (int i = 1; i < u.Length; i++) if (u[i] <= u[i - 1] + minDu) u[i] = u[i - 1] + minDu; // Renormalize back onto [0,1] if the nudging pushed past the end. if (u[u.Length - 1] > 1f) { float s = 1f / u[u.Length - 1]; for (int i = 1; i < u.Length; i++) u[i] *= s; u[u.Length - 1] = 1f; } // ---- mountainLift: v ← v^(1/lift). Monotone, endpoints fixed. ---- if (Math.Abs(mountainLift - 1f) > 1e-6f) { float e = 1f / mountainLift; for (int i = 1; i <= n; i++) v[i] = MathF.Pow(v[i], e); } // ---- the no-bench repair: floor every grade, renormalize to keep v(1) = 1 ---- float onsetU = u[n]; // the last measured percentile int floored = RepairSecants(u, v, out _); // ---- peakSharpness: reshape ONLY the segment above the onset ---- // Insert a midpoint whose height defers the rise, so the final approach steepens. // v_mid = v_onset + (1 - v_onset) * 0.5^sharpness ⇒ sharpness 1 is exactly linear. if (peakSharpness > 1f + 1e-6f) { float uS = u[n], vS = v[n]; float uMid = (uS + 1f) * 0.5f; float vMid = vS + (1f - vS) * MathF.Pow(0.5f, peakSharpness); var u2 = new float[u.Length + 1]; var v2 = new float[v.Length + 1]; Array.Copy(u, u2, n + 1); Array.Copy(v, v2, n + 1); u2[n + 1] = uMid; v2[n + 1] = vMid; u2[n + 2] = 1f; v2[n + 2] = 1f; u = u2; v = v2; // ⚠ The deferred first half must still not be a bench. Re-floor ONLY that segment, // leaving the calibrated massif below the onset untouched — re-running the global // repair here would renormalize the massif and undo the decoupling. float du = uMid - uS; float minDv = MinNormalizedSecant * du; if (vMid - vS < minDv) v[n + 1] = vS + minDv; } var rawAt = (float[])rawQuantiles.Clone(); var outAt = (float[])outQuantiles.Clone(); var cal = new ClimbCalibration(u, v, (double[])percentiles.Clone(), rawAt, outAt, mountainLift, peakSharpness, onsetU, floored); cal.AssertUsable(); return cal; } /// /// Floor every segment's normalized grade at and renormalize /// so the last point still lands exactly on 1. Iterated, because renormalizing can push a /// floored segment back under the floor; it converges as long as the un-floored segments have /// room to absorb the excess. /// private static int RepairSecants(float[] u, float[] v, out float minSecant) { int m = u.Length; var s = new float[m - 1]; var du = new float[m - 1]; for (int i = 0; i < m - 1; i++) { du[i] = u[i + 1] - u[i]; s[i] = (v[i + 1] - v[i]) / du[i]; } int flooredCount = 0; for (int it = 0; it < RepairIterations; it++) { int hit = 0; for (int i = 0; i < s.Length; i++) if (s[i] < MinNormalizedSecant) { s[i] = MinNormalizedSecant; hit++; } flooredCount = hit; float total = 0f; for (int i = 0; i < s.Length; i++) total += s[i] * du[i]; if (Math.Abs(total - 1f) < 1e-6f) break; for (int i = 0; i < s.Length; i++) s[i] /= total; } // Rebuild v from the repaired grades. minSecant = float.MaxValue; v[0] = 0f; for (int i = 0; i < s.Length; i++) { if (s[i] < minSecant) minSecant = s[i]; v[i + 1] = v[i] + s[i] * du[i]; } v[m - 1] = 1f; // exact, against accumulated float drift return flooredCount; } /// /// The invariants a calibration must satisfy before it is allowed to shape terrain. Throws /// and refuses, rather than producing a curve nobody checked. /// private void AssertUsable() { for (int i = 1; i < U.Length; i++) { if (U[i] <= U[i - 1]) throw new InvalidOperationException( $"[ClimbCalibration] control point {i} is not strictly right of its predecessor " + $"(u {U[i - 1]} → {U[i]}). Refusing to generate."); if (V[i] <= V[i - 1]) throw new InvalidOperationException( $"[ClimbCalibration] control point {i} does not RISE (v {V[i - 1]} → {V[i]}) — that is a " + $"bench, which is the artifact this mode exists to remove. Refusing to generate."); } if (Math.Abs(U[0]) > 1e-6f || Math.Abs(V[0]) > 1e-6f || Math.Abs(U[U.Length - 1] - 1f) > 1e-6f || Math.Abs(V[V.Length - 1] - 1f) > 1e-6f) throw new InvalidOperationException( "[ClimbCalibration] the endpoints must be exactly (0,0) and (1,1) — the lowland handover and " + "the peak cap are not negotiable. Refusing to generate."); } /// The calibration as one line for the INDEX, the log and the report. public string Describe() { var sb = new StringBuilder(); sb.Append($"lift {MountainLift:F2} sharp {PeakSharpness:F2} onsetU {SummitOnsetU:F3} " + $"floored {SegmentsFloored} · uv "); for (int i = 0; i < U.Length; i++) sb.Append($"({U[i]:F3},{V[i]:F3}) "); return sb.ToString().TrimEnd(); } /// The measured percentile table, for the report. public string DescribeMeasured(float ceilingOut, float peakCap) { var sb = new StringBuilder(); for (int i = 0; i < Percentiles.Length; i++) sb.Append($"P{Percentiles[i]:F0}→{WorldScale.MetresFromRaw(TargetHeightAt[i] - 0.15f):F0}m "); return sb.ToString().TrimEnd(); } } }