- commit
- 77d6d0748cb520b2ee8c2c85dc3b5a3c3692eb01
- parent
- a63365f982a8454526693dd3bed53a6d995463c9
- author
- Dami <dami.etoile@gmail.com>
| M | .gitignore | 9 | + |
| M | README.md | 69 | +- |
| A | assets/ | 5 | + |
| A | assets/ | bin | |
| A | assets/ | bin | |
| A | assets/ | bin | |
| A | assets/ | bin | |
| A | assets/ | bin | |
| A | assets/ | bin | |
| A | assets/ | 16 | + |
| A | assets/ | 157 | ++++ |
| A | assets/ | 333 | ++++++++ |
| A | assets/ | 49 | + |
| A | gleam.toml | 34 | + |
| A | manifest.toml | 52 | + |
| A | src/ | 1331 | ++++++++++++++++++++++++++++++ |
| A | src/ | 97 | ++ |
| A | src/ | 129 | +++ |
| A | src/ | 109 | ++ |
| A | src/ | 274 | ++++++ |
| A | src/ | 349 | ++++++++ |
| A | test/ | 48 | + |
| A | test/ | 20 | + |
| A | test/ | 5 | + |
@@ -1,14 +1,73 @@
11 # Klotski22 3-An installable, offline sliding-block puzzle game with generated boards, rendered in one bit.3+An installable, offline sliding-block puzzle game built with Gleam and Lustre.44 5-`mockup/` is a **working visual and UX mockup**: a real, playable, installable PWA used to settle6-the look and the feel before the production app is built. It is not scaffolding to be extended —7-it is a thing to be judged, argued with, and then rebuilt properly (see *Next* below).5+The production application now lives at the repository root. `mockup/` is locked as a working6+proof of concept and design record: it remains playable, but production code does not import it7+or extend it. The port preserves its interaction model and visual language while treating its8+implementation and forward-looking technical claims as historical context rather than contracts.9+10+## Production app11+12+The app is a client-side Lustre application targeting JavaScript. It requires no backend and no13+network connection after the first load.14+15+```sh16+# Development server at http://localhost:123417+gleam run -m lustre/dev start18+19+# Type-check and test20+gleam check --target javascript21+gleam test --target javascript22+23+# Offline-capable production bundle in dist/24+gleam run -m lustre/dev build25+```26+27+The production architecture keeps the mockup's UI/UX as its acceptance reference while changing28+the implementation where a Lustre application benefits from a different boundary:29+30+- `src/klotski/domain.gleam` is the pure puzzle core. Movement, collision, undo, and goal checks31+ have no browser or rendering dependencies.32+- `src/klotski.gleam` is the Lustre Model–View–Update application. Screens, settings, pointer and33+ keyboard input, progress, and effects are explicit messages and model transitions.34+- `src/klotski/catalog.gleam` contains 32 published boards across the eight piece families. The35+ family graph is surveyed on demand by `assets/solver-worker.js`, off the UI thread, so `TO GOAL`36+ remains an exact distance after every legal move and the survey screen stays responsive.37+- `src/klotski/persistence.gleam` owns versioned local progress and preferences. The small38+ `src/klotski/browser_ffi.mjs` adapter owns browser-only capabilities: local storage, the survey39+ worker, install prompting, tones, and haptics.40+- `assets/` contains the UI stylesheet and PWA shell. The service worker precaches the shell and41+ compiled Lustre bundle—including the survey worker—so an installed app and a previously loaded42+ page can cold-reload and calculate exact distances offline.43+- The print treatments are CSS screens applied to declarative block elements. The port keeps the44+ one-bit/print character without coupling Lustre updates to a full-frame imperative canvas and45+ pixel diffusion pipeline. "Atkinson" is therefore a visual treatment in production, not a claim46+ that the browser is running the mockup's exact error-diffusion compositor.47+- The proof of concept's open-ended ladder is treated as a product-direction experiment rather48+ than an architectural requirement. This production version currently publishes four curated49+ stages per family; its minimums and live remaining distances are nevertheless solver-derived.50+51+The UI supports direct dragging, the mockup's single-exit tap affordance, arrow-key movement,52+undo/reset, exact hints, eight puzzle families, four published stages per family, the survey/card/53+play/solved/record flow, install prompting, versioned local records, palettes, screen treatments,54+reduced-motion behavior, tones, and haptics.55+56+## Offline PWA57+58+`gleam run -m lustre/dev build` generates `dist/index.html` and `dist/klotski.js`, then copies the59+manifest, service worker, solver worker, fonts, icons, and stylesheet. Serve `dist/` from an HTTPS60+origin (or localhost) to install it. The service worker uses a versioned cache, removes superseded61+caches on activation, refreshes same-origin resources while online, and falls back to cached62+resources or the app shell offline.863 964 ---1065 11-## The direction66+## Proof-of-concept design record67+68+Everything below documents decisions made in `mockup/`. It is preserved because it explains the69+desired look and feel and the experiments behind it; it does not prescribe the production Gleam70+architecture.1271 1372 **Atkinson dithering.** Bill Atkinson wrote the dithering in QuickDraw for the Lisa and the1473 Macintosh, and it is the reason images from 1984-88 look the way they do. It is error diffusion:Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -0,0 +1,16 @@
1+{2+ "name": "Klotski",3+ "short_name": "Klotski",4+ "description": "Offline sliding-block puzzles, rendered like a tiny one-bit print shop.",5+ "start_url": "/",6+ "scope": "/",7+ "display": "standalone",8+ "orientation": "any",9+ "background_color": "#e8f4f2",10+ "theme_color": "#e8f4f2",11+ "icons": [12+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },13+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },14+ { "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }15+ ]16+}@@ -0,0 +1,157 @@
1+const SHAPE_W = [0, 1, 1, 2, 2];2+const SHAPE_H = [0, 1, 2, 1, 2];3+const DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]];4+const surveys = new Map();5+6+function board(width, height) {7+ const n = width * height;8+ return { width, height, n, scratch: new Uint8Array(n), seen: new Uint8Array(n) };9+}10+11+function encode(b, grid) {12+ let key = 0;13+ for (let index = b.n - 1; index >= 0; index--) key = key * 5 + grid[index];14+ return key;15+}16+17+function decode(b, key, output) {18+ for (let index = 0; index < b.n; index++) {19+ const code = key % 5;20+ output[index] = code;21+ key = (key - code) / 5;22+ }23+ return output;24+}25+26+function piecesOf(b, grid, output) {27+ output.length = 0;28+ b.seen.fill(0);29+ for (let y = 0; y < b.height; y++) for (let x = 0; x < b.width; x++) {30+ const index = y * b.width + x;31+ const code = grid[index];32+ if (!code || b.seen[index]) continue;33+ const width = SHAPE_W[code], height = SHAPE_H[code];34+ for (let dy = 0; dy < height; dy++) for (let dx = 0; dx < width; dx++) {35+ b.seen[(y + dy) * b.width + x + dx] = 1;36+ }37+ output.push({ x, y, width, height, code });38+ }39+ return output;40+}41+42+function canMove(b, grid, piece, dx, dy) {43+ const nx = piece.x + dx, ny = piece.y + dy;44+ if (nx < 0 || ny < 0 || nx + piece.width > b.width || ny + piece.height > b.height) return false;45+ for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {46+ const cx = nx + x, cy = ny + y;47+ if (cx >= piece.x && cx < piece.x + piece.width && cy >= piece.y && cy < piece.y + piece.height) continue;48+ if (grid[cy * b.width + cx]) return false;49+ }50+ return true;51+}52+53+function applyMove(b, grid, piece, dx, dy) {54+ for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {55+ grid[(piece.y + y) * b.width + piece.x + x] = 0;56+ }57+ for (let y = 0; y < piece.height; y++) for (let x = 0; x < piece.width; x++) {58+ grid[(piece.y + dy + y) * b.width + piece.x + dx + x] = piece.code;59+ }60+}61+62+function neighbours(b, key, output, pieces) {63+ output.length = 0;64+ const grid = decode(b, key, b.scratch);65+ piecesOf(b, grid, pieces);66+ for (const piece of pieces) for (const [dx, dy] of DIRS) {67+ if (!canMove(b, grid, piece, dx, dy)) continue;68+ applyMove(b, grid, piece, dx, dy);69+ output.push(encode(b, grid));70+ applyMove(b, grid, { ...piece, x: piece.x + dx, y: piece.y + dy }, -dx, -dy);71+ }72+ return output;73+}74+75+function seated(b, key, goalX, goalY) {76+ const grid = decode(b, key, b.scratch);77+ return grid[goalY * b.width + goalX] === 478+ && grid[goalY * b.width + goalX + 1] === 479+ && grid[(goalY + 1) * b.width + goalX] === 480+ && grid[(goalY + 1) * b.width + goalX + 1] === 4;81+}82+83+function runSurvey(message) {84+ const b = board(message.width, message.height);85+ const start = encode(b, Uint8Array.from(message.solvedGrid));86+ const seen = new Set([start]);87+ const dist = new Map();88+ const neighboursBuffer = [], piecesBuffer = [];89+ let queue = [start], head = 0, phase = 1, eccentricity = 0;90+ let lastReport = 0;91+92+ const tick = () => {93+ const deadline = performance.now() + 10;94+ if (phase === 1) {95+ while (head < queue.length && performance.now() < deadline) {96+ const key = queue[head++];97+ neighbours(b, key, neighboursBuffer, piecesBuffer);98+ for (const next of neighboursBuffer) if (!seen.has(next)) {99+ seen.add(next);100+ queue.push(next);101+ }102+ }103+ if (head >= queue.length) {104+ queue = [];105+ head = 0;106+ for (const key of seen) if (seated(b, key, message.goalX, message.goalY)) {107+ dist.set(key, 0);108+ queue.push(key);109+ }110+ phase = 2;111+ }112+ } else {113+ while (head < queue.length && performance.now() < deadline) {114+ const key = queue[head++];115+ const depth = dist.get(key) + 1;116+ neighbours(b, key, neighboursBuffer, piecesBuffer);117+ for (const next of neighboursBuffer) if (seen.has(next) && !dist.has(next)) {118+ dist.set(next, depth);119+ queue.push(next);120+ if (depth > eccentricity) eccentricity = depth;121+ }122+ }123+ if (head >= queue.length) {124+ surveys.set(message.familyId, { dist, eccentricity, count: seen.size });125+ self.postMessage({126+ type: "ready",127+ familyId: message.familyId,128+ distance: dist.get(message.startState) ?? 0,129+ eccentricity,130+ count: seen.size,131+ });132+ return;133+ }134+ }135+ if (performance.now() - lastReport > 50) {136+ lastReport = performance.now();137+ self.postMessage({ type: "progress", familyId: message.familyId, count: phase === 1 ? seen.size : dist.size, phase });138+ }139+ setTimeout(tick, 0);140+ };141+ tick();142+}143+144+self.addEventListener("message", ({ data }) => {145+ if (data.type === "survey") {146+ const cached = surveys.get(data.familyId);147+ if (cached) {148+ self.postMessage({ type: "ready", familyId: data.familyId, distance: cached.dist.get(data.startState) ?? 0, eccentricity: cached.eccentricity, count: cached.count });149+ } else {150+ runSurvey(data);151+ }152+ }153+ if (data.type === "distance") {154+ const survey = surveys.get(data.familyId);155+ self.postMessage({ type: "distance", requestId: data.requestId, state: data.state, distance: survey?.dist.get(data.state) ?? 0 });156+ }157+});@@ -0,0 +1,333 @@
1+@font-face { font-family: Silk; src: url("/fonts/silk-bold.woff2") format("woff2"); font-display: swap; }2+@font-face { font-family: SilkR; src: url("/fonts/silk.woff2") format("woff2"); font-display: swap; }3+@font-face { font-family: Mono; src: url("/fonts/mono.woff2") format("woff2"); font-display: swap; }4+5+:root {6+ font-family: Mono, ui-monospace, monospace;7+ color-scheme: light dark;8+ --ink: #2b2b22;9+ --paper: #e8f4f2;10+ --ground: #c9e6e1;11+ --ground-dot: #78918d;12+ --red: #bd4037;13+ --green: #328456;14+ --blue: #3169a5;15+ --orange: #c4772e;16+ --unit: clamp(1px, 0.24vw, 2px);17+}18+19+* { box-sizing: border-box; }20+html, body, #app { width: 100%; height: 100%; margin: 0; }21+body {22+ overflow: hidden;23+ overscroll-behavior: none;24+ background: var(--ground);25+ color: var(--ink);26+ user-select: none;27+ -webkit-user-select: none;28+ -webkit-touch-callout: none;29+ -webkit-tap-highlight-color: transparent;30+}31+button { -webkit-tap-highlight-color: transparent; }32+33+.app {34+ width: 100%;35+ height: 100dvh;36+ overflow: hidden;37+ color: var(--ink);38+ background-color: var(--ground);39+ background-image: radial-gradient(circle, var(--ground-dot) 0 1px, transparent 1.25px);40+ background-size: 8px 8px;41+}42+43+.palette-macintosh { --ink:#2b2b22; --paper:#e8f4f2; --ground:#c9e6e1; --ground-dot:#78918d; }44+.palette-paper { --ink:#302c21; --paper:#ebe5ce; --ground:#d8cfad; --ground-dot:#8e8568; }45+.palette-night { --ink:#dce4e2; --paper:#1c2028; --ground:#12161d; --ground-dot:#54616a; --red:#ef6a5c; --green:#55b783; --blue:#5f9bdb; --orange:#e69a50; }46+.palette-amoled { --ink:#dcf2ff; --paper:#000; --ground:#000; --ground-dot:#27404d; --red:#ff6358; --green:#56ca85; --blue:#65a8ff; --orange:#f3a248; }47+.palette-auto { --ink:#2b2b22; --paper:#e8f4f2; --ground:#c9e6e1; --ground-dot:#78918d; }48+@media (prefers-color-scheme: dark) {49+ .palette-auto { --ink:#dce4e2; --paper:#1c2028; --ground:#12161d; --ground-dot:#54616a; --red:#ef6a5c; --green:#55b783; --blue:#5f9bdb; --orange:#e69a50; }50+}51+52+.screen { width: 100%; height: 100%; overflow: hidden; }53+.window {54+ color: var(--ink);55+ background: var(--paper);56+ border: calc(1px + var(--unit)) solid var(--ink);57+}58+59+button {60+ appearance: none;61+ color: var(--ink);62+ background: var(--paper);63+ border: 2px solid var(--ink);64+ border-radius: 0;65+ padding: 0.62rem 0.85rem;66+ font: 0.72rem/1 Mono, monospace;67+ letter-spacing: 0.1em;68+ cursor: pointer;69+}70+button:active, button.armed { color: var(--paper); background: var(--ink); }71+button:focus-visible { outline: 3px double var(--ink); outline-offset: 3px; }72+button:disabled { opacity: 0.32; cursor: default; }73+button.ghost { border-color: transparent; background: transparent; }74+button.compact { padding: 0.35rem 0.5rem; }75+76+.landing, .card-screen {77+ width: 100%; height: 100%; border: 0; padding: max(1.2rem, env(safe-area-inset-top)) 1.2rem max(1.2rem, env(safe-area-inset-bottom));78+ background: transparent; display: grid; place-items: center;79+}80+.landing:active, .card-screen:active { color: var(--ink); background: transparent; }81+.landing-window {82+ width: min(88vw, 342px); height: min(66dvh, 558px);83+ display: flex; flex-direction: column; align-items: center; justify-content: flex-start;84+ padding: 2.7rem 1.2rem 2.8rem;85+ transform:translateY(-1.15rem);86+}87+.landing h1, .stage-card h1, .solved-window h1 {88+ margin: 0; font: clamp(2.35rem, 11vw, 4.6rem)/1 Silk, monospace; letter-spacing: 0.07em;89+}90+.landing h1 { font-size:clamp(2.1rem,10.8vw,2.65rem); letter-spacing:0.035em; }91+.subtitle { margin: 0.42rem 0 0; font: 0.62rem SilkR, monospace; letter-spacing: 0.15em; }92+.hairline { flex:0 0 2px; width: 100%; height: 2px; background: var(--ink); opacity: 0.55; }93+.landing-window .hairline { margin-top:1.15rem; }94+.tap-prompt { font: 0.9rem Silk, monospace; animation: blink 1.4s steps(1, end) infinite; }95+@keyframes blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }96+@media (prefers-reduced-motion: reduce) { .tap-prompt { animation: none; } }97+98+.menu-bar {99+ height: max(2.65rem, calc(2rem + env(safe-area-inset-top)));100+ padding: env(safe-area-inset-top) max(0.9rem, env(safe-area-inset-right)) 0 max(0.9rem, env(safe-area-inset-left));101+ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;102+ background: var(--paper); border-bottom: 1px solid var(--ink);103+}104+.menu-bar > :first-child { justify-self:start; }105+.menu-brand { display:flex; align-items:center; gap:0.45rem; }106+.menu-brand strong { font: 1.02rem Silk, monospace; letter-spacing: 0.1em; }107+.signature { position:relative; display:block; width:1.7rem; height:1.7rem; border:2px solid var(--ink); border-radius:50%; }108+.signature-mark { position:absolute; width:0; height:0; left:0.45rem; top:0.35rem; border-left:0.34rem solid transparent; border-right:0.34rem solid transparent; border-bottom:0.62rem solid var(--red); transform:rotate(32deg); }109+.menu-title { min-width:1px; }110+.menu-title { font:1.2rem Silk,monospace; letter-spacing:0.18em; }111+.menu-actions { display: flex; justify-content: flex-end; gap: 0.4rem; }112+.icon-button { width: 1.8rem; height: 1.8rem; padding: 0; display: grid; place-items: center; font-size: 0.8rem; }113+.icon-button::before, .icon-button::after { content:""; display:block; background:currentColor; }114+.icon-record::before { width:2px; height:8px; box-shadow:4px -3px 0 currentColor,8px -6px 0 currentColor; transform:translate(-4px,3px); }115+.icon-settings::before { width:10px; height:1px; box-shadow:0 5px 0 currentColor; }116+.icon-settings::after { position:absolute; width:2px; height:5px; box-shadow:4px 5px 0 currentColor; transform:translate(-2px,-3px); }117+.icon-button { position:relative; }118+119+.atlas {120+ height: calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top))));121+ overflow-y: auto; scrollbar-width: thin; overscroll-behavior: contain;122+ display: grid; grid-template-columns: minmax(0, 470px); align-content: start; justify-content: center;123+ gap: 0.75rem; padding: 0.85rem max(0.85rem, env(safe-area-inset-right)) max(1.5rem, env(safe-area-inset-bottom));124+}125+.family-card {126+ width: 100%; min-height: 8.1rem; padding: 0.4rem 0.7rem; text-align: left;127+ display: grid; grid-template-columns: 39% 1fr; gap: 0.65rem; align-items: center;128+}129+.family-card:active { color: var(--ink); background: var(--paper); }130+.family-copy h2 { margin: 0 0 0.75rem; font: clamp(1rem, 5vw, 1.35rem) Silk, monospace; letter-spacing: 0.08em; }131+.family-copy p { margin: 0 0 0.6rem; display: flex; justify-content: space-between; gap: 1rem; font: 0.68rem SilkR, monospace; }132+.family-copy p > :last-child { margin-left:auto; }133+.family-copy small { display: block; margin-top: 0.4rem; font-size: 0.56rem; opacity: 0.65; }134+.family-copy .begin-label { margin-top:1.15rem; font-size:0.64rem; }135+.family-copy:has(.begin-label) { align-self:stretch; position:relative; }136+.family-copy:has(.begin-label) h2 { position:absolute; top:0.55rem; left:0; }137+.family-copy:has(.begin-label) p { position:absolute; top:2.8rem; left:0; right:0; }138+.family-copy:has(.begin-label) .begin-label { position:absolute; top:4.7rem; left:0; margin:0; }139+.mini-board-wrap { display: grid; place-items: center; min-width: 0; }140+.mini-board-wrap .board { width:min(100%,7rem,calc(7.15rem * var(--cols) / var(--rows))); border:0; box-shadow:none; padding:0; background:transparent; }141+.mini-board-wrap .exit { display:none; }142+.depth-track { position: relative; height: 9px; border-top: 2px solid var(--ink); }143+.depth-track::before, .depth-track::after { content:""; position:absolute; top:-5px; width:2px; height:7px; background:var(--ink); }144+.depth-track::after { right:0; }145+.depth-marker { position: absolute; top: -7px; left: calc((100% - 5px) * var(--progress) / var(--total)); width: 5px; height: 11px; background: var(--ink); }146+147+.stage-card {148+ width: min(88vw, 342px); height:min(25dvh,211px); padding: 1.25rem;149+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;150+}151+.stage-card p { margin: 0; font: 0.8rem SilkR, monospace; letter-spacing: 0.12em; }152+.stage-card h1 { font-size: clamp(2rem, 10vw, 3.8rem); }153+154+.survey-layout { width:100%; height:100%; display:grid; place-items:center; padding:1rem; }155+.survey-window { width:min(88vw,342px); min-height:18rem; padding:2rem; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1.2rem; text-align:center; }156+.survey-window p, .survey-window strong { margin:0; }157+.survey-window strong { font:clamp(1.7rem,9vw,3rem)/1 Silk,monospace; }158+.survey-window > p:last-child { font:0.62rem SilkR,monospace; letter-spacing:0.1em; opacity:0.7; }159+.survey-pulse { width:3rem; height:3rem; border:2px solid var(--ink); animation:survey-pulse 0.8s steps(4,end) infinite; }160+@keyframes survey-pulse { 50% { background:var(--ink); transform:rotate(90deg); } }161+@media (prefers-reduced-motion:reduce) { .survey-pulse { animation:none; } }162+163+.game-layout {164+ height: calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top))));165+ padding: clamp(1.25rem, 9.8dvh, 5.2rem) max(0.85rem, env(safe-area-inset-right)) max(0.8rem, env(safe-area-inset-bottom));166+ display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 2.15rem; justify-items: center;167+}168+.board-window {169+ min-height: 0; width: min(87vw, 470px); padding: clamp(0.8rem, 2vw, 1.15rem);170+ display: flex; flex-direction: column; align-items: center; justify-content: center;171+ box-shadow:inset 0 0 0 4px var(--paper),inset 0 0 0 6px var(--ink),inset 0 0 0 9px var(--paper);172+}173+.board-title { margin: 0.8rem 0 0; font: clamp(1rem, 4vw, 1.5rem) Silk, monospace; letter-spacing: 0.12em; }174+.control-window { width: min(93vw, 470px); min-height:14.4rem; padding:0.45rem 1rem 0.85rem; display:grid; grid-template-rows:2.75rem 2.5rem 2.5rem; gap:1.85rem; align-content:start; }175+.hud { display: flex; align-items: center; justify-content: space-evenly; gap: 0.65rem; }176+.stat { min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.25rem; text-align: center; }177+.stat strong { font: clamp(1.1rem, 4vw, 1.5rem) Silk, monospace; }178+.stat span { font-size: clamp(0.47rem, 1.8vw, 0.58rem); letter-spacing: 0.08em; opacity: 0.72; }179+.actions { display: flex; justify-content: center; gap: 0.75rem; }180+.interaction-hint { display:none; }181+.gauge { position:relative; height:2.75rem; font-size:0.48rem; opacity:0.72; }182+.gauge > span { position:absolute; top:2rem; }183+.gauge > span:first-child { left:13%; }184+.gauge > span:last-child { right:13%; }185+.gauge > div { position:absolute; left:12%; right:12%; top:0.45rem; height:9px; border-top:2px solid var(--ink); background-image:linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)),linear-gradient(var(--ink),var(--ink)); background-size:1px 5px; background-repeat:no-repeat; background-position:25% 0,50% 0,75% 0,100% 0; }186+.gauge i { position:absolute; top:-6px; left:max(0px,calc((100% - 5px) * (var(--start) - var(--gap)) / var(--start))); width:5px; height:10px; background:var(--ink); }187+.game-screen .hud { transform:translateY(-0.75rem); }188+.game-screen .actions { transform:translateY(0.65rem); }189+190+.board {191+ --gap: 3px;192+ position: relative; isolation: isolate;193+ width: min(72vw, 42dvh, 350px);194+ aspect-ratio: var(--cols) / var(--rows);195+ display: grid;196+ grid-template-columns: repeat(var(--cols), minmax(0, 1fr));197+ grid-template-rows: repeat(var(--rows), minmax(0, 1fr));198+ background: var(--paper);199+ border: 2px solid var(--ink);200+ box-shadow: inset 0 0 0 3px var(--paper);201+ padding: 4px;202+ touch-action: none;203+}204+.exit { position:absolute; z-index:3; left:calc((100% - 8px) * var(--goal-x) / var(--cols) + 4px); bottom:-7px; width:calc((100% - 8px) * 2 / var(--cols)); height:10px; background:var(--paper); border-left:2px solid var(--ink); border-right:2px solid var(--ink); }205+.piece {206+ --piece: var(--orange);207+ position: relative; z-index: 2; min-width: 0; min-height: 0;208+ grid-column: calc(var(--x) + 1) / span var(--w);209+ grid-row: calc(var(--y) + 1) / span var(--h);210+ margin: var(--gap); padding: 0;211+ overflow: hidden; border: 2px solid var(--ink); background-color: var(--paper);212+ box-shadow: 3px 3px 0 var(--ink); touch-action: none;213+ transform:translate(var(--drag-x,0),var(--drag-y,0));214+ will-change:transform;215+}216+.piece-single { --piece:var(--orange); }217+.piece-upright { --piece:var(--blue); }218+.piece-recumbent { --piece:var(--green); }219+.piece-goal { --piece:var(--red); }220+.piece::before { content:""; position:absolute; inset:0; opacity:0.9; pointer-events:none; }221+.goal-mark { position:absolute; z-index:2; left:50%; top:50%; width:52%; aspect-ratio:1; transform:translate(-50%,-50%); border:clamp(2px,0.65vw,4px) solid var(--piece); border-radius:50%; background-color:var(--paper); background-image:radial-gradient(circle,var(--piece) 0 1px,transparent 1.4px); background-size:5px 5px; box-shadow:inset 0 0 0 2px var(--paper); }222+.interactive .piece { cursor: grab; }223+.interactive .piece:active { cursor: grabbing; color:inherit; background-color:var(--paper); box-shadow:2px 2px 0 var(--ink); }224+.mini-board-wrap .piece { --gap:1px; border-width:1px; box-shadow:1px 1px 0 var(--ink); }225+.mini-board-wrap .goal-mark { border-width:1px; }226+.landing-window .board { width:min(56vw, 218px); margin-top:1.85rem; }227+.landing-window .tap-prompt { margin-top:3.65rem; }228+229+[data-density="low"] .piece { --grain: 5px; }230+[data-density="default"] .piece { --grain: 3px; }231+[data-shading="atkinson"] .piece::before { background-image:radial-gradient(circle at 25% 25%, var(--piece) 0 1px, transparent 1.4px); }232+[data-shading="atkinson"] .piece { background-size: var(--grain, 3px) var(--grain, 3px); }233+[data-shading="atkinson"] .piece::before { background-size:var(--grain,3px) var(--grain,3px); }234+[data-shading="ordered"] .piece::before { background-image:conic-gradient(from 90deg, var(--piece) 25%, transparent 0 75%, var(--piece) 0); }235+[data-shading="ordered"] .piece { background-size: calc(var(--grain, 3px) * 2) calc(var(--grain, 3px) * 2); }236+[data-shading="ordered"] .piece::before { background-size:calc(var(--grain,3px) * 2) calc(var(--grain,3px) * 2); }237+[data-shading="benday"] .piece::before { background-image:radial-gradient(circle, var(--piece) 0 30%, transparent 34%); }238+[data-shading="benday"] .piece { background-size: calc(var(--grain, 3px) * 3) calc(var(--grain, 3px) * 3); }239+[data-shading="benday"] .piece::before { background-size:calc(var(--grain,3px) * 3) calc(var(--grain,3px) * 3); }240+[data-shading="lines"] .piece::before { background-image:repeating-linear-gradient(0deg, var(--piece) 0 1px, transparent 1px calc(var(--grain, 3px) * 2)); }241+[data-shading="diagonal"] .piece::before { background-image:repeating-linear-gradient(45deg, var(--piece) 0 1px, transparent 1px calc(var(--grain, 3px) * 2)); }242+[data-shading="crosshatch"] .piece::before { background-image:repeating-linear-gradient(45deg, var(--piece) 0 1px, transparent 1px 7px), repeating-linear-gradient(-45deg, var(--piece) 0 1px, transparent 1px 7px); }243+[data-shading="comic"] .piece::before { background-image:radial-gradient(circle, var(--piece) 0 27%, transparent 31%), radial-gradient(circle, var(--paper) 0 18%, transparent 22%); }244+[data-shading="comic"] .piece { background-size: 9px 9px, 11px 11px; background-position:0 0, 4px 3px; }245+[data-shading="comic"] .piece::before { background-size:9px 9px,11px 11px; background-position:0 0,4px 3px; }246+[data-shading="solid"] .piece::before { background:var(--piece); }247+248+.solved-window { position:relative; min-height:0; width:min(87vw,470px); padding:1rem; overflow:hidden; box-shadow:inset 0 0 0 4px var(--paper),inset 0 0 0 6px var(--ink),inset 0 0 0 9px var(--paper); }249+.solved-window p, .solved-window h1, .solved-window small { position:absolute; margin:0; left:0; right:0; text-align:center; }250+.solved-window > p { top:4.55rem; font:0.8rem SilkR,monospace; letter-spacing:0.45em; }251+.solved-window h1 { top:6.1rem; font-size:clamp(1.5rem,7vw,2.2rem); }252+.solved-window small { bottom:1.35rem; font:clamp(0.9rem,5vw,1.3rem) Silk,monospace; letter-spacing:0.13em; }253+.trophy { position:absolute; left:50%; top:1rem; width:2.6rem; height:2.7rem; transform:translateX(-50%); }254+.trophy::before,.trophy::after { content:""; position:absolute; z-index:0; top:0.35rem; width:0.65rem; height:0.9rem; border:3px solid var(--ink); background:var(--paper); }255+.trophy::before { left:0; border-right:0; }256+.trophy::after { right:0; border-left:0; }257+.trophy-cup { position:absolute; z-index:1; left:0.48rem; top:0; width:1.65rem; height:1.65rem; background:#c99725; border:3px solid var(--ink); clip-path:polygon(0 0,100% 0,82% 72%,62% 88%,38% 88%,18% 72%); }258+.trophy-stem { position:absolute; left:1.15rem; top:1.55rem; width:0.32rem; height:0.62rem; background:var(--ink); }259+.trophy-base { position:absolute; left:0.72rem; top:2.08rem; width:1.2rem; height:0.38rem; background:#c99725; border:3px solid var(--ink); }260+.run-chart { position:absolute; left:11%; right:11%; top:8.55rem; height:14.25rem; display:flex; align-items:end; border-bottom:2px solid var(--ink); overflow:hidden; }261+.run-chart span { flex:1 1 1px; min-width:1px; height:calc(100% * var(--bar) / var(--peak)); max-height:100%; background:var(--ink); }262+.run-chart span { box-shadow:1px 0 0 var(--ink); }263+.run-chart::after { content:""; position:absolute; z-index:2; left:0; top:0; width:135%; border-top:2px dashed #c99725; transform-origin:left center; transform:rotate(42deg); }264+.record-hud { flex-wrap:wrap; }265+.won-screen .control-window { position:relative; display:block; padding:1rem; }266+.won-screen .record-hud { position:absolute; left:1rem; right:1rem; top:2.15rem; justify-content:space-between; }267+.won-screen .control-window > .actions { position:absolute; left:0; right:0; bottom:2rem; transform:none; }268+269+.stats-grid { height:calc(100% - max(2.65rem, calc(2rem + env(safe-area-inset-top)))); overflow:auto; padding:0.85rem; display:grid; gap:0.75rem; align-content:start; justify-content:center; grid-template-columns:minmax(0,470px); }270+.level-panel { position:relative; height:6.9rem; padding:1rem; }271+.level-panel p { margin:0; position:absolute; top:1rem; font:0.68rem SilkR,monospace; letter-spacing:0.18em; opacity:0.7; }272+.level-panel .level-label { left:1.2rem; }273+.level-panel .xp-label { right:1.2rem; }274+.level-panel > strong { position:absolute; left:1.2rem; top:2.8rem; font:2.45rem Silk,monospace; }275+.level-track { position:absolute; left:38%; right:9%; top:3.8rem; height:1rem; border:2px solid var(--ink); }276+.level-track::before { content:""; display:block; width:calc(100% * var(--level-progress) / 45); height:100%; background:var(--ink); }277+.level-panel small { position:absolute; right:9%; top:5.35rem; font-size:0.55rem; opacity:0.6; letter-spacing:0.1em; }278+.lifetime-panel { height:8.45rem; padding:0.8rem; }279+.record-cells { width:100%; height:100%; display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(2,1fr); }280+.record-cells .stat strong { font-size:1.05rem; }281+.record-cells .stat span { font-size:0.58rem; }282+.family-table { min-height:13.8rem; padding:0.75rem 0.95rem; }283+.family-table-head,.family-table-body > div { display:grid; grid-template-columns:1fr 4rem 4rem 6.4rem; align-items:center; }284+.family-table-head { margin-bottom:0.65rem; font-size:0.57rem; letter-spacing:0.15em; opacity:0.6; }285+.family-table-head span:not(:first-child),.family-table-body span { text-align:right; }286+.family-table-body { display:grid; gap:0.35rem; }287+.family-table-body > div { font:0.62rem SilkR,monospace; opacity:0.48; }288+.family-table-body strong { font:0.65rem Silk,monospace; letter-spacing:0.08em; }289+.recent-panel { position:relative; height:14rem; padding:0.8rem 0.95rem; }290+.recent-panel p { margin:0; font-size:0.58rem; letter-spacing:0.15em; opacity:0.6; }291+.recent-panel span { position:absolute; left:0; right:0; top:58%; text-align:center; font-size:0.58rem; letter-spacing:0.1em; opacity:0.48; }292+.recent-chart { position:absolute; left:0.95rem; right:0.95rem; top:2rem; bottom:1.25rem; display:flex; align-items:end; border-bottom:2px solid var(--ink); gap:2px; }293+.recent-chart i { width:6px; height:calc(100% * var(--depth) / 42); min-height:2px; background:var(--ink); }294+.record-footer { min-height:2.2rem; display:grid; place-items:center; font-size:0.57rem; letter-spacing:0.1em; color:color-mix(in srgb,var(--ink) 65%,var(--paper)); }295+296+.sheet-scrim { position:fixed; z-index:20; inset:0; display:grid; place-items:center; background:var(--paper); }297+.settings-sheet { position:relative; width:100%; height:100dvh; overflow:auto; border:0; padding:max(2.2rem,env(safe-area-inset-top)) 1rem max(1.2rem,env(safe-area-inset-bottom)); display:flex; flex-direction:column; gap:1.35rem; align-items:center; justify-content:center; transform:translateY(-0.55rem); }298+.setting-group { display:grid; gap:0.7rem; justify-items:center; }299+.setting-group > p { margin:0; font-size:0.58rem; letter-spacing:0.16em; opacity:0.65; }300+.setting-row { display:flex; flex-wrap:wrap; justify-content:center; gap:0.45rem; }301+.setting-group:first-child .setting-row { max-width:17.8rem; }302+.setting-row button { font-size:0.68rem; padding:0.72rem 1rem; }303+.setting-group:first-child .setting-row button { padding-inline:1.1rem; }304+.shade-menu { position:relative; }305+.shade-menu > button { width:10.45rem; padding:0.72rem 0.9rem; text-align:left; }306+.shade-list { position:absolute; z-index:2; left:50%; transform:translateX(-50%); top:calc(100% - 2px); width:max-content; min-width:10rem; display:grid; background:var(--paper); border:2px solid var(--ink); box-shadow:3px 3px 0 var(--ink); }307+.shade-list button { border:0; text-align:left; }308+.credit { margin:0; font-size:0.48rem; line-height:1.8; letter-spacing:0.06em; opacity:0.55; text-align:center; }309+310+@media (min-width: 760px) {311+ .atlas { grid-template-columns:repeat(2,minmax(300px,470px)); }312+ .stats-grid { grid-template-columns:minmax(0,470px) minmax(0,470px); }313+ .settings-sheet { width:100%; }314+}315+316+@media (orientation: landscape) and (max-height: 800px) and (min-width:760px) {317+ .game-layout { grid-template-columns:minmax(0,1.4fr) minmax(260px,0.75fr); grid-template-rows:1fr; align-items:stretch; }318+ .board-window, .control-window { width:100%; }319+ .board { width:min(48vw,65dvh,330px); }320+ .landing-window { width:min(64vw,768px); height:min(66dvh,462px); transform:translateY(-0.75rem); padding:1.38rem 1.75rem 2.25rem; display:flex; text-align:center; }321+ .landing-window h1 { font-size:4.7rem; }322+ .landing-window .subtitle { margin-top:1.08rem; font-size:1.5rem; }323+ .landing-window .hairline { margin-top:2.5rem; }324+ .landing-window .board { width:min(10vw,120px); margin-top:1.38rem; }325+ .landing-window .tap-prompt { margin-top:1.2rem; }326+ .solved-window { width:100%; }327+}328+329+@media (max-height: 620px) and (orientation: portrait) {330+ .interaction-hint { display:none; }331+ .control-window { gap:0.45rem; padding:0.55rem; }332+ .game-layout { gap:0.55rem; }333+}@@ -0,0 +1,49 @@
1+const VERSION = "klotski-gleam-v4";2+const CORE = [3+ "/",4+ "/klotski.js",5+ "/styles.css",6+ "/bootstrap.js",7+ "/solver-worker.js",8+ "/manifest.webmanifest",9+ "/fonts/silk.woff2",10+ "/fonts/silk-bold.woff2",11+ "/fonts/mono.woff2",12+ "/icons/icon-192.png",13+ "/icons/icon-512.png",14+ "/icons/icon-maskable.png",15+];16+17+self.addEventListener("install", (event) => {18+ event.waitUntil(caches.open(VERSION).then((cache) => cache.addAll(CORE)));19+ self.skipWaiting();20+});21+22+self.addEventListener("activate", (event) => {23+ event.waitUntil(24+ caches.keys().then((keys) =>25+ Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))),26+ ),27+ );28+ self.clients.claim();29+});30+31+self.addEventListener("fetch", (event) => {32+ if (event.request.method !== "GET") return;33+ const url = new URL(event.request.url);34+ if (url.origin !== self.location.origin) return;35+36+ event.respondWith(37+ fetch(event.request).then((response) => {38+ if (response.ok) {39+ const copy = response.clone();40+ caches.open(VERSION).then((cache) => cache.put(event.request, copy));41+ }42+ return response;43+ }).catch(() => caches.match(event.request).then((cached) => {44+ if (cached) return cached;45+ if (event.request.mode === "navigate") return caches.match("/");46+ return new Response("Offline", { status: 503, statusText: "Offline" });47+ })),48+ );49+});@@ -0,0 +1,34 @@
1+name = "klotski"2+version = "0.1.0"3+description = "A one-bit sliding-block puzzle game built with Gleam and Lustre"4+licences = ["MIT"]5+target = "javascript"6+7+[dependencies]8+gleam_stdlib = ">= 0.69.0 and < 2.0.0"9+lustre = ">= 5.7.1 and < 6.0.0"10+11+[dev-dependencies]12+gleeunit = ">= 1.11.0 and < 2.0.0"13+lustre_dev_tools = ">= 2.3.6 and < 3.0.0"14+15+[tools.lustre.build]16+minify = true17+outdir = "./dist"18+19+[tools.lustre.html]20+title = "Klotski"21+lang = "en"22+stylesheets = [{ href = "/styles.css" }]23+links = [24+ { rel = "manifest", href = "/manifest.webmanifest" },25+ { rel = "icon", href = "/icons/icon-192.png" },26+ { rel = "apple-touch-icon", href = "/icons/icon-192.png" },27+]28+meta = [29+ { name = "description", content = "Offline sliding-block puzzles, rendered like a tiny one-bit print shop." },30+ { name = "theme-color", content = "#e8f4f2" },31+ { name = "mobile-web-app-capable", content = "yes" },32+ { name = "apple-mobile-web-app-capable", content = "yes" },33+]34+scripts = [{ src = "/bootstrap.js", type = "module" }]@@ -0,0 +1,52 @@
1+# Do not manually edit this file, it is managed by Gleam.2+#3+# This file locks the dependency versions used, to make your build4+# deterministic and to prevent unexpected versions from being included5+# in your application.6+#7+# You should check this file into your source control repository.8+9+packages = [10+ { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" },11+ { name = "booklet", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "08E0FDB78DC4D8A5D3C80295B021505C7D2A2E7B6C6D5EAB7286C36F4A53C851" },12+ { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },13+ { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" },14+ { name = "exception", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "6BDEA95248093599391C3B5DF1835C5C6A86C353C2F99CE539B450E3432FE117" },15+ { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },16+ { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" },17+ { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" },18+ { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" },19+ { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },20+ { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },21+ { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },22+ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },23+ { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },24+ { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },25+ { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" },26+ { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" },27+ { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" },28+ { name = "glint", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "26CCA9BC3ACB56CD9D754ACF598F49B4C281C4FDD81AE61FA21C8E92E9C8218E" },29+ { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" },30+ { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" },31+ { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" },32+ { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" },33+ { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },34+ { name = "justin", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" },35+ { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" },36+ { name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" },37+ { name = "lustre_dev_tools", version = "2.3.6", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7A650A107C7E767D902A8CB608460B927DE0CDD47D5BA04DED71F9F622FE6AD9" },38+ { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },39+ { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" },40+ { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },41+ { name = "polly", version = "3.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_erlang", "gleam_otp", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "51FB565D81FF6212FDF3306D44419601F2A7C4EDD1F00FC9DA5C376A00AED4FE" },42+ { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" },43+ { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" },44+ { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" },45+ { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" },46+]47+48+[requirements]49+gleam_stdlib = { version = ">= 0.69.0 and < 2.0.0" }50+gleeunit = { version = ">= 1.11.0 and < 2.0.0" }51+lustre = { version = ">= 5.7.1 and < 6.0.0" }52+lustre_dev_tools = { version = ">= 2.3.6 and < 3.0.0" }@@ -0,0 +1,1331 @@
1+import gleam/dynamic/decode2+import gleam/float3+import gleam/int4+import gleam/list5+import gleam/option.{type Option, None, Some}6+import gleam/result7+import gleam/string8+import klotski/browser9+import klotski/catalog.{type Family, type Puzzle}10+import klotski/domain.{11+ type Direction, type Move, type Piece, Down, Left, Move, Right, Up,12+}13+import klotski/persistence.{14+ type Density, type Hints, type Palette, type Save, type Shading, Amoled,15+ Atkinson, Auto, Benday, Best, Comic, Crosshatch, Default, Diagonal, Full,16+ Lines, Low, Macintosh, Night, Off, Ordered, Paper, Save, ShadeAuto, Solid,17+}18+import lustre19+import lustre/attribute20+import lustre/effect.{type Effect}21+import lustre/element.{type Element}22+import lustre/element/html23+import lustre/event24+25+pub type Screen {26+ Landing27+ Atlas28+ Survey29+ Card30+ Playing31+ Won32+ Statistics33+}34+35+pub type Drag {36+ Drag(37+ piece_id: Int,38+ start_x: Float,39+ start_y: Float,40+ current_x: Float,41+ current_y: Float,42+ cell_px: Float,43+ )44+}45+46+pub type Model {47+ Model(48+ screen: Screen,49+ families: List(Family),50+ family_index: Int,51+ puzzle_index: Int,52+ pieces: List(Piece),53+ initial_pieces: List(Piece),54+ steps: Int,55+ history: List(Move),56+ save: Save,57+ settings_open: Bool,58+ shading_open: Bool,59+ drag: Option(Drag),60+ distance: Option(Int),61+ survey_count: Int,62+ survey_phase: Int,63+ survey_eccentricity: Int,64+ install_available: Bool,65+ run_detours: Int,66+ )67+}68+69+pub type Message {70+ UserTappedLanding71+ UserSelectedFamily(Int)72+ UserTappedCard73+ UserTappedBack74+ UserTappedStats75+ UserTappedSettings76+ UserClosedSettings77+ UserToggledShadingMenu78+ UserSelectedPalette(Palette)79+ UserSelectedShading(Shading)80+ UserSelectedHints(Hints)81+ UserSelectedDensity(Density)82+ UserToggledSound83+ UserToggledHaptics84+ UserPressedPiece(Int, Float, Float, Float)85+ UserMovedBoard(Float, Float)86+ UserReleasedBoard(Float, Float)87+ UserPressedPieceKey(Int, String)88+ UserTappedUndo89+ UserTappedReset90+ UserTappedNext91+ SurveyAdvanced(Int, Int)92+ SurveyReady(Int, Int, Int)93+ DistanceUpdated(Int, Int)94+ InstallAvailable95+ UserTappedInstall96+ InstallFinished97+}98+99+pub fn main() {100+ let app = lustre.application(init, update, view)101+ let assert Ok(_) = lustre.start(app, "#app", browser.load())102+ Nil103+}104+105+fn init(raw_save: String) -> #(Model, Effect(Message)) {106+ let families = catalog.all()107+ #(108+ Model(109+ screen: Landing,110+ families:,111+ family_index: 0,112+ puzzle_index: 0,113+ pieces: [],114+ initial_pieces: [],115+ steps: 0,116+ history: [],117+ save: persistence.decode(raw_save, families),118+ settings_open: False,119+ shading_open: False,120+ drag: None,121+ distance: None,122+ survey_count: 0,123+ survey_phase: 1,124+ survey_eccentricity: 0,125+ install_available: False,126+ run_detours: 0,127+ ),128+ browser.watch_install(InstallAvailable),129+ )130+}131+132+fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {133+ case message {134+ UserTappedLanding -> #(135+ Model(..model, screen: Atlas),136+ feedback(model, "page"),137+ )138+ UserSelectedFamily(index) -> open_family(model, index)139+ UserTappedCard -> #(140+ Model(141+ ..model,142+ screen: Playing,143+ pieces: model.initial_pieces,144+ steps: 0,145+ history: [],146+ drag: None,147+ run_detours: 0,148+ ),149+ feedback(model, "page"),150+ )151+ UserTappedBack -> #(152+ Model(153+ ..model,154+ screen: Atlas,155+ settings_open: False,156+ shading_open: False,157+ drag: None,158+ ),159+ feedback(model, "page"),160+ )161+ UserTappedStats -> #(162+ Model(..model, screen: Statistics),163+ feedback(model, "page"),164+ )165+ UserTappedSettings -> #(166+ Model(..model, settings_open: True, shading_open: False),167+ effect.none(),168+ )169+ UserClosedSettings -> #(170+ Model(..model, settings_open: False, shading_open: False),171+ effect.none(),172+ )173+ UserToggledShadingMenu -> #(174+ Model(..model, shading_open: !model.shading_open),175+ effect.none(),176+ )177+ UserSelectedPalette(palette) -> {178+ let preferences =179+ persistence.Preferences(..model.save.preferences, palette:)180+ persist(Model(..model, save: Save(..model.save, preferences:)))181+ }182+ UserSelectedShading(shading) -> {183+ let preferences =184+ persistence.Preferences(..model.save.preferences, shading:)185+ persist(186+ Model(187+ ..model,188+ save: Save(..model.save, preferences:),189+ shading_open: False,190+ ),191+ )192+ }193+ UserSelectedHints(hints) -> {194+ let preferences =195+ persistence.Preferences(..model.save.preferences, hints:)196+ persist(Model(..model, save: Save(..model.save, preferences:)))197+ }198+ UserSelectedDensity(density) -> {199+ let preferences =200+ persistence.Preferences(..model.save.preferences, density:)201+ persist(Model(..model, save: Save(..model.save, preferences:)))202+ }203+ UserToggledSound -> {204+ let preferences =205+ persistence.Preferences(206+ ..model.save.preferences,207+ sound: !model.save.preferences.sound,208+ )209+ persist(Model(..model, save: Save(..model.save, preferences:)))210+ }211+ UserToggledHaptics -> {212+ let preferences =213+ persistence.Preferences(214+ ..model.save.preferences,215+ haptics: !model.save.preferences.haptics,216+ )217+ persist(Model(..model, save: Save(..model.save, preferences:)))218+ }219+ UserPressedPiece(piece_id, x, y, piece_width) -> {220+ let piece = domain.piece_by_id(model.pieces, piece_id)221+ let cell_px = case piece {222+ Ok(piece) -> piece_width /. int.to_float(piece.width)223+ Error(_) -> piece_width224+ }225+ #(226+ Model(..model, drag: Some(Drag(piece_id, x, y, x, y, cell_px))),227+ effect.none(),228+ )229+ }230+ UserMovedBoard(x, y) -> {231+ let drag = case model.drag {232+ None -> None233+ Some(drag) -> Some(Drag(..drag, current_x: x, current_y: y))234+ }235+ #(Model(..model, drag:), effect.none())236+ }237+ UserReleasedBoard(x, y) -> release_piece(model, x, y)238+ UserPressedPieceKey(piece_id, key) ->239+ case key_direction(key) {240+ None -> #(model, effect.none())241+ Some(direction) -> perform_move(model, piece_id, direction, 1)242+ }243+ UserTappedUndo -> undo(model)244+ UserTappedReset -> #(245+ Model(246+ ..model,247+ pieces: model.initial_pieces,248+ steps: 0,249+ history: [],250+ drag: None,251+ distance: Some(current_puzzle(model).optimal),252+ run_detours: 0,253+ ),254+ feedback(model, "page"),255+ )256+ UserTappedNext -> next_puzzle(model)257+ SurveyAdvanced(count, phase) -> #(258+ Model(..model, survey_count: count, survey_phase: phase),259+ effect.none(),260+ )261+ SurveyReady(distance, eccentricity, count) -> #(262+ Model(263+ ..model,264+ screen: Card,265+ distance: Some(distance),266+ survey_eccentricity: eccentricity,267+ survey_count: count,268+ ),269+ feedback(model, "page"),270+ )271+ DistanceUpdated(state, distance) -> {272+ let current =273+ domain.encode_state(current_family(model).board, model.pieces)274+ case current == state {275+ True -> {276+ let added = case model.distance {277+ Some(previous) if distance > previous -> distance - previous278+ _ -> 0279+ }280+ #(281+ Model(282+ ..model,283+ distance: Some(distance),284+ run_detours: model.run_detours + added,285+ ),286+ effect.none(),287+ )288+ }289+ False -> #(model, effect.none())290+ }291+ }292+ InstallAvailable -> #(293+ Model(..model, install_available: True),294+ effect.none(),295+ )296+ UserTappedInstall -> #(297+ Model(..model, install_available: False),298+ browser.prompt_install(InstallFinished),299+ )300+ InstallFinished -> #(model, effect.none())301+ }302+}303+304+fn open_family(model: Model, index: Int) -> #(Model, Effect(Message)) {305+ let family = family_at(model, index)306+ let progress = persistence.progress_for(model.save, family.id)307+ let puzzle_index = int.min(catalog.puzzle_count(family) - 1, progress.cleared)308+ let puzzle = catalog.puzzle_at(family, puzzle_index)309+ let pieces = domain.pieces_from_grid(family.board, puzzle.grid)310+ #(311+ Model(312+ ..model,313+ screen: Survey,314+ family_index: index,315+ puzzle_index:,316+ pieces:,317+ initial_pieces: pieces,318+ steps: 0,319+ history: [],320+ drag: None,321+ distance: None,322+ survey_count: 1,323+ survey_phase: 1,324+ run_detours: 0,325+ ),326+ effect.batch([327+ feedback(model, "page"),328+ browser.begin_survey(329+ family.id,330+ family.board.width,331+ family.board.height,332+ family.board.goal_x,333+ family.board.goal_y,334+ grid_string(catalog.preview_grid(family)),335+ domain.encode_state(family.board, pieces),336+ SurveyAdvanced,337+ SurveyReady,338+ ),339+ ]),340+ )341+}342+343+fn release_piece(344+ model: Model,345+ x: Float,346+ y: Float,347+) -> #(Model, Effect(Message)) {348+ case model.drag {349+ None -> #(model, effect.none())350+ Some(Drag(piece_id, start_x, start_y, _, _, cell_px)) -> {351+ let dx = x -. start_x352+ let dy = y -. start_y353+ let distance =354+ float.max(float.absolute_value(dx), float.absolute_value(dy))355+ let released = Model(..model, drag: None)356+357+ case distance <. float.max(7.0, cell_px *. 0.2) {358+ True ->359+ case360+ domain.move_options(361+ current_family(model).board,362+ model.pieces,363+ piece_id,364+ )365+ {366+ [direction] -> perform_move(released, piece_id, direction, 1)367+ [] -> #(released, feedback(model, "blocked"))368+ _ -> #(released, effect.none())369+ }370+ False -> {371+ let direction = case372+ float.absolute_value(dx) >. float.absolute_value(dy)373+ {374+ True ->375+ case dx >. 0.0 {376+ True -> Right377+ False -> Left378+ }379+ False ->380+ case dy >. 0.0 {381+ True -> Down382+ False -> Up383+ }384+ }385+ let requested = int.max(1, float.round(distance /. cell_px))386+ perform_move(released, piece_id, direction, requested)387+ }388+ }389+ }390+ }391+}392+393+fn perform_move(394+ model: Model,395+ piece_id: Int,396+ direction: Direction,397+ requested: Int,398+) -> #(Model, Effect(Message)) {399+ let family = current_family(model)400+ let #(pieces, moved) =401+ domain.move_many(family.board, model.pieces, piece_id, direction, requested)402+403+ case moved == 0 {404+ True -> #(Model(..model, drag: None), feedback(model, "blocked"))405+ False -> {406+ let next =407+ Model(408+ ..model,409+ pieces:,410+ steps: model.steps + moved,411+ history: [Move(piece_id, direction, moved), ..model.history],412+ drag: None,413+ )414+ case domain.is_solved(family.board, pieces) {415+ False -> #(416+ next,417+ effect.batch([418+ feedback(model, "move"),419+ distance_effect(next),420+ ]),421+ )422+ True -> {423+ let save =424+ persistence.record_solve(425+ next.save,426+ family.id,427+ next.puzzle_index,428+ catalog.puzzle_count(family),429+ next.steps,430+ current_puzzle(model).optimal,431+ next.run_detours,432+ )433+ let won = Model(..next, screen: Won, save:)434+ #(435+ won,436+ effect.batch([437+ browser.persist(persistence.encode(save)),438+ feedback(won, "solve"),439+ ]),440+ )441+ }442+ }443+ }444+ }445+}446+447+fn undo(model: Model) -> #(Model, Effect(Message)) {448+ case model.history {449+ [] -> #(model, effect.none())450+ [Move(piece_id, direction, cells), ..rest] -> {451+ let pieces =452+ indices(cells)453+ |> list.fold(model.pieces, fn(pieces, _) {454+ domain.move_unchecked(pieces, piece_id, domain.opposite(direction))455+ })456+ #(457+ Model(458+ ..model,459+ pieces:,460+ steps: int.max(0, model.steps - cells),461+ history: rest,462+ drag: None,463+ distance: None,464+ ),465+ effect.batch([466+ feedback(model, "page"),467+ browser.query_distance(468+ current_family(model).id,469+ domain.encode_state(current_family(model).board, pieces),470+ DistanceUpdated,471+ ),472+ ]),473+ )474+ }475+ }476+}477+478+fn next_puzzle(model: Model) -> #(Model, Effect(Message)) {479+ let family = current_family(model)480+ case model.puzzle_index + 1 < catalog.puzzle_count(family) {481+ False -> #(Model(..model, screen: Atlas), feedback(model, "page"))482+ True -> {483+ let puzzle_index = model.puzzle_index + 1484+ let puzzle = catalog.puzzle_at(family, puzzle_index)485+ let pieces = domain.pieces_from_grid(family.board, puzzle.grid)486+ #(487+ Model(488+ ..model,489+ screen: Card,490+ puzzle_index:,491+ pieces:,492+ initial_pieces: pieces,493+ steps: 0,494+ history: [],495+ distance: Some(puzzle.optimal),496+ run_detours: 0,497+ ),498+ feedback(model, "page"),499+ )500+ }501+ }502+}503+504+fn persist(model: Model) -> #(Model, Effect(Message)) {505+ #(model, browser.persist(persistence.encode(model.save)))506+}507+508+fn feedback(model: Model, kind: String) -> Effect(Message) {509+ browser.feedback(510+ kind,511+ model.save.preferences.sound,512+ model.save.preferences.haptics,513+ )514+}515+516+fn family_at(model: Model, index: Int) -> Family {517+ model.families518+ |> list.drop(index)519+ |> list.first520+ |> result.unwrap(catalog.family_at(0))521+}522+523+fn current_family(model: Model) -> Family {524+ family_at(model, model.family_index)525+}526+527+fn current_puzzle(model: Model) -> Puzzle {528+ catalog.puzzle_at(current_family(model), model.puzzle_index)529+}530+531+fn key_direction(key: String) -> Option(Direction) {532+ case key {533+ "ArrowUp" -> Some(Up)534+ "ArrowDown" -> Some(Down)535+ "ArrowLeft" -> Some(Left)536+ "ArrowRight" -> Some(Right)537+ _ -> None538+ }539+}540+541+fn view(model: Model) -> Element(Message) {542+ let preferences = model.save.preferences543+ html.div(544+ [545+ attribute.class(546+ "app palette-" <> persistence.palette_id(preferences.palette),547+ ),548+ attribute.attribute("data-shading", resolved_shading(model)),549+ attribute.attribute(550+ "data-density",551+ persistence.density_id(preferences.density),552+ ),553+ ],554+ [555+ view_screen(model),556+ case model.settings_open {557+ True -> settings_sheet(model)558+ False -> element.none()559+ },560+ ],561+ )562+}563+564+fn view_screen(model: Model) -> Element(Message) {565+ case model.screen {566+ Landing -> landing_view()567+ Atlas -> atlas_view(model)568+ Survey -> survey_view(model)569+ Card -> card_view(model)570+ Playing -> play_view(model)571+ Won -> won_view(model)572+ Statistics -> statistics_view(model)573+ }574+}575+576+fn survey_view(model: Model) -> Element(Message) {577+ let family = current_family(model)578+ let phase_label = case model.survey_phase {579+ 1 -> "CONFIGURATIONS REACHED"580+ _ -> "MEASURING DISTANCE TO GOAL"581+ }582+ html.div([attribute.class("screen survey-screen")], [583+ html.main([attribute.class("survey-layout")], [584+ html.section([attribute.class("window survey-window")], [585+ html.p([attribute.class("eyebrow")], [html.text(family.label)]),586+ html.div([attribute.class("survey-pulse")], []),587+ html.strong([], [html.text(int.to_string(model.survey_count))]),588+ html.p([], [html.text(phase_label)]),589+ ]),590+ ]),591+ ])592+}593+594+fn landing_view() -> Element(Message) {595+ let family = catalog.family_at(0)596+ let pieces =597+ domain.pieces_from_grid(family.board, catalog.preview_grid(family))598+ html.button([attribute.class("landing"), event.on_click(UserTappedLanding)], [599+ html.div([attribute.class("window landing-window")], [600+ html.h1([], [html.text("KLOTSKI")]),601+ html.p([attribute.class("subtitle")], [html.text("SLIDING BLOCK PUZZLES")]),602+ html.div([attribute.class("hairline")], []),603+ board_element(family, pieces, False, None),604+ html.span([attribute.class("tap-prompt")], [html.text("TAP TO BEGIN")]),605+ ]),606+ ])607+}608+609+fn top_bar(model: Model, title: String, home: Bool) -> Element(Message) {610+ html.header([attribute.class("menu-bar")], [611+ case home {612+ True ->613+ html.div([attribute.class("menu-brand")], [614+ html.span([attribute.class("signature")], [615+ html.span([attribute.class("signature-mark")], []),616+ ]),617+ html.strong([], [html.text(title)]),618+ ])619+ False ->620+ html.button(621+ [attribute.class("ghost compact"), event.on_click(UserTappedBack)],622+ [html.text("BACK")],623+ )624+ },625+ case home {626+ True -> element.none()627+ False -> html.span([attribute.class("menu-title")], [html.text(title)])628+ },629+ html.div([attribute.class("menu-actions")], [630+ case model.screen == Atlas {631+ True ->632+ case model.install_available {633+ True ->634+ html.button(635+ [attribute.class("compact"), event.on_click(UserTappedInstall)],636+ [html.text("INSTALL")],637+ )638+ False -> element.none()639+ }640+ False -> element.none()641+ },642+ case model.screen == Atlas {643+ True ->644+ html.button(645+ [646+ attribute.class("icon-button icon-record"),647+ attribute.attribute("aria-label", "Record"),648+ event.on_click(UserTappedStats),649+ ],650+ [],651+ )652+ False -> element.none()653+ },654+ case model.screen == Atlas {655+ True ->656+ html.button(657+ [658+ attribute.class("icon-button icon-settings"),659+ attribute.attribute("aria-label", "Settings"),660+ event.on_click(UserTappedSettings),661+ ],662+ [],663+ )664+ False -> element.none()665+ },666+ ]),667+ ])668+}669+670+fn atlas_view(model: Model) -> Element(Message) {671+ html.div([attribute.class("screen atlas-screen")], [672+ top_bar(model, "PUZZLES", True),673+ html.main(674+ [attribute.class("atlas")],675+ list.index_map(model.families, fn(family, index) {676+ let progress = persistence.progress_for(model.save, family.id)677+ let puzzle_index =678+ int.min(catalog.puzzle_count(family) - 1, progress.cleared)679+ let puzzle = catalog.puzzle_at(family, puzzle_index)680+ let preview_grid = case progress.cleared == 0 {681+ True -> catalog.preview_grid(family)682+ False -> puzzle.grid683+ }684+ let pieces = domain.pieces_from_grid(family.board, preview_grid)685+ html.button(686+ [687+ attribute.class("window family-card"),688+ event.on_click(UserSelectedFamily(index)),689+ ],690+ [691+ html.div([attribute.class("mini-board-wrap")], [692+ board_element(family, pieces, False, None),693+ ]),694+ html.div([attribute.class("family-copy")], [695+ html.h2([], [html.text(family.label)]),696+ html.p([], [697+ case progress.cleared == 0 {698+ True -> element.none()699+ False ->700+ html.span([], [701+ html.text(int.to_string(puzzle.optimal) <> " STEPS"),702+ ])703+ },704+ html.span([], [705+ html.text("STAGE " <> int.to_string(puzzle.stage)),706+ ]),707+ ]),708+ case progress.cleared == 0 {709+ True ->710+ html.small([attribute.class("begin-label")], [711+ html.text("TAP TO BEGIN"),712+ ])713+ False ->714+ html.div([], [715+ html.div([attribute.class("depth-track")], [716+ html.span(717+ [718+ attribute.class("depth-marker"),719+ attribute.attribute(720+ "style",721+ "--progress:"722+ <> int.to_string(puzzle.optimal)723+ <> ";--total:"724+ <> int.to_string(family.ceiling),725+ ),726+ ],727+ [],728+ ),729+ ]),730+ html.small([], [731+ html.text("TO " <> int.to_string(family.ceiling)),732+ ]),733+ ])734+ },735+ ]),736+ ],737+ )738+ }),739+ ),740+ ])741+}742+743+fn card_view(model: Model) -> Element(Message) {744+ let family = current_family(model)745+ let puzzle = current_puzzle(model)746+ html.button(747+ [attribute.class("screen card-screen"), event.on_click(UserTappedCard)],748+ [749+ html.div([attribute.class("window stage-card")], [750+ html.h1([], [html.text(family.label)]),751+ html.p([], [html.text("STAGE " <> int.to_string(puzzle.stage))]),752+ html.p([], [753+ html.text(int.to_string(puzzle.optimal) <> " STEPS MINIMUM"),754+ ]),755+ ]),756+ ],757+ )758+}759+760+fn play_view(model: Model) -> Element(Message) {761+ let family = current_family(model)762+ let puzzle = current_puzzle(model)763+ let distance = model.distance |> option.unwrap(puzzle.optimal)764+ let start_distance = int.max(1, puzzle.optimal)765+ html.div([attribute.class("screen game-screen")], [766+ top_bar(model, "", False),767+ html.main([attribute.class("game-layout")], [768+ html.section([attribute.class("window board-window")], [769+ board_element(family, model.pieces, True, model.drag),770+ html.h1([attribute.class("board-title")], [html.text(family.label)]),771+ ]),772+ html.aside([attribute.class("window control-window")], [773+ case model.save.preferences.hints == Off {774+ True -> element.none()775+ False -> gap_gauge(distance, start_distance)776+ },777+ html.div([attribute.class("hud")], [778+ stat(int.to_string(model.steps), "STEPS"),779+ case model.save.preferences.hints == Full {780+ True -> stat(int.to_string(distance), "TO GOAL")781+ False -> element.none()782+ },783+ stat(int.to_string(puzzle.optimal), "OPTIMAL"),784+ ]),785+ html.div([attribute.class("actions")], [786+ html.button(787+ [788+ event.on_click(UserTappedUndo),789+ attribute.disabled(list.is_empty(model.history)),790+ ],791+ [html.text("UNDO")],792+ ),793+ html.button(794+ [attribute.class("ghost"), event.on_click(UserTappedReset)],795+ [html.text("RESET")],796+ ),797+ ]),798+ html.p([attribute.class("interaction-hint")], [799+ html.text("DRAG A BLOCK · ARROW KEYS ALSO WORK"),800+ ]),801+ ]),802+ ]),803+ ])804+}805+806+fn won_view(model: Model) -> Element(Message) {807+ let family = current_family(model)808+ let puzzle = current_puzzle(model)809+ html.div([attribute.class("screen won-screen")], [810+ top_bar(model, "", False),811+ html.main([attribute.class("game-layout")], [812+ html.section([attribute.class("window solved-window")], [813+ html.div([attribute.class("trophy")], [814+ html.span([attribute.class("trophy-cup")], []),815+ html.span([attribute.class("trophy-stem")], []),816+ html.span([attribute.class("trophy-base")], []),817+ ]),818+ html.p([], [html.text("SOLVED")]),819+ html.h1([], [html.text(family.label)]),820+ html.div(821+ [822+ attribute.class("run-chart"),823+ attribute.attribute(824+ "style",825+ "--peak:" <> int.to_string(int.max(1, puzzle.optimal)),826+ ),827+ ],828+ indices(int.max(1, model.steps))829+ |> list.map(fn(index) {830+ html.span(831+ [832+ attribute.attribute(833+ "style",834+ "--bar:"835+ <> int.to_string(int.max(836+ 1,837+ puzzle.optimal - int.min(index, puzzle.optimal - 1),838+ )),839+ ),840+ ],841+ [],842+ )843+ }),844+ ),845+ html.small([], [html.text("DISTANCE TO GOAL")]),846+ ]),847+ html.aside([attribute.class("window control-window")], [848+ html.div([attribute.class("hud record-hud")], [849+ stat(int.to_string(model.steps), "YOUR STEPS"),850+ stat(int.to_string(puzzle.optimal), "OPTIMAL"),851+ stat(int.to_string(model.run_detours), "DETOURS"),852+ stat(int.to_string(model.run_detours), "DEEPEST"),853+ ]),854+ html.div([attribute.class("actions")], [855+ html.button([event.on_click(UserTappedNext)], [856+ html.text(857+ case model.puzzle_index + 1 < catalog.puzzle_count(family) {858+ True -> "NEXT"859+ False -> "PUZZLES"860+ },861+ ),862+ ]),863+ html.button(864+ [attribute.class("ghost"), event.on_click(UserTappedBack)],865+ [html.text("PUZZLES")],866+ ),867+ ]),868+ ]),869+ ]),870+ ])871+}872+873+fn statistics_view(model: Model) -> Element(Message) {874+ let efficiency = case model.save.steps == 0 {875+ True -> "0%"876+ False -> int.to_string(model.save.optimal * 100 / model.save.steps) <> "%"877+ }878+ html.div([attribute.class("screen stats-screen")], [879+ top_bar(model, "RECORD", False),880+ html.main([attribute.class("stats-grid")], [881+ html.section([attribute.class("window level-panel")], [882+ html.p([attribute.class("level-label")], [html.text("LEVEL")]),883+ html.p([attribute.class("xp-label")], [884+ html.text(int.to_string(model.save.xp) <> " XP"),885+ ]),886+ html.strong([], [html.text("1")]),887+ html.div(888+ [889+ attribute.class("level-track"),890+ attribute.attribute(891+ "style",892+ "--level-progress:" <> int.to_string(int.min(45, model.save.xp)),893+ ),894+ ],895+ [],896+ ),897+ html.small([], [898+ html.text(int.to_string(model.save.xp) <> " / 45 TO 2"),899+ ]),900+ ]),901+ html.section([attribute.class("window lifetime-panel")], [902+ html.div([attribute.class("record-cells")], [903+ stat(int.to_string(model.save.solved), "SOLVED"),904+ stat(int.to_string(model.save.clean), "CLEAN"),905+ stat(int.to_string(model.save.detours), "DETOURS"),906+ stat(efficiency, "EFFICIENCY"),907+ stat("0S", "PLAYED"),908+ stat(909+ case model.save.solved > 0 {910+ True -> "1"911+ False -> "0"912+ },913+ "DAY RUN",914+ ),915+ ]),916+ ]),917+ html.section([attribute.class("window family-table")], [918+ html.div([attribute.class("family-table-head")], [919+ html.span([], [html.text("SET")]),920+ html.span([], [html.text("STAGE")]),921+ html.span([], [html.text("DONE")]),922+ html.span([], [html.text("DEEPEST")]),923+ ]),924+ html.div(925+ [attribute.class("family-table-body")],926+ list.map(model.families, fn(family) {927+ let progress = persistence.progress_for(model.save, family.id)928+ html.div([], [929+ html.strong([], [html.text(family.label)]),930+ html.span([], [html.text(int.to_string(progress.cleared + 1))]),931+ html.span([], [html.text(int.to_string(progress.cleared))]),932+ html.span([], [html.text(deepest_for(family, progress.cleared))]),933+ ])934+ }),935+ ),936+ ]),937+ html.section([attribute.class("window recent-panel")], [938+ html.p([], [html.text("RECENT DEPTH")]),939+ case recent_depths(model) {940+ [] -> html.span([], [html.text("NOTHING SOLVED YET")])941+ depths ->942+ html.div(943+ [attribute.class("recent-chart")],944+ list.map(depths, fn(depth) {945+ html.i(946+ [947+ attribute.attribute(948+ "style",949+ "--depth:" <> int.to_string(depth),950+ ),951+ ],952+ [],953+ )954+ }),955+ )956+ },957+ ]),958+ html.footer([attribute.class("window record-footer")], [959+ html.text(case model.survey_count > 1 {960+ True -> int.to_string(model.survey_count) <> " CONFIGURATIONS MAPPED"961+ False -> "8 PIECE SETS, NONE MAPPED YET"962+ }),963+ ]),964+ ]),965+ ])966+}967+968+fn board_element(969+ family: Family,970+ pieces: List(Piece),971+ interactive: Bool,972+ drag: Option(Drag),973+) -> Element(Message) {974+ let board = family.board975+ let board_attributes = [976+ attribute.class(977+ case interactive {978+ True -> "board interactive"979+ False -> "board"980+ }981+ <> " family-"982+ <> family.id,983+ ),984+ attribute.attribute(985+ "style",986+ "--cols:"987+ <> int.to_string(board.width)988+ <> ";--rows:"989+ <> int.to_string(board.height)990+ <> ";--goal-x:"991+ <> int.to_string(board.goal_x),992+ ),993+ ]994+ let board_attributes = case interactive {995+ True -> [996+ pointer_event("pointermove", UserMovedBoard),997+ pointer_event("pointerup", UserReleasedBoard),998+ ..board_attributes999+ ]1000+ False -> board_attributes1001+ }1002+ html.div(board_attributes, [1003+ html.div([attribute.class("exit")], []),1004+ ..list.map(pieces, fn(piece) { piece_element(piece, interactive, drag) })1005+ ])1006+}1007+1008+fn piece_element(1009+ piece: Piece,1010+ interactive: Bool,1011+ drag: Option(Drag),1012+) -> Element(Message) {1013+ let drag_style = case drag {1014+ Some(Drag(id, start_x, start_y, current_x, current_y, _))1015+ if id == piece.id1016+ ->1017+ ";--drag-x:"1018+ <> float.to_string(current_x -. start_x)1019+ <> "px;--drag-y:"1020+ <> float.to_string(current_y -. start_y)1021+ <> "px;--dragging:1"1022+ _ -> ""1023+ }1024+ let attributes = [1025+ attribute.class("piece piece-" <> domain.shape_name(piece.shape)),1026+ attribute.attribute(1027+ "style",1028+ "--x:"1029+ <> int.to_string(piece.x)1030+ <> ";--y:"1031+ <> int.to_string(piece.y)1032+ <> ";--w:"1033+ <> int.to_string(piece.width)1034+ <> ";--h:"1035+ <> int.to_string(piece.height)1036+ <> drag_style,1037+ ),1038+ attribute.attribute(1039+ "aria-label",1040+ domain.shape_name(piece.shape) <> " block",1041+ ),1042+ ]1043+ let attributes = case interactive {1044+ True -> [1045+ piece_pointer_event(piece.id),1046+ pointer_event("pointerup", UserReleasedBoard),1047+ event.on_keydown(UserPressedPieceKey(piece.id, _)),1048+ ..attributes1049+ ]1050+ False -> [attribute.tabindex(-1), ..attributes]1051+ }1052+ let children = case piece.shape == domain.Goal {1053+ True -> [html.span([attribute.class("goal-mark")], [])]1054+ False -> []1055+ }1056+ case interactive {1057+ True -> html.button(attributes, children)1058+ False -> html.div(attributes, children)1059+ }1060+}1061+1062+fn pointer_event(1063+ name: String,1064+ message: fn(Float, Float) -> Message,1065+) -> attribute.Attribute(Message) {1066+ let decoder = {1067+ use x <- decode.field("clientX", decode.float)1068+ use y <- decode.field("clientY", decode.float)1069+ decode.success(message(x, y))1070+ }1071+ event.on(name, decoder) |> event.prevent_default1072+}1073+1074+fn piece_pointer_event(piece_id: Int) -> attribute.Attribute(Message) {1075+ let decoder = {1076+ use x <- decode.field("clientX", decode.float)1077+ use y <- decode.field("clientY", decode.float)1078+ use width <- decode.subfield(["currentTarget", "offsetWidth"], decode.float)1079+ decode.success(UserPressedPiece(piece_id, x, y, width))1080+ }1081+ event.on("pointerdown", decoder) |> event.prevent_default1082+}1083+1084+fn stat(value: String, label: String) -> Element(Message) {1085+ html.div([attribute.class("stat")], [1086+ html.strong([], [html.text(value)]),1087+ html.span([], [html.text(label)]),1088+ ])1089+}1090+1091+fn gap_gauge(gap: Int, start_gap: Int) -> Element(Message) {1092+ html.div([attribute.class("gauge")], [1093+ html.span([], [html.text("START")]),1094+ html.div([], [1095+ html.i(1096+ [1097+ attribute.attribute(1098+ "style",1099+ "--gap:"1100+ <> int.to_string(gap)1101+ <> ";--start:"1102+ <> int.to_string(start_gap),1103+ ),1104+ ],1105+ [],1106+ ),1107+ ]),1108+ html.span([], [html.text("SOLVED")]),1109+ ])1110+}1111+1112+fn settings_sheet(model: Model) -> Element(Message) {1113+ let preferences = model.save.preferences1114+ html.div([attribute.class("sheet-scrim")], [1115+ html.section(1116+ [1117+ attribute.class("window settings-sheet"),1118+ attribute.attribute("role", "dialog"),1119+ attribute.attribute("aria-modal", "true"),1120+ attribute.attribute("aria-label", "Settings"),1121+ ],1122+ [1123+ setting_group("THEME", [1124+ setting_button(1125+ "AUTO",1126+ preferences.palette == Auto,1127+ UserSelectedPalette(Auto),1128+ ),1129+ setting_button(1130+ "MACINTOSH",1131+ preferences.palette == Macintosh,1132+ UserSelectedPalette(Macintosh),1133+ ),1134+ setting_button(1135+ "PAPER",1136+ preferences.palette == Paper,1137+ UserSelectedPalette(Paper),1138+ ),1139+ setting_button(1140+ "NIGHT",1141+ preferences.palette == Night,1142+ UserSelectedPalette(Night),1143+ ),1144+ setting_button(1145+ "AMOLED",1146+ preferences.palette == Amoled,1147+ UserSelectedPalette(Amoled),1148+ ),1149+ ]),1150+ html.div([attribute.class("setting-group")], [1151+ html.p([], [html.text("BLOCKS")]),1152+ html.div([attribute.class("shade-menu")], [1153+ html.button([event.on_click(UserToggledShadingMenu)], [1154+ html.text(shading_label(preferences.shading) <> " ▾"),1155+ ]),1156+ case model.shading_open {1157+ False -> element.none()1158+ True ->1159+ html.div(1160+ [attribute.class("shade-list")],1161+ list.map(shadings(), fn(shading) {1162+ setting_button(1163+ shading_label(shading),1164+ preferences.shading == shading,1165+ UserSelectedShading(shading),1166+ )1167+ }),1168+ )1169+ },1170+ ]),1171+ ]),1172+ setting_group("INTENSITY", [1173+ setting_button(1174+ "LOW",1175+ preferences.density == Low,1176+ UserSelectedDensity(Low),1177+ ),1178+ setting_button(1179+ "DEFAULT",1180+ preferences.density == Default,1181+ UserSelectedDensity(Default),1182+ ),1183+ ]),1184+ setting_group("HINTS", [1185+ setting_button(1186+ "FULL",1187+ preferences.hints == Full,1188+ UserSelectedHints(Full),1189+ ),1190+ setting_button(1191+ "BEST",1192+ preferences.hints == Best,1193+ UserSelectedHints(Best),1194+ ),1195+ setting_button(1196+ "OFF",1197+ preferences.hints == Off,1198+ UserSelectedHints(Off),1199+ ),1200+ ]),1201+ setting_group("SOUND", [1202+ setting_button("TONES", preferences.sound, UserToggledSound),1203+ setting_button("HAPTICS", preferences.haptics, UserToggledHaptics),1204+ ]),1205+ html.p([attribute.class("credit")], [1206+ html.text("ERROR DIFFUSION AND ORDERED SCREENS"),1207+ html.br([]),1208+ html.text("SILKSCREEN AND SPACE MONO, OFL"),1209+ ]),1210+ html.button([event.on_click(UserClosedSettings)], [html.text("CLOSE")]),1211+ ],1212+ ),1213+ ])1214+}1215+1216+fn setting_group(1217+ label: String,1218+ controls: List(Element(Message)),1219+) -> Element(Message) {1220+ html.div([attribute.class("setting-group")], [1221+ html.p([], [html.text(label)]),1222+ html.div([attribute.class("setting-row")], controls),1223+ ])1224+}1225+1226+fn setting_button(1227+ label: String,1228+ armed: Bool,1229+ message: Message,1230+) -> Element(Message) {1231+ html.button(1232+ [1233+ attribute.class(case armed {1234+ True -> "armed"1235+ False -> ""1236+ }),1237+ event.on_click(message),1238+ ],1239+ [html.text(label)],1240+ )1241+}1242+1243+fn shadings() -> List(Shading) {1244+ [1245+ ShadeAuto,1246+ Atkinson,1247+ Ordered,1248+ Benday,1249+ Lines,1250+ Diagonal,1251+ Crosshatch,1252+ Comic,1253+ Solid,1254+ ]1255+}1256+1257+fn shading_label(shading: Shading) -> String {1258+ case shading {1259+ ShadeAuto -> "AUTO"1260+ Atkinson -> "ATKINSON"1261+ Ordered -> "ORDERED"1262+ Benday -> "BEN-DAY"1263+ Lines -> "LINES"1264+ Diagonal -> "DIAGONAL"1265+ Crosshatch -> "CROSSHATCH"1266+ Comic -> "COMIC"1267+ Solid -> "SOLID"1268+ }1269+}1270+1271+fn resolved_shading(model: Model) -> String {1272+ case model.save.preferences.shading {1273+ ShadeAuto -> {1274+ let choices = [1275+ "atkinson",1276+ "ordered",1277+ "benday",1278+ "lines",1279+ "diagonal",1280+ "crosshatch",1281+ "comic",1282+ ]1283+ let seed = model.family_index + model.puzzle_index1284+ let index = seed % list.length(choices)1285+ choices1286+ |> list.drop(index)1287+ |> list.first1288+ |> result.unwrap("atkinson")1289+ }1290+ shading -> persistence.shading_id(shading)1291+ }1292+}1293+1294+fn indices(count: Int) -> List(Int) {1295+ int.range(from: 0, to: count, with: [], run: fn(items, index) {1296+ [index, ..items]1297+ })1298+ |> list.reverse1299+}1300+1301+fn grid_string(grid: List(Int)) -> String {1302+ grid |> list.map(int.to_string) |> string.join(",")1303+}1304+1305+fn distance_effect(model: Model) -> Effect(Message) {1306+ let family = current_family(model)1307+ browser.query_distance(1308+ family.id,1309+ domain.encode_state(family.board, model.pieces),1310+ DistanceUpdated,1311+ )1312+}1313+1314+fn deepest_for(family: Family, cleared: Int) -> String {1315+ case cleared <= 0 {1316+ True -> "0"1317+ False ->1318+ family.puzzles1319+ |> list.take(cleared)1320+ |> list.map(fn(puzzle) { puzzle.optimal })1321+ |> list.fold(0, int.max)1322+ |> int.to_string1323+ }1324+}1325+1326+fn recent_depths(model: Model) -> List(Int) {1327+ model.save.families1328+ |> list.flat_map(fn(progress) {1329+ progress.best |> list.filter(fn(steps) { steps > 0 })1330+ })1331+}@@ -0,0 +1,97 @@
1+import lustre/effect.{type Effect}2+3+@external(javascript, "./browser_ffi.mjs", "load")4+pub fn load() -> String5+6+@external(javascript, "./browser_ffi.mjs", "save")7+fn save_value(value: String) -> Nil8+9+@external(javascript, "./browser_ffi.mjs", "feedback")10+fn play_feedback(kind: String, sound: Bool, haptics: Bool) -> Nil11+12+pub fn persist(value: String) -> Effect(message) {13+ effect.from(fn(_dispatch) { save_value(value) })14+}15+16+pub fn feedback(kind: String, sound: Bool, haptics: Bool) -> Effect(message) {17+ effect.from(fn(_dispatch) { play_feedback(kind, sound, haptics) })18+}19+20+@external(javascript, "./browser_ffi.mjs", "beginSurvey")21+fn begin_survey_ffi(22+ family_id: String,23+ width: Int,24+ height: Int,25+ goal_x: Int,26+ goal_y: Int,27+ solved_grid: String,28+ start_state: Int,29+ dispatch: fn(message) -> Nil,30+ progress_message: fn(Int, Int) -> message,31+ ready_message: fn(Int, Int, Int) -> message,32+) -> Nil33+34+@external(javascript, "./browser_ffi.mjs", "queryDistance")35+fn query_distance_ffi(36+ family_id: String,37+ state: Int,38+ dispatch: fn(message) -> Nil,39+ distance_message: fn(Int, Int) -> message,40+) -> Nil41+42+pub fn begin_survey(43+ family_id: String,44+ width: Int,45+ height: Int,46+ goal_x: Int,47+ goal_y: Int,48+ solved_grid: String,49+ start_state: Int,50+ progress_message: fn(Int, Int) -> message,51+ ready_message: fn(Int, Int, Int) -> message,52+) -> Effect(message) {53+ effect.from(fn(dispatch) {54+ begin_survey_ffi(55+ family_id,56+ width,57+ height,58+ goal_x,59+ goal_y,60+ solved_grid,61+ start_state,62+ dispatch,63+ progress_message,64+ ready_message,65+ )66+ })67+}68+69+pub fn query_distance(70+ family_id: String,71+ state: Int,72+ distance_message: fn(Int, Int) -> message,73+) -> Effect(message) {74+ effect.from(fn(dispatch) {75+ query_distance_ffi(family_id, state, dispatch, distance_message)76+ })77+}78+79+@external(javascript, "./browser_ffi.mjs", "watchInstall")80+fn watch_install_ffi(81+ dispatch: fn(message) -> Nil,82+ available_message: message,83+) -> Nil84+85+@external(javascript, "./browser_ffi.mjs", "promptInstall")86+fn prompt_install_ffi(87+ dispatch: fn(message) -> Nil,88+ finished_message: message,89+) -> Nil90+91+pub fn watch_install(available_message: message) -> Effect(message) {92+ effect.from(fn(dispatch) { watch_install_ffi(dispatch, available_message) })93+}94+95+pub fn prompt_install(finished_message: message) -> Effect(message) {96+ effect.from(fn(dispatch) { prompt_install_ffi(dispatch, finished_message) })97+}@@ -0,0 +1,129 @@
1+const KEY = "klotski.gleam.save";2+let audioContext = null;3+let solverWorker = null;4+let requestId = 0;5+const surveys = new Map();6+const queries = new Map();7+let installPrompt = null;8+let installWatcher = null;9+10+function solver() {11+ if (solverWorker) return solverWorker;12+ solverWorker = new Worker("/solver-worker.js", { type: "module" });13+ solverWorker.addEventListener("message", ({ data }) => {14+ if (data.type === "progress") {15+ const active = surveys.get(data.familyId);16+ if (active) active.dispatch(active.progress(data.count, data.phase));17+ }18+ if (data.type === "ready") {19+ const active = surveys.get(data.familyId);20+ if (active) {21+ active.dispatch(active.ready(data.distance, data.eccentricity, data.count));22+ surveys.delete(data.familyId);23+ }24+ }25+ if (data.type === "distance") {26+ const active = queries.get(data.requestId);27+ if (active) {28+ active.dispatch(active.message(data.state, data.distance));29+ queries.delete(data.requestId);30+ }31+ }32+ });33+ return solverWorker;34+}35+36+export function beginSurvey(familyId, width, height, goalX, goalY, solvedGrid, startState, dispatch, progress, ready) {37+ surveys.set(familyId, { dispatch, progress, ready });38+ solver().postMessage({39+ type: "survey",40+ familyId,41+ width,42+ height,43+ goalX,44+ goalY,45+ solvedGrid: solvedGrid.split(",").map(Number),46+ startState,47+ });48+}49+50+export function queryDistance(familyId, state, dispatch, message) {51+ const id = ++requestId;52+ queries.set(id, { dispatch, message });53+ solver().postMessage({ type: "distance", familyId, state, requestId: id });54+}55+56+export function watchInstall(dispatch, availableMessage) {57+ installWatcher = () => dispatch(availableMessage);58+ window.addEventListener("beforeinstallprompt", (event) => {59+ event.preventDefault();60+ installPrompt = event;61+ installWatcher?.();62+ }, { once: true });63+}64+65+export async function promptInstall(dispatch, finishedMessage) {66+ const prompt = installPrompt;67+ installPrompt = null;68+ if (prompt) {69+ try {70+ await prompt.prompt();71+ await prompt.userChoice;72+ } catch (_) {}73+ }74+ dispatch(finishedMessage);75+}76+77+export function load() {78+ try {79+ return localStorage.getItem(KEY) || "";80+ } catch (_) {81+ return "";82+ }83+}84+85+export function save(value) {86+ try {87+ localStorage.setItem(KEY, value);88+ } catch (_) {89+ // Storage may be unavailable in private browsing. The in-memory model is90+ // still authoritative for this session.91+ }92+}93+94+function tone(frequency, duration = 0.12, volume = 0.045, delay = 0) {95+ const AudioContext = window.AudioContext || window.webkitAudioContext;96+ if (!AudioContext) return;97+ audioContext ||= new AudioContext();98+ if (audioContext.state === "suspended") audioContext.resume();99+100+ const start = audioContext.currentTime + delay;101+ const oscillator = audioContext.createOscillator();102+ const gain = audioContext.createGain();103+ oscillator.type = "triangle";104+ oscillator.frequency.value = frequency;105+ gain.gain.setValueAtTime(0.0001, start);106+ gain.gain.exponentialRampToValueAtTime(volume, start + 0.008);107+ gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);108+ oscillator.connect(gain).connect(audioContext.destination);109+ oscillator.start(start);110+ oscillator.stop(start + duration + 0.02);111+}112+113+export function feedback(kind, sound, haptics) {114+ if (sound) {115+ if (kind === "move") tone(196);116+ if (kind === "page") tone(330, 0.09, 0.035);117+ if (kind === "blocked") tone(82, 0.1, 0.035);118+ if (kind === "solve") {119+ [196, 233, 294, 392].forEach((frequency, index) =>120+ tone(frequency, 0.42, 0.05, index * 0.075),121+ );122+ }123+ }124+125+ if (haptics && navigator.vibrate) {126+ const pattern = kind === "solve" ? [18, 35, 70] : kind === "blocked" ? [10, 24, 10] : 7;127+ try { navigator.vibrate(pattern); } catch (_) {}128+ }129+}@@ -0,0 +1,109 @@
1+import gleam/list2+import gleam/result3+import klotski/domain.{type Board, Board}4+5+pub type Puzzle {6+ Puzzle(stage: Int, optimal: Int, grid: List(Int))7+}8+9+pub type Family {10+ Family(11+ id: String,12+ label: String,13+ board: Board,14+ ceiling: Int,15+ puzzles: List(Puzzle),16+ )17+}18+19+pub fn all() -> List(Family) {20+ [21+ Family("classic", "CLASSIC", Board(4, 5, 1, 3), 126, [22+ p(1, 14, [2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 4, 4, 0, 0, 4, 4, 3, 3, 1, 1]),23+ p(2, 20, [0, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 1, 1, 4, 4, 1, 1, 0, 3, 3]),24+ p(3, 26, [1, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 1, 0, 4, 4, 0, 1, 3, 3, 1]),25+ p(4, 32, [2, 2, 2, 1, 2, 2, 2, 1, 1, 2, 4, 4, 0, 2, 4, 4, 1, 3, 3, 0]),26+ ]),27+ Family("narrow", "NARROW", Board(3, 5, 0, 3), 24, [28+ p(1, 5, [1, 3, 3, 1, 0, 1, 1, 1, 1, 0, 4, 4, 1, 4, 4]),29+ p(2, 7, [1, 1, 1, 1, 0, 1, 3, 3, 0, 1, 4, 4, 1, 4, 4]),30+ p(3, 9, [3, 3, 1, 1, 1, 0, 4, 4, 0, 4, 4, 1, 1, 1, 1]),31+ p(4, 11, [3, 3, 1, 0, 1, 1, 1, 4, 4, 1, 4, 4, 1, 0, 1]),32+ ]),33+ Family("compact", "COMPACT", Board(4, 4, 1, 2), 27, [34+ p(1, 6, [3, 3, 3, 3, 4, 4, 1, 0, 4, 4, 1, 1, 0, 1, 0, 0]),35+ p(2, 8, [3, 3, 4, 4, 3, 3, 4, 4, 1, 0, 0, 0, 1, 0, 1, 1]),36+ p(3, 10, [3, 3, 4, 4, 3, 3, 4, 4, 1, 0, 0, 1, 0, 1, 1, 0]),37+ p(4, 12, [0, 0, 4, 4, 3, 3, 4, 4, 3, 3, 1, 0, 1, 0, 1, 1]),38+ ]),39+ Family("span", "SPAN", Board(5, 4, 1, 2), 33, [40+ p(1, 7, [2, 2, 1, 0, 0, 2, 2, 1, 1, 0, 2, 2, 4, 4, 1, 2, 2, 4, 4, 0]),41+ p(2, 9, [2, 4, 4, 1, 0, 2, 4, 4, 0, 1, 2, 2, 2, 0, 1, 2, 2, 2, 1, 0]),42+ p(3, 11, [4, 4, 2, 0, 1, 4, 4, 2, 2, 1, 2, 2, 0, 2, 0, 2, 2, 1, 0, 1]),43+ p(4, 13, [2, 2, 4, 4, 0, 2, 2, 4, 4, 0, 2, 2, 0, 1, 1, 2, 2, 1, 0, 1]),44+ ]),45+ Family("wide", "WIDE", Board(4, 5, 1, 3), 80, [46+ p(1, 14, [1, 3, 3, 1, 0, 0, 1, 1, 3, 3, 4, 4, 3, 3, 4, 4, 3, 3, 1, 1]),47+ p(2, 18, [3, 3, 3, 3, 1, 1, 3, 3, 1, 4, 4, 0, 1, 4, 4, 1, 3, 3, 1, 0]),48+ p(3, 22, [3, 3, 3, 3, 3, 3, 4, 4, 0, 1, 4, 4, 1, 3, 3, 0, 1, 1, 1, 1]),49+ p(4, 26, [3, 3, 3, 3, 3, 3, 4, 4, 1, 1, 4, 4, 3, 3, 1, 1, 0, 0, 1, 1]),50+ ]),51+ Family("yard", "YARD", Board(5, 4, 2, 2), 101, [52+ p(1, 14, [2, 3, 3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 1, 4, 4, 1, 0, 1, 4, 4]),53+ p(2, 19, [2, 3, 3, 3, 3, 2, 1, 4, 4, 0, 3, 3, 4, 4, 0, 1, 3, 3, 1, 1]),54+ p(3, 24, [2, 3, 3, 0, 0, 2, 3, 3, 4, 4, 3, 3, 1, 4, 4, 1, 1, 3, 3, 1]),55+ p(4, 29, [3, 3, 4, 4, 0, 3, 3, 4, 4, 0, 1, 2, 3, 3, 1, 1, 2, 3, 3, 1]),56+ ]),57+ Family("corner", "CORNER", Board(4, 5, 0, 3), 129, [58+ p(1, 14, [3, 3, 3, 3, 1, 3, 3, 2, 0, 1, 1, 2, 2, 0, 4, 4, 2, 1, 4, 4]),59+ p(2, 20, [3, 3, 3, 3, 3, 3, 1, 1, 2, 0, 0, 2, 2, 4, 4, 2, 1, 4, 4, 1]),60+ p(3, 26, [3, 3, 1, 2, 3, 3, 0, 2, 2, 1, 4, 4, 2, 1, 4, 4, 3, 3, 1, 0]),61+ p(4, 32, [0, 3, 3, 1, 2, 3, 3, 2, 2, 4, 4, 2, 1, 4, 4, 0, 3, 3, 1, 1]),62+ ]),63+ Family("mixed", "MIXED", Board(4, 5, 1, 3), 178, [64+ p(1, 14, [3, 3, 3, 3, 1, 1, 3, 3, 2, 2, 4, 4, 2, 2, 4, 4, 0, 1, 0, 1]),65+ p(2, 23, [3, 3, 1, 2, 2, 3, 3, 2, 2, 4, 4, 1, 0, 4, 4, 0, 1, 1, 3, 3]),66+ p(3, 32, [2, 0, 3, 3, 2, 0, 3, 3, 1, 4, 4, 1, 1, 4, 4, 2, 3, 3, 1, 2]),67+ p(4, 41, [3, 3, 3, 3, 2, 0, 0, 1, 2, 4, 4, 2, 1, 4, 4, 2, 1, 1, 3, 3]),68+ ]),69+ ]70+}71+72+fn p(stage: Int, optimal: Int, grid: List(Int)) -> Puzzle {73+ Puzzle(stage, optimal, grid)74+}75+76+pub fn family_at(index: Int) -> Family {77+ all()78+ |> list.drop(index)79+ |> list.first80+ |> result.unwrap(Family("classic", "CLASSIC", Board(4, 5, 1, 3), 126, []))81+}82+83+pub fn puzzle_at(family: Family, index: Int) -> Puzzle {84+ family.puzzles85+ |> list.drop(index)86+ |> list.first87+ |> result.unwrap(Puzzle(1, 0, []))88+}89+90+pub fn puzzle_count(family: Family) -> Int {91+ list.length(family.puzzles)92+}93+94+/// The solved packing that identifies a family in the atlas. These are not95+/// puzzle starts: showing the family packing before it has been opened is an96+/// intentional part of the original navigation language.97+pub fn preview_grid(family: Family) -> List(Int) {98+ case family.id {99+ "classic" -> [2, 4, 4, 2, 2, 4, 4, 2, 2, 3, 3, 2, 2, 1, 1, 2, 1, 0, 0, 1]100+ "narrow" -> [3, 3, 1, 1, 1, 1, 1, 1, 1, 4, 4, 0, 4, 4, 0]101+ "compact" -> [3, 3, 3, 3, 1, 1, 1, 1, 0, 4, 4, 0, 0, 4, 4, 0]102+ "span" -> [2, 2, 2, 2, 1, 2, 2, 2, 2, 1, 0, 4, 4, 0, 1, 0, 4, 4, 0, 1]103+ "wide" -> [3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 4, 4, 1, 0, 4, 4, 0]104+ "yard" -> [2, 3, 3, 3, 3, 2, 3, 3, 3, 3, 1, 1, 4, 4, 0, 1, 1, 4, 4, 0]105+ "corner" -> [3, 3, 3, 3, 2, 3, 3, 2, 2, 1, 1, 2, 4, 4, 1, 1, 4, 4, 0, 0]106+ "mixed" -> [3, 3, 3, 3, 2, 3, 3, 2, 2, 1, 1, 2, 1, 4, 4, 1, 0, 4, 4, 0]107+ _ -> []108+ }109+}@@ -0,0 +1,274 @@
1+import gleam/int2+import gleam/list3+import gleam/result4+import gleam/set5+6+pub type Shape {7+ Single8+ Upright9+ Recumbent10+ Goal11+}12+13+pub type Direction {14+ Up15+ Down16+ Left17+ Right18+}19+20+pub type Board {21+ Board(width: Int, height: Int, goal_x: Int, goal_y: Int)22+}23+24+pub type Piece {25+ Piece(id: Int, shape: Shape, x: Int, y: Int, width: Int, height: Int)26+}27+28+pub type Move {29+ Move(piece_id: Int, direction: Direction, cells: Int)30+}31+32+pub fn pieces_from_grid(board: Board, grid: List(Int)) -> List(Piece) {33+ let cells = indices(board.width * board.height)34+ let #(pieces, _, _) =35+ list.fold(cells, #([], set.new(), 0), fn(state, index) {36+ let #(pieces, covered, next_id) = state37+ let code = grid |> list.drop(index) |> list.first |> result.unwrap(0)38+39+ case code == 0 || set.contains(covered, index) {40+ True -> state41+ False -> {42+ let #(shape, width, height) = dimensions(code)43+ let x = index % board.width44+ let y = index / board.width45+ let occupied = occupied_indices(board, x, y, width, height)46+ let covered = list.fold(occupied, covered, set.insert)47+ #(48+ [Piece(next_id, shape, x, y, width, height), ..pieces],49+ covered,50+ next_id + 1,51+ )52+ }53+ }54+ })55+56+ list.reverse(pieces)57+}58+59+fn dimensions(code: Int) -> #(Shape, Int, Int) {60+ case code {61+ 1 -> #(Single, 1, 1)62+ 2 -> #(Upright, 1, 2)63+ 3 -> #(Recumbent, 2, 1)64+ _ -> #(Goal, 2, 2)65+ }66+}67+68+fn occupied_indices(69+ board: Board,70+ x: Int,71+ y: Int,72+ width: Int,73+ height: Int,74+) -> List(Int) {75+ list.flat_map(indices(height), fn(dy) {76+ list.map(indices(width), fn(dx) { { y + dy } * board.width + x + dx })77+ })78+}79+80+fn indices(count: Int) -> List(Int) {81+ int.range(from: 0, to: count, with: [], run: fn(items, index) {82+ [index, ..items]83+ })84+ |> list.reverse85+}86+87+pub fn direction_delta(direction: Direction) -> #(Int, Int) {88+ case direction {89+ Up -> #(0, -1)90+ Down -> #(0, 1)91+ Left -> #(-1, 0)92+ Right -> #(1, 0)93+ }94+}95+96+pub fn opposite(direction: Direction) -> Direction {97+ case direction {98+ Up -> Down99+ Down -> Up100+ Left -> Right101+ Right -> Left102+ }103+}104+105+pub fn piece_by_id(pieces: List(Piece), id: Int) -> Result(Piece, Nil) {106+ list.find(pieces, fn(piece) { piece.id == id })107+}108+109+pub fn can_move(110+ board: Board,111+ pieces: List(Piece),112+ piece_id: Int,113+ direction: Direction,114+) -> Bool {115+ case piece_by_id(pieces, piece_id) {116+ Error(_) -> False117+ Ok(piece) -> {118+ let #(dx, dy) = direction_delta(direction)119+ let moved = Piece(..piece, x: piece.x + dx, y: piece.y + dy)120+ let in_bounds =121+ moved.x >= 0122+ && moved.y >= 0123+ && moved.x + moved.width <= board.width124+ && moved.y + moved.height <= board.height125+126+ in_bounds127+ && !list.any(pieces, fn(other) {128+ other.id != moved.id && overlaps(moved, other)129+ })130+ }131+ }132+}133+134+fn overlaps(a: Piece, b: Piece) -> Bool {135+ a.x < b.x + b.width136+ && a.x + a.width > b.x137+ && a.y < b.y + b.height138+ && a.y + a.height > b.y139+}140+141+pub fn move_one(142+ board: Board,143+ pieces: List(Piece),144+ piece_id: Int,145+ direction: Direction,146+) -> Result(List(Piece), Nil) {147+ case can_move(board, pieces, piece_id, direction) {148+ False -> Error(Nil)149+ True -> Ok(move_unchecked(pieces, piece_id, direction))150+ }151+}152+153+pub fn move_many(154+ board: Board,155+ pieces: List(Piece),156+ piece_id: Int,157+ direction: Direction,158+ requested: Int,159+) -> #(List(Piece), Int) {160+ move_many_loop(board, pieces, piece_id, direction, int.max(0, requested), 0)161+}162+163+fn move_many_loop(164+ board: Board,165+ pieces: List(Piece),166+ piece_id: Int,167+ direction: Direction,168+ remaining: Int,169+ moved: Int,170+) -> #(List(Piece), Int) {171+ case remaining <= 0 {172+ True -> #(pieces, moved)173+ False ->174+ case move_one(board, pieces, piece_id, direction) {175+ Error(_) -> #(pieces, moved)176+ Ok(next) ->177+ move_many_loop(178+ board,179+ next,180+ piece_id,181+ direction,182+ remaining - 1,183+ moved + 1,184+ )185+ }186+ }187+}188+189+pub fn move_unchecked(190+ pieces: List(Piece),191+ piece_id: Int,192+ direction: Direction,193+) -> List(Piece) {194+ let #(dx, dy) = direction_delta(direction)195+ list.map(pieces, fn(piece) {196+ case piece.id == piece_id {197+ True -> Piece(..piece, x: piece.x + dx, y: piece.y + dy)198+ False -> piece199+ }200+ })201+}202+203+pub fn move_options(204+ board: Board,205+ pieces: List(Piece),206+ piece_id: Int,207+) -> List(Direction) {208+ [Up, Down, Left, Right]209+ |> list.filter(can_move(board, pieces, piece_id, _))210+}211+212+pub fn free_cells(213+ board: Board,214+ pieces: List(Piece),215+ piece_id: Int,216+ direction: Direction,217+) -> Int {218+ let #(_, moved) = move_many(board, pieces, piece_id, direction, 6)219+ moved220+}221+222+pub fn is_solved(board: Board, pieces: List(Piece)) -> Bool {223+ list.any(pieces, fn(piece) {224+ piece.shape == Goal && piece.x == board.goal_x && piece.y == board.goal_y225+ })226+}227+228+pub fn goal_gap(board: Board, pieces: List(Piece)) -> Int {229+ case list.find(pieces, fn(piece) { piece.shape == Goal }) {230+ Error(_) -> 0231+ Ok(piece) ->232+ int.absolute_value(piece.x - board.goal_x)233+ + int.absolute_value(piece.y - board.goal_y)234+ }235+}236+237+/// Encode a configuration using the same base-five, identity-free key as the238+/// browser survey worker. Pieces of the same shape are interchangeable.239+pub fn encode_state(board: Board, pieces: List(Piece)) -> Int {240+ indices(board.width * board.height)241+ |> list.map(fn(index) {242+ let x = index % board.width243+ let y = index / board.width244+ pieces245+ |> list.find(fn(piece) {246+ x >= piece.x247+ && x < piece.x + piece.width248+ && y >= piece.y249+ && y < piece.y + piece.height250+ })251+ |> result.map(fn(piece) { shape_code(piece.shape) })252+ |> result.unwrap(0)253+ })254+ |> list.reverse255+ |> list.fold(0, fn(key, code) { key * 5 + code })256+}257+258+fn shape_code(shape: Shape) -> Int {259+ case shape {260+ Single -> 1261+ Upright -> 2262+ Recumbent -> 3263+ Goal -> 4264+ }265+}266+267+pub fn shape_name(shape: Shape) -> String {268+ case shape {269+ Single -> "single"270+ Upright -> "upright"271+ Recumbent -> "recumbent"272+ Goal -> "goal"273+ }274+}@@ -0,0 +1,349 @@
1+import gleam/int2+import gleam/list3+import gleam/result4+import gleam/string5+import klotski/catalog.{type Family}6+7+pub type Palette {8+ Auto9+ Macintosh10+ Paper11+ Night12+ Amoled13+}14+15+pub type Shading {16+ ShadeAuto17+ Atkinson18+ Ordered19+ Benday20+ Lines21+ Diagonal22+ Crosshatch23+ Comic24+ Solid25+}26+27+pub type Hints {28+ Full29+ Best30+ Off31+}32+33+pub type Density {34+ Low35+ Default36+}37+38+pub type Preferences {39+ Preferences(40+ palette: Palette,41+ shading: Shading,42+ hints: Hints,43+ density: Density,44+ sound: Bool,45+ haptics: Bool,46+ )47+}48+49+pub type FamilyProgress {50+ FamilyProgress(id: String, cleared: Int, best: List(Int))51+}52+53+pub type Save {54+ Save(55+ preferences: Preferences,56+ solved: Int,57+ steps: Int,58+ optimal: Int,59+ clean: Int,60+ detours: Int,61+ xp: Int,62+ families: List(FamilyProgress),63+ )64+}65+66+pub fn fresh(families: List(Family)) -> Save {67+ Save(68+ preferences: Preferences(Auto, ShadeAuto, Full, Default, True, True),69+ solved: 0,70+ steps: 0,71+ optimal: 0,72+ clean: 0,73+ detours: 0,74+ xp: 0,75+ families: list.map(families, fn(family) {76+ FamilyProgress(family.id, 0, [0, 0, 0, 0])77+ }),78+ )79+}80+81+pub fn decode(raw: String, families: List(Family)) -> Save {82+ let fallback = fresh(families)83+ let parts = string.split(raw, "~")84+ let version = part(parts, 0, "")85+86+ case version == "v1" || version == "v2" {87+ False -> fallback88+ True -> {89+ let preferences =90+ Preferences(91+ palette_from_id(part(parts, 1, "auto")),92+ shading_from_id(part(parts, 2, "auto")),93+ hints_from_id(part(parts, 3, "full")),94+ density_from_id(part(parts, 4, "default")),95+ part(parts, 5, "1") == "1",96+ part(parts, 6, "1") == "1",97+ )98+ let records_index = case version == "v2" {99+ True -> 13100+ False -> 9101+ }102+ let records =103+ part(parts, records_index, "")104+ |> string.split(";")105+ |> list.filter_map(parse_family_progress)106+ let complete_records =107+ list.map(families, fn(family) {108+ records109+ |> list.find(fn(record) { record.id == family.id })110+ |> result.unwrap(FamilyProgress(family.id, 0, [0, 0, 0, 0]))111+ })112+113+ Save(114+ preferences:,115+ solved: parse_int(part(parts, 7, "0")),116+ steps: parse_int(part(parts, 8, "0")),117+ optimal: case version == "v2" {118+ True -> parse_int(part(parts, 9, "0"))119+ False -> 0120+ },121+ clean: case version == "v2" {122+ True -> parse_int(part(parts, 10, "0"))123+ False -> 0124+ },125+ detours: case version == "v2" {126+ True -> parse_int(part(parts, 11, "0"))127+ False -> 0128+ },129+ xp: case version == "v2" {130+ True -> parse_int(part(parts, 12, "0"))131+ False -> 0132+ },133+ families: complete_records,134+ )135+ }136+ }137+}138+139+pub fn encode(save: Save) -> String {140+ let preferences = save.preferences141+ let records =142+ save.families143+ |> list.map(fn(record) {144+ let best = record.best |> list.map(int.to_string) |> string.join(".")145+ string.join([record.id, int.to_string(record.cleared), best], ",")146+ })147+ |> string.join(";")148+149+ string.join(150+ [151+ "v2",152+ palette_id(preferences.palette),153+ shading_id(preferences.shading),154+ hints_id(preferences.hints),155+ density_id(preferences.density),156+ bool_id(preferences.sound),157+ bool_id(preferences.haptics),158+ int.to_string(save.solved),159+ int.to_string(save.steps),160+ int.to_string(save.optimal),161+ int.to_string(save.clean),162+ int.to_string(save.detours),163+ int.to_string(save.xp),164+ records,165+ ],166+ "~",167+ )168+}169+170+fn part(parts: List(String), index: Int, fallback: String) -> String {171+ parts |> list.drop(index) |> list.first |> result.unwrap(fallback)172+}173+174+fn parse_int(value: String) -> Int {175+ value |> int.parse |> result.unwrap(0)176+}177+178+fn parse_family_progress(value: String) -> Result(FamilyProgress, Nil) {179+ let parts = string.split(value, ",")180+ let id = part(parts, 0, "")181+ case id == "" {182+ True -> Error(Nil)183+ False -> {184+ let best =185+ part(parts, 2, "")186+ |> string.split(".")187+ |> list.map(parse_int)188+ Ok(FamilyProgress(id, parse_int(part(parts, 1, "0")), best))189+ }190+ }191+}192+193+pub fn progress_for(save: Save, id: String) -> FamilyProgress {194+ save.families195+ |> list.find(fn(progress) { progress.id == id })196+ |> result.unwrap(FamilyProgress(id, 0, [0, 0, 0, 0]))197+}198+199+pub fn record_solve(200+ save: Save,201+ family_id: String,202+ puzzle_index: Int,203+ puzzle_count: Int,204+ steps: Int,205+ optimal: Int,206+ detours: Int,207+) -> Save {208+ let previous = progress_for(save, family_id)209+ let first_clear = puzzle_index >= previous.cleared210+ let earned = int.max(1, 4 + optimal * 13 / 15)211+ let earned = case first_clear {212+ True -> earned213+ False -> int.max(1, earned / 4)214+ }215+ let updated =216+ list.map(save.families, fn(progress) {217+ case progress.id == family_id {218+ False -> progress219+ True -> {220+ let prior =221+ progress.best222+ |> list.drop(puzzle_index)223+ |> list.first224+ |> result.unwrap(0)225+ let best = case prior == 0 || steps < prior {226+ True -> steps227+ False -> prior228+ }229+ FamilyProgress(230+ ..progress,231+ cleared: int.min(232+ puzzle_count,233+ int.max(progress.cleared, puzzle_index + 1),234+ ),235+ best: replace_at(progress.best, puzzle_index, best),236+ )237+ }238+ }239+ })240+ Save(241+ ..save,242+ solved: save.solved + 1,243+ steps: save.steps + steps,244+ optimal: save.optimal + optimal,245+ clean: save.clean246+ + case detours == 0 {247+ True -> 1248+ False -> 0249+ },250+ detours: save.detours + detours,251+ xp: save.xp + earned,252+ families: updated,253+ )254+}255+256+fn replace_at(items: List(a), index: Int, value: a) -> List(a) {257+ items258+ |> list.index_map(fn(item, item_index) {259+ case item_index == index {260+ True -> value261+ False -> item262+ }263+ })264+}265+266+fn bool_id(value: Bool) -> String {267+ case value {268+ True -> "1"269+ False -> "0"270+ }271+}272+273+pub fn palette_id(value: Palette) -> String {274+ case value {275+ Auto -> "auto"276+ Macintosh -> "macintosh"277+ Paper -> "paper"278+ Night -> "night"279+ Amoled -> "amoled"280+ }281+}282+283+pub fn palette_from_id(value: String) -> Palette {284+ case value {285+ "macintosh" -> Macintosh286+ "paper" -> Paper287+ "night" -> Night288+ "amoled" -> Amoled289+ _ -> Auto290+ }291+}292+293+pub fn shading_id(value: Shading) -> String {294+ case value {295+ ShadeAuto -> "auto"296+ Atkinson -> "atkinson"297+ Ordered -> "ordered"298+ Benday -> "benday"299+ Lines -> "lines"300+ Diagonal -> "diagonal"301+ Crosshatch -> "crosshatch"302+ Comic -> "comic"303+ Solid -> "solid"304+ }305+}306+307+pub fn shading_from_id(value: String) -> Shading {308+ case value {309+ "atkinson" -> Atkinson310+ "ordered" -> Ordered311+ "benday" -> Benday312+ "lines" -> Lines313+ "diagonal" -> Diagonal314+ "crosshatch" -> Crosshatch315+ "comic" -> Comic316+ "solid" -> Solid317+ _ -> ShadeAuto318+ }319+}320+321+pub fn hints_id(value: Hints) -> String {322+ case value {323+ Full -> "full"324+ Best -> "best"325+ Off -> "off"326+ }327+}328+329+pub fn hints_from_id(value: String) -> Hints {330+ case value {331+ "best" -> Best332+ "off" -> Off333+ _ -> Full334+ }335+}336+337+pub fn density_id(value: Density) -> String {338+ case value {339+ Low -> "low"340+ Default -> "default"341+ }342+}343+344+pub fn density_from_id(value: String) -> Density {345+ case value {346+ "low" -> Low347+ _ -> Default348+ }349+}@@ -0,0 +1,48 @@
1+import gleam/list2+import gleeunit/should3+import klotski/catalog4+import klotski/domain.{Down, Goal, Left, Piece, Right}5+6+pub fn classic_catalog_grid_parses_into_ten_pieces_test() {7+ let family = catalog.family_at(0)8+ let puzzle = catalog.puzzle_at(family, 0)9+ let pieces = domain.pieces_from_grid(family.board, puzzle.grid)10+ pieces |> list.length |> should.equal(10)11+}12+13+pub fn a_piece_moves_only_through_free_cells_test() {14+ let family = catalog.family_at(0)15+ let puzzle = catalog.puzzle_at(family, 0)16+ let pieces = domain.pieces_from_grid(family.board, puzzle.grid)17+18+ domain.can_move(family.board, pieces, 4, Down) |> should.be_true19+ domain.free_cells(family.board, pieces, 4, Down) |> should.equal(1)20+ domain.can_move(family.board, pieces, 4, Right) |> should.be_false21+}22+23+pub fn moving_and_reversing_restores_a_board_test() {24+ let family = catalog.family_at(0)25+ let puzzle = catalog.puzzle_at(family, 0)26+ let pieces = domain.pieces_from_grid(family.board, puzzle.grid)27+ let assert Ok(moved) = domain.move_one(family.board, pieces, 4, Down)28+ let restored = domain.move_unchecked(moved, 4, domain.opposite(Down))29+ restored |> should.equal(pieces)30+}31+32+pub fn goal_is_solved_only_when_seated_test() {33+ let board = domain.Board(4, 4, 1, 2)34+ let away = [Piece(0, Goal, 0, 0, 2, 2)]35+ let seated = [Piece(0, Goal, 1, 2, 2, 2)]36+37+ domain.is_solved(board, away) |> should.be_false38+ domain.is_solved(board, seated) |> should.be_true39+ domain.goal_gap(board, away) |> should.equal(3)40+}41+42+pub fn goal_block_obeys_board_edges_test() {43+ let board = domain.Board(4, 4, 1, 2)44+ let pieces = [Piece(0, Goal, 0, 0, 2, 2)]45+46+ domain.can_move(board, pieces, 0, Left) |> should.be_false47+ domain.can_move(board, pieces, 0, Right) |> should.be_true48+}@@ -0,0 +1,20 @@
1+import gleeunit/should2+import klotski/catalog3+import klotski/persistence4+5+pub fn saves_round_trip_test() {6+ let families = catalog.all()7+ let initial = persistence.fresh(families)8+ let solved = persistence.record_solve(initial, "classic", 0, 4, 16, 14, 1)9+ let decoded = persistence.decode(persistence.encode(solved), families)10+ let progress = persistence.progress_for(decoded, "classic")11+12+ decoded.solved |> should.equal(1)13+ decoded.steps |> should.equal(16)14+ decoded.optimal |> should.equal(14)15+ decoded.clean |> should.equal(0)16+ decoded.detours |> should.equal(1)17+ decoded.xp |> should.equal(16)18+ progress.cleared |> should.equal(1)19+ progress.best |> should.equal([16, 0, 0, 0])20+}