1/// Progression, kept in a versioned envelope.2///3/// localStorage is enough, but the shape is the one that survives: a schema4/// version to migrate against, and a monotonic counter rather than a wall5/// clock, so a later sync can merge by "which write came after" without6/// trusting two devices to agree on the time.7///8/// Lifetime totals are kept incrementally rather than recomputed from the log,9/// because the log is capped. A ring buffer of recent solves is enough to draw10/// a chart; it is not enough to answer "how many have I ever solved", so that11/// number is counted as it happens and never derived. The same goes for `clean`12/// and `detours`: deriving them would quietly mean "of the last hundred and13/// forty".14import gleam/dynamic/decode.{type Decoder}15import gleam/int16import gleam/json.{type Json}17import gleam/list18import gleam/option.{type Option, None, Some}19import gleam/result20import gleam/string21import klotski/families22import klotski/progress23import klotski/shading.{type Pattern}2425pub const schema = 42627const log_cap = 1402829/// How much of the solver shows. Only ever subtractive: FULL is everything,30/// BEST takes away the exact distance and leaves the gauge, OFF takes the gauge31/// too. STEPS and OPTIMAL stay throughout — one is a record of what you did and32/// the other is on the card before you start, so neither is a hint.33pub type Hints {34 Full35 Best36 OffHints37}3839/// Buffer pixels per layout unit, said in the only terms anyone outside the40/// renderer has any reason to care about. Nobody wants to choose a number of41/// buffer pixels; what they can tell is whether the screen looks the way it42/// should and whether the phone in their hand keeps up.43pub type Density {44 Low45 Standard46}4748pub type FamilyRecord {49 FamilyRecord(50 id: String,51 rung: Int,52 solved: Int,53 deepest: Int,54 /// rung -> fewest steps it has been cleared in.55 best: List(#(Int, Int)),56 ms: Int,57 )58}5960pub type Totals {61 Totals(62 solved: Int,63 steps: Int,64 optimal: Int,65 ms: Int,66 xp: Int,67 clean: Int,68 detours: Int,69 )70}7172pub type Days {73 Days(first: Option(String), last: Option(String), streak: Int, best: Int)74}7576/// One solve, newest last, capped. `detours` and `worst_detour` are the shape77/// of the run; absent on anything solved before they were recorded, which the78/// record screen reads as unknown rather than as zero.79pub type Entry {80 Entry(81 family: String,82 rung: Int,83 optimal: Int,84 steps: Int,85 ms: Int,86 at: Int,87 detours: Option(Int),88 worst_detour: Int,89 )90}9192pub type Save {93 Save(94 /// AUTO follows the system's light/dark preference. Anyone who has already95 /// chosen a theme keeps it: a save that names one is a decision rather than96 /// a default.97 palette: String,98 sound: Bool,99 haptics: Bool,100 /// Seeded per rung unless pinned to one screen shape.101 shading: Result(Pattern, Nil),102 hints: Hints,103 density: Density,104 families: List(FamilyRecord),105 totals: Totals,106 days: Days,107 log: List(Entry),108 counter: Int,109 )110}111112pub fn fresh() -> Save {113 Save(114 palette: "auto",115 sound: True,116 haptics: True,117 shading: Error(Nil),118 hints: Full,119 density: Standard,120 families: [],121 totals: Totals(0, 0, 0, 0, 0, 0, 0),122 days: Days(None, None, 0, 0),123 log: [],124 counter: 0,125 )126}127128pub fn family_record(id: String) -> FamilyRecord {129 FamilyRecord(id:, rung: 1, solved: 0, deepest: 0, best: [], ms: 0)130}131132pub fn record_for(save: Save, id: String) -> FamilyRecord {133 save.families134 |> list.find(fn(record) { record.id == id })135 |> result.unwrap(family_record(id))136}137138fn put_record(save: Save, record: FamilyRecord) -> Save {139 let replaced =140 list.map(save.families, fn(existing) {141 case existing.id == record.id {142 True -> record143 False -> existing144 }145 })146 let families = case147 list.any(save.families, fn(existing) { existing.id == record.id })148 {149 True -> replaced150 False -> list.append(save.families, [record])151 }152 Save(..save, families:)153}154155/// What a solve earned.156pub type Gain {157 Gain(xp: Int, first_clear: Bool, level: Int, levelled: Bool)158}159160/// Record a solved board and return what it earned.161pub fn record(162 save save: Save,163 family_id family_id: String,164 rung rung: Int,165 optimal optimal: Int,166 steps steps: Int,167 ms ms: Int,168 score score: Float,169 detours detours: Int,170 worst_detour worst_detour: Int,171 now now: Int,172 today today: String,173) -> #(Save, Gain) {174 let before = progress.level_from_xp(save.totals.xp)175 let family = record_for(save, family_id)176 let first_clear = !list.any(family.best, fn(entry) { entry.0 == rung })177 let xp = progress.xp_for(optimal:, score:, steps:, first_clear:)178179 let previous_best = family.best |> list.key_find(rung) |> result.unwrap(steps)180 let best = case first_clear || steps < previous_best {181 True -> list.key_set(family.best, rung, steps)182 False -> family.best183 }184 let family =185 FamilyRecord(186 ..family,187 best:,188 solved: family.solved189 + case first_clear {190 True -> 1191 False -> 0192 },193 deepest: int.max(family.deepest, optimal),194 ms: family.ms + ms,195 // The ladder only advances on a first clear, so replaying never skips196 // ahead.197 rung: case first_clear && rung >= family.rung {198 True -> rung + 1199 False -> family.rung200 },201 )202203 let totals = save.totals204 let totals =205 Totals(206 solved: totals.solved + 1,207 steps: totals.steps + steps,208 optimal: totals.optimal + optimal,209 ms: totals.ms + ms,210 xp: totals.xp + xp,211 clean: totals.clean212 + case detours == 0 {213 True -> 1214 False -> 0215 },216 detours: totals.detours + detours,217 )218219 let entry =220 Entry(221 family: family_id,222 rung:,223 optimal:,224 steps:,225 ms:,226 at: now,227 detours: Some(detours),228 worst_detour:,229 )230 let log = list.append(save.log, [entry]) |> cap_log231232 let save =233 Save(..save, totals:, log:, days: stamp_day(save.days, today))234 |> put_record(family)235 let after = progress.level_from_xp(save.totals.xp)236 #(save, Gain(xp:, first_clear:, level: after, levelled: after > before))237}238239fn cap_log(log: List(Entry)) -> List(Entry) {240 let excess = list.length(log) - log_cap241 case excess > 0 {242 True -> list.drop(log, excess)243 False -> log244 }245}246247/// A streak survives a gap of one day, because "yesterday" should still count248/// while today is still going. Two days is a break.249pub fn stamp_day(days: Days, today: String) -> Days {250 let first = option.or(days.first, Some(today))251 case days.last {252 Some(last) if last == today -> Days(..days, first:)253 last -> {254 let streak = case last {255 Some(previous) if previous != "" ->256 case days_between(previous, today) {257 1 -> days.streak + 1258 _ -> 1259 }260 _ -> 1261 }262 Days(first:, last: Some(today), streak:, best: int.max(days.best, streak))263 }264 }265}266267/// Whole days between two ISO dates. Kept as arithmetic on the parts rather268/// than as a date library: the only question asked of it is "is this exactly269/// one day later", and the answer never has to survive a timezone.270fn days_between(from: String, to: String) -> Int {271 case parse_date(from), parse_date(to) {272 Ok(a), Ok(b) -> day_number(b) - day_number(a)273 _, _ -> 9999274 }275}276277fn parse_date(value: String) -> Result(#(Int, Int, Int), Nil) {278 case string.split(value, "-") {279 [year, month, day] -> {280 use year <- result.try(int.parse(year))281 use month <- result.try(int.parse(month))282 use day <- result.try(int.parse(day))283 Ok(#(year, month, day))284 }285 _ -> Error(Nil)286 }287}288289/// Days since the proleptic Gregorian epoch, by the standard civil-from-days290/// inverse. Exact, and no calendar object anywhere near it.291fn day_number(date: #(Int, Int, Int)) -> Int {292 let #(year, month, day) = date293 let year = case month <= 2 {294 True -> year - 1295 False -> year296 }297 let era = case year >= 0 {298 True -> year / 400299 False -> { year - 399 } / 400300 }301 let year_of_era = year - era * 400302 let shifted = case month > 2 {303 True -> month - 3304 False -> month + 9305 }306 let day_of_year = { 153 * shifted + 2 } / 5 + day - 1307 let day_of_era =308 year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year309 era * 146_097 + day_of_era310}311312/// ------------------------------------------------------------------- codec313///314/// The envelope is written by hand rather than derived, because its whole point315/// is that it is stable across versions of the thing inside it.316pub fn encode(save: Save) -> String {317 json.object([318 #("schema", json.int(schema)),319 #("counter", json.int(save.counter + 1)),320 #(321 "payload",322 json.object([323 #("palette", json.string(save.palette)),324 #("sound", json.bool(save.sound)),325 #("haptics", json.bool(save.haptics)),326 #(327 "shading",328 json.string(case save.shading {329 Ok(pattern) -> shading.pattern_id(pattern)330 Error(_) -> "auto"331 }),332 ),333 #("hints", json.string(hints_id(save.hints))),334 #("density", json.int(density_units(save.density))),335 #("fams", json.array(save.families, encode_family)),336 #("totals", encode_totals(save.totals)),337 #("days", encode_days(save.days)),338 #("log", json.array(save.log, encode_entry)),339 ]),340 ),341 ])342 |> json.to_string343}344345fn encode_family(record: FamilyRecord) -> Json {346 json.object([347 #("id", json.string(record.id)),348 #("rung", json.int(record.rung)),349 #("solved", json.int(record.solved)),350 #("deepest", json.int(record.deepest)),351 #("ms", json.int(record.ms)),352 #(353 "best",354 json.array(record.best, fn(pair) {355 json.preprocessed_array([json.int(pair.0), json.int(pair.1)])356 }),357 ),358 ])359}360361fn encode_totals(totals: Totals) -> Json {362 json.object([363 #("solved", json.int(totals.solved)),364 #("steps", json.int(totals.steps)),365 #("optimal", json.int(totals.optimal)),366 #("ms", json.int(totals.ms)),367 #("xp", json.int(totals.xp)),368 #("clean", json.int(totals.clean)),369 #("detours", json.int(totals.detours)),370 ])371}372373fn encode_days(days: Days) -> Json {374 json.object([375 #("first", json.nullable(days.first, json.string)),376 #("last", json.nullable(days.last, json.string)),377 #("streak", json.int(days.streak)),378 #("best", json.int(days.best)),379 ])380}381382fn encode_entry(entry: Entry) -> Json {383 json.object([384 #("f", json.string(entry.family)),385 #("r", json.int(entry.rung)),386 #("L", json.int(entry.optimal)),387 #("s", json.int(entry.steps)),388 #("ms", json.int(entry.ms)),389 #("t", json.int(entry.at)),390 #("d", json.nullable(entry.detours, json.int)),391 #("w", json.int(entry.worst_detour)),392 ])393}394395/// Read a save back. Anything that does not parse is a fresh one: a corrupt396/// record is worth less than a playable game, and there is nothing here that397/// cannot be earned again.398pub fn decode(raw: String) -> Save {399 case json.parse(raw, envelope_decoder()) {400 Ok(save) -> guard(save)401 Error(_) -> fresh()402 }403}404405fn envelope_decoder() -> Decoder(Save) {406 use counter <- decode.optional_field("counter", 0, decode.int)407 use save <- decode.field("payload", payload_decoder())408 decode.success(Save(..save, counter:))409}410411fn payload_decoder() -> Decoder(Save) {412 let default = fresh()413 use palette <- decode.optional_field("palette", "auto", decode.string)414 use sound <- decode.optional_field("sound", True, decode.bool)415 use haptics <- decode.optional_field("haptics", True, decode.bool)416 use pattern <- decode.optional_field("shading", "auto", decode.string)417 use hints <- decode.optional_field("hints", "full", decode.string)418 use density <- decode.optional_field("density", 3, decode.int)419 use fams <- decode.optional_field("fams", [], decode.list(family_decoder()))420 use totals <- decode.optional_field(421 "totals",422 default.totals,423 totals_decoder(),424 )425 use days <- decode.optional_field("days", default.days, days_decoder())426 use log <- decode.optional_field("log", [], decode.list(entry_decoder()))427 decode.success(Save(428 palette:,429 sound:,430 haptics:,431 shading: shading.pattern_from_id(pattern),432 hints: hints_from_id(hints),433 density: density_from_units(density),434 families: fams,435 totals:,436 days:,437 log:,438 counter: 0,439 ))440}441442fn family_decoder() -> Decoder(FamilyRecord) {443 use id <- decode.field("id", decode.string)444 use rung <- decode.optional_field("rung", 1, decode.int)445 use solved <- decode.optional_field("solved", 0, decode.int)446 use deepest <- decode.optional_field("deepest", 0, decode.int)447 use ms <- decode.optional_field("ms", 0, decode.int)448 use best <- decode.optional_field("best", [], decode.list(pair_decoder()))449 decode.success(FamilyRecord(id:, rung:, solved:, deepest:, best:, ms:))450}451452fn pair_decoder() -> Decoder(#(Int, Int)) {453 use rung <- decode.field(0, decode.int)454 use steps <- decode.field(1, decode.int)455 decode.success(#(rung, steps))456}457458fn totals_decoder() -> Decoder(Totals) {459 use solved <- decode.optional_field("solved", 0, decode.int)460 use steps <- decode.optional_field("steps", 0, decode.int)461 use optimal <- decode.optional_field("optimal", 0, decode.int)462 use ms <- decode.optional_field("ms", 0, decode.int)463 use xp <- decode.optional_field("xp", 0, decode.int)464 use clean <- decode.optional_field("clean", 0, decode.int)465 use detours <- decode.optional_field("detours", 0, decode.int)466 decode.success(Totals(solved:, steps:, optimal:, ms:, xp:, clean:, detours:))467}468469fn days_decoder() -> Decoder(Days) {470 use first <- decode.optional_field(471 "first",472 None,473 decode.optional(decode.string),474 )475 use last <- decode.optional_field(476 "last",477 None,478 decode.optional(decode.string),479 )480 use streak <- decode.optional_field("streak", 0, decode.int)481 use best <- decode.optional_field("best", 0, decode.int)482 decode.success(Days(first:, last:, streak:, best:))483}484485fn entry_decoder() -> Decoder(Entry) {486 use family <- decode.optional_field("f", "", decode.string)487 use rung <- decode.optional_field("r", 1, decode.int)488 use optimal <- decode.optional_field("L", 0, decode.int)489 use steps <- decode.optional_field("s", 0, decode.int)490 use ms <- decode.optional_field("ms", 0, decode.int)491 use at <- decode.optional_field("t", 0, decode.int)492 use detours <- decode.optional_field("d", None, decode.optional(decode.int))493 use worst <- decode.optional_field("w", 0, decode.int)494 decode.success(Entry(495 family:,496 rung:,497 optimal:,498 steps:,499 ms:,500 at:,501 detours:,502 worst_detour: worst,503 ))504}505506/// A save is the one input to the renderer nobody validates, and the ladder is507/// the one input to the solver nobody validates. A stale or hand-edited value508/// lands on the standard rather than on a canvas of some arbitrary size or a509/// rung that does not exist.510fn guard(save: Save) -> Save {511 Save(512 ..save,513 families: list.filter(save.families, fn(record) {514 list.any(families.all(), fn(family) { family.id == record.id })515 })516 |> list.map(fn(record) {517 FamilyRecord(..record, rung: int.max(1, record.rung))518 }),519 )520}521522pub fn hints_id(hints: Hints) -> String {523 case hints {524 Full -> "full"525 Best -> "best"526 OffHints -> "off"527 }528}529530pub fn hints_from_id(value: String) -> Hints {531 case value {532 "best" -> Best533 "off" -> OffHints534 _ -> Full535 }536}537538/// Buffer pixels per layout unit.539pub fn density_units(density: Density) -> Int {540 case density {541 Low -> 2542 Standard -> 3543 }544}545546pub fn density_from_units(units: Int) -> Density {547 case units {548 2 -> Low549 _ -> Standard550 }551}552553pub fn density_label(density: Density) -> String {554 case density {555 Low -> "LOW"556 Standard -> "DEFAULT"557 }558}