clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

main
klot.ski / src / klotski / families.gleam
families.gleam 5.2K
1/// A family is a board, a piece multiset and a goal — the unit of generation.2///3/// Difficulty inside a family comes from the distance field, not from changing4/// the pieces; changing the pieces is what makes a *different* family, with its5/// own state-space character. Hole count is the strongest single dial: three6/// cells of air move loosely, two seize up.7///8/// Every packing here but one is a *solved* one, with the big block already9/// seated. Sweeping outward from a solved state makes reachability free, which10/// matters more than it sounds: most hand-written packings strand the big block11/// in a component containing no solved state at all, and are not puzzles.12///13/// CLASSIC is the exception, and is allowed to be: its packing is the14/// traditional arrangement, which is a landmark in its own right at 116 unit15/// steps from home. It is safe because the sweep does not require a solved16/// packing, only a packing in a component that contains one — pass one finds the17/// component, pass two measures it from every seated state inside it.18///19/// None of these is a puzzle *start*. A start is whatever configuration the20/// survey hands back at the depth the ladder asked for, which is why there is no21/// list of them anywhere: a family carries a state space, not a set of levels.22import gleam/list23import gleam/result24import klotski/domain.{type Board, Board}2526/// A cell, one letter wide so that a packing below stays a picture of itself.27/// The values are the shape codes `domain` encodes with, not a private28/// numbering, so a grid can be read straight into `pieces_from_grid`.29///30/// One letter rather than a word because every cell has to be the same width or31/// the rows stop lining up, and a packing that does not line up is a list of32/// numbers again. `e` empty, `s` single, `u` upright, `r` recumbent, `g` goal.33const e = 03435const s = 13637const u = 23839const r = 34041const g = 44243pub type Family {44 Family(45 id: String,46 label: String,47 width: Int,48 height: Int,49 goal_x: Int,50 goal_y: Int,51 /// The solved packing, a row at a time. Nested rather than flat because the52 /// formatter keeps a list of lists one element to a line and reflows a list53 /// of integers to fill the width — so the shape survives `gleam format`54 /// here and did not before.55 rows: List(List(Int)),56 )57}5859pub fn board(family: Family) -> Board {60 Board(family.width, family.height, family.goal_x, family.goal_y)61}6263/// The packing flat, which is how everything downstream of here wants it: the64/// worker, the encoder and `pieces_from_grid` all read a board in row-major65/// order and none of them cares where the rows ended.66pub fn grid(family: Family) -> List(Int) {67 list.flatten(family.rows)68}6970/// Classic leads because it is what the word on the title screen promises. The71/// rest are ordered by ceiling, so the list past it is a ramp: 24 steps of72/// depth at the top, 178 at the bottom.73pub fn all() -> List(Family) {74 [75 // The traditional packing, and the only family whose own arrangement is a76 // landmark in its own right: 116 unit steps from home.77 Family("classic", "CLASSIC", 4, 5, 1, 3, [78 [u, g, g, u],79 [u, g, g, u],80 [u, r, r, u],81 [u, s, s, u],82 [s, e, e, s],83 ]),84 Family("narrow", "NARROW", 3, 5, 0, 3, [85 [r, r, s],86 [s, s, s],87 [s, s, s],88 [g, g, e],89 [g, g, e],90 ]),91 Family("compact", "COMPACT", 4, 4, 1, 2, [92 [r, r, r, r],93 [s, s, s, s],94 [e, g, g, e],95 [e, g, g, e],96 ]),97 // Four cells of air: it gives, and keeps giving. A wide state space with a98 // low ceiling, which is a different kind of easy from a small one.99 Family("span", "SPAN", 5, 4, 1, 2, [100 [u, u, u, u, s],101 [u, u, u, u, s],102 [e, g, g, e, s],103 [e, g, g, e, s],104 ]),105 Family("wide", "WIDE", 4, 5, 1, 3, [106 [r, r, r, r],107 [r, r, r, r],108 [s, s, s, s],109 [s, g, g, s],110 [e, g, g, e],111 ]),112 Family("yard", "YARD", 5, 4, 2, 2, [113 [u, r, r, r, r],114 [u, r, r, r, r],115 [s, s, g, g, e],116 [s, s, g, g, e],117 ]),118 // The same pieces as MIXED, leaving by the corner instead of the middle.119 // One goal cell moved, and the distance field is a different shape — the120 // cheapest way there is to get a genuinely new family.121 Family("corner", "CORNER", 4, 5, 0, 3, [122 [r, r, r, r],123 [u, r, r, u],124 [u, s, s, u],125 [g, g, s, s],126 [g, g, e, e],127 ]),128 Family("mixed", "MIXED", 4, 5, 1, 3, [129 [r, r, r, r],130 [u, r, r, u],131 [u, s, s, u],132 [s, g, g, s],133 [e, g, g, e],134 ]),135 ]136}137138pub fn count() -> Int {139 list.length(all())140}141142pub fn at(index: Int) -> Family {143 all()144 |> list.drop(index)145 |> list.first146 |> result.unwrap(fallback())147}148149pub fn by_id(id: String) -> Family {150 all()151 |> list.find(fn(family) { family.id == id })152 |> result.unwrap(fallback())153}154155pub fn index_of(id: String) -> Int {156 all()157 |> list.index_map(fn(family, index) { #(family.id, index) })158 |> list.key_find(id)159 |> result.unwrap(0)160}161162fn fallback() -> Family {163 Family("classic", "CLASSIC", 4, 5, 1, 3, [164 [u, g, g, u],165 [u, g, g, u],166 [u, r, r, u],167 [u, s, s, u],168 [s, e, e, s],169 ])170}