1/// The ladder, and the level.2///3/// A family's sweep hands back an exact distance for every reachable4/// configuration, so a rung is nothing more than "a board at depth D". The5/// schedule below turns a rung number into that depth.6///7/// On what "endless" honestly means here: the *ladder* has no end, but depth8/// does. A family's eccentricity is the furthest any configuration sits from a9/// solved one, and nothing can be deeper than that. So the schedule climbs10/// quickly at first, asymptotes to the ceiling, and once it arrives keeps11/// drawing fresh boards from the deep band — ranked by complexity rather than12/// by length, since length has run out of room. Rung 400 is not deeper than13/// rung 40; it is a different board that is just as deep, and there are14/// thousands of those.15import gleam/float16import gleam/int1718/// Rungs of growth before a family reaches its ceiling.19const arc = 182021/// Candidates weighed per rung, and the narrower window used once the schedule22/// has run out of depth: weighing twelve overlapping candidates and keeping the23/// best simply re-elects the same winner rung after rung.24pub const pool = 122526pub const ceiling_pool = 42728/// Depth served at a given rung, for a family whose ceiling is `ecc`.29///30/// Linear, and deliberately so. An exponential ramp looked reasonable written31/// down and was wrong in the hand: on the classic board it served 13 steps and32/// then 33, so the second puzzle you ever saw was two and a half times the33/// first. Growing by a fixed step keeps every family to the same shape of arc —34/// an opening board of five to fourteen steps, about eighteen rungs of growth,35/// then the ceiling — whether its ceiling is 24 or 178.36pub fn depth_for_rung(rung: Int, ecc: Int) -> Int {37 case ecc > 0 {38 False -> 039 True -> {40 let base = int.max(5, int.min(14, float.round(int.to_float(ecc) *. 0.22)))41 let step =42 int.max(2, float.round(int.to_float(ecc - base) /. int.to_float(arc)))43 int.min(ecc, base + step * { int.max(1, rung) - 1 })44 }45 }46}4748/// True once the schedule has run out of depth and is varying boards instead.49pub fn at_ceiling(rung: Int, ecc: Int) -> Bool {50 depth_for_rung(rung, ecc) >= ecc - 151}5253/// XP is earned for the shape of the board and how cleanly you took it, not for54/// showing up: depth and complexity set the base, efficiency scales it, and55/// replaying a rung you have already cleared is worth a quarter.56pub fn xp_for(57 optimal optimal: Int,58 score score: Float,59 steps steps: Int,60 first_clear first_clear: Bool,61) -> Int {62 let base = 4.0 +. int.to_float(optimal) *. { 0.6 +. score /. 6.0 }63 let ratio = int.to_float(optimal) /. int.to_float(int.max(1, steps))64 let efficiency = case float.square_root(clamp(ratio, 0.35, 1.0)) {65 Ok(value) -> value66 Error(_) -> 1.067 }68 let replay = case first_clear {69 True -> 1.070 False -> 0.2571 }72 int.max(1, float.round(base *. efficiency *. replay))73}7475fn clamp(value: Float, low: Float, high: Float) -> Float {76 float.max(low, float.min(high, value))77}7879/// A gentle power curve: later levels cost more, none of them wall off.80const xp_unit = 45.08182const xp_power = 1.78384pub fn xp_for_level(level: Int) -> Int {85 case float.power(int.to_float(int.max(0, level - 1)), xp_power) {86 Ok(value) -> float.round(xp_unit *. value)87 Error(_) -> 088 }89}9091pub fn level_from_xp(xp: Int) -> Int {92 // The closed form is only an estimate because xp_for_level rounds; walk the93 // last step or two so the two functions are exact inverses of each other.94 let estimate = case95 float.power(int.to_float(int.max(0, xp)) /. xp_unit, 1.0 /. xp_power)96 {97 Ok(value) -> 1 + float.truncate(value)98 Error(_) -> 199 }100 estimate |> climb(xp) |> descend(xp)101}102103fn climb(level: Int, xp: Int) -> Int {104 case xp_for_level(level + 1) <= xp {105 True -> climb(level + 1, xp)106 False -> level107 }108}109110fn descend(level: Int, xp: Int) -> Int {111 case level > 1 && xp_for_level(level) > xp {112 True -> descend(level - 1, xp)113 False -> level114 }115}116117pub type Level {118 Level(level: Int, into: Int, span: Int, from: Int, to: Int)119}120121/// Progress within the current level, which is what a bar should show.122pub fn level_progress(xp: Int) -> Level {123 let level = level_from_xp(xp)124 let from = xp_for_level(level)125 let to = xp_for_level(level + 1)126 Level(level:, into: xp - from, span: int.max(1, to - from), from:, to:)127}