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();
}
}
}