clone

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

Document production Gleam app and ignore generated artifacts

Dami committed 77d6d07
commit
77d6d0748cb520b2ee8c2c85dc3b5a3c3692eb01
parent
a63365f982a8454526693dd3bed53a6d995463c9
author
Dami <dami.etoile@gmail.com>
24 files changed, +3081 -5
M.gitignore9+
MREADME.md69+-
Aassets/bootstrap.js5+
Aassets/fonts/mono.woff2bin
Aassets/fonts/silk-bold.woff2bin
Aassets/fonts/silk.woff2bin
Aassets/icons/icon-192.pngbin
Aassets/icons/icon-512.pngbin
Aassets/icons/icon-maskable.pngbin
Aassets/manifest.webmanifest16+
Aassets/solver-worker.js157++++
Aassets/styles.css333++++++++
Aassets/sw.js49+
Agleam.toml34+
Amanifest.toml52+
Asrc/klotski.gleam1331++++++++++++++++++++++++++++++
Asrc/klotski/browser.gleam97++
Asrc/klotski/browser_ffi.mjs129+++
Asrc/klotski/catalog.gleam109++
Asrc/klotski/domain.gleam274++++++
Asrc/klotski/persistence.gleam349++++++++
Atest/klotski/domain_test.gleam48+
Atest/klotski/persistence_test.gleam20+
Atest/klotski_test.gleam5+
M .gitignore +9 -0
M README.md +64 -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:
A assets/bootstrap.js +5 -0
A assets/fonts/mono.woff2 +0 -0

Binary file.

A assets/fonts/silk-bold.woff2 +0 -0

Binary file.

A assets/fonts/silk.woff2 +0 -0

Binary file.

A assets/icons/icon-192.png +0 -0

Binary file.

A assets/icons/icon-512.png +0 -0

Binary file.

A assets/icons/icon-maskable.png +0 -0

Binary file.

A assets/manifest.webmanifest +16 -0
A assets/solver-worker.js +157 -0
@@ -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+});
A assets/styles.css +333 -0
@@ -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+}
A assets/sw.js +49 -0
A gleam.toml +34 -0
A manifest.toml +52 -0
@@ -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" }
A src/klotski.gleam +1331 -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+}
A src/klotski/browser.gleam +97 -0
@@ -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+}
A src/klotski/browser_ffi.mjs +129 -0
@@ -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+}
A src/klotski/catalog.gleam +109 -0
@@ -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+}
A src/klotski/domain.gleam +274 -0
@@ -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+}
A src/klotski/persistence.gleam +349 -0
@@ -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+}
A test/klotski/domain_test.gleam +48 -0
@@ -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+}
A test/klotski/persistence_test.gleam +20 -0
A test/klotski_test.gleam +5 -0