1import gleam/int2import gleam/list3import gleeunit/should4import klotski/progress56/// The schedule is linear, and deliberately so. An exponential ramp looked7/// reasonable written down and was wrong in the hand: on the classic board it8/// served 13 steps and then 33, so the second puzzle you ever saw was two and a9/// half times the first.10pub fn the_ladder_grows_by_a_fixed_step_test() {11 // CLASSIC's ceiling: an opening board of 14 and a step of 6.12 [1, 2, 3, 4, 5]13 |> list.map(progress.depth_for_rung(_, 126))14 |> should.equal([14, 20, 26, 32, 38])1516 // NARROW's is 24, so the same arc is walked in much smaller steps.17 [1, 2, 3, 4, 5]18 |> list.map(progress.depth_for_rung(_, 24))19 |> should.equal([5, 7, 9, 11, 13])20}2122/// Every family reaches its ceiling in about the same number of rungs, whether23/// that ceiling is 24 or 178. That is what the fixed step buys.24pub fn every_family_arrives_in_about_eighteen_rungs_test() {25 [24, 33, 80, 126, 178]26 |> list.map(fn(ecc) { first_at_ceiling(ecc, 1) })27 |> list.all(fn(rung) { rung >= 10 && rung <= 20 })28 |> should.be_true29}3031fn first_at_ceiling(ecc: Int, rung: Int) -> Int {32 case rung > 60 || progress.at_ceiling(rung, ecc) {33 True -> rung34 False -> first_at_ceiling(ecc, rung + 1)35 }36}3738/// Depth cannot exceed a family's own eccentricity, and a family nobody has39/// swept has no depth to serve.40pub fn the_schedule_never_outruns_the_family_test() {41 progress.depth_for_rung(400, 126) |> should.equal(126)42 progress.depth_for_rung(1, 0) |> should.equal(0)43 progress.at_ceiling(400, 126) |> should.be_true44 progress.at_ceiling(1, 126) |> should.be_false45}4647/// The closed form for a level is only an estimate because the cost of one48/// rounds, so the two functions have to be walked into agreement. If they ever49/// disagree, a bar fills past its own end.50pub fn levels_and_their_cost_are_exact_inverses_test() {51 levels(40)52 |> list.all(fn(level) {53 let at = progress.xp_for_level(level)54 progress.level_from_xp(at) == level55 && progress.level_from_xp(at - 1) == level - 1 + boundary(level)56 })57 |> should.be_true58}5960fn levels(count: Int) -> List(Int) {61 int.range(from: 1, to: count + 1, with: [], run: fn(items, n) { [n, ..items] })62}6364fn boundary(level: Int) -> Int {65 // Level one has nothing below it to fall back to.66 case level {67 1 -> 168 _ -> 069 }70}7172pub fn progress_within_a_level_stays_inside_its_span_test() {73 let at = progress.level_progress(200)74 { at.into >= 0 && at.into < at.span } |> should.be_true75 at.from |> should.equal(progress.xp_for_level(at.level))76 at.to |> should.equal(progress.xp_for_level(at.level + 1))77}7879/// XP is earned for the shape of the board and how cleanly you took it, not for80/// showing up: a replay is worth a quarter, and a wasteful run less than a tidy81/// one on the same board.82pub fn xp_rewards_the_board_and_the_run_test() {83 let clean =84 progress.xp_for(optimal: 40, score: 2.4, steps: 40, first_clear: True)85 let messy =86 progress.xp_for(optimal: 40, score: 2.4, steps: 90, first_clear: True)87 let replay =88 progress.xp_for(optimal: 40, score: 2.4, steps: 40, first_clear: False)8990 { clean > messy } |> should.be_true91 { replay < clean } |> should.be_true92 { replay >= 1 } |> should.be_true9394 // A deeper board is worth more than a shallow one taken just as cleanly.95 let shallow =96 progress.xp_for(optimal: 8, score: 2.4, steps: 8, first_clear: True)97 { clean > shallow } |> should.be_true98}