clone

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

A solved board says what it cost, and now what it cost it on

Dami committed a69b179
Steps against optimal says how much a run spent. It does not say what
on -- and it very nearly says the rest by itself, which is the part that
decided this. A step back has to be re-walked, so it costs two, and
where a family offers no idle move -- classic offers one every eighty
states -- "wrong moves" is exactly the gap between steps and optimal,
halved. Printing that would be printing the same fact twice.

What cannot be worked out from those two numbers is the shape. Four
setbacks can be four separate stumbles or two holes two deep, and on the
chart that is the difference between four bumps and two. So the panel
reads YOUR STEPS, OPTIMAL, DETOURS, DEEPEST: one excursion above the
closest you had already been is a detour, and DEEPEST is how far back
the worst of them put you.

EARNED gives up the slot. XP is a function of the numbers beside it
rather than a fact about the run, and it is on the record screen with
every other lifetime figure, which is where someone looking for it
would go.

The same two numbers are kept with the solve, so the record screen can
carry them too: CLEAN counts solves that never went backwards at all,
DETOURS the lifetime total of the times one did, and the recent-depth
chart gains a pip under every bar that was a straight descent -- the bar
says how deep the board was, the pip says how it went. Counted as they
happen rather than derived from the log, which is capped, so deriving
them would quietly mean "of the last hundred and forty". A solve
recorded before any of this has no answer rather than a zero, and gets
no pip.

The lifetime row became two rows of three on the way. Five captions
across a phone put EFFICIENCY and PLAYED into each other and the row
read as one long word; three to a row survives a 360-wide screen and
splits along the meaning anyway -- what you have solved on top, how you
play underneath.
commit
a69b1798f115ce5864e0115698381f671b700806
parent
57bd1489932199b7093d1e0f613107510d348090
author
Dami <dami.etoile@gmail.com>
6 files changed, +129 -20
MREADME.md53+++++++++++++++++++++++++++---
Mmockup/index.html7+++-
Mmockup/src/game.js19++++++++++-
Mmockup/src/render.js48+++++++++++++++++++++++----
Mmockup/src/store.js20+++++++++--
Mmockup/sw.js2+-
M README.md +47 -6
@@ -449,10 +449,27 @@ run was drawn at a different size on every family, and on the square plates the
449449 the middle of the window with a hand's width of nothing under it. There is nothing else in that450450 window: the chart takes everything between the set's name and the caption, on every board.451451 452-The record screen shows level and XP, lifetime solved / efficiency / time / day streak, a per-family453-table of rung, solves and deepest board, a chart of recent depths, and the total number of454-configurations mapped this session — which uses `count`, `ecc` and `widths` off the sweep, and is455-the one number that says why this puzzle is interesting in the first place.452+**A solved board is described by what cannot be worked out from the two numbers already on it.**453+Steps against optimal says how much a run cost; it does not say what on. And it very nearly says the454+rest by itself: a step back has to be re-walked, so it costs two, and where a family offers no idle455+moves — classic offers one every eighty states — "wrong moves" is just the gap between steps and456+optimal, halved. Printing that would be printing the same fact twice.457+458+So the panel under the chart reads YOUR STEPS, OPTIMAL, DETOURS, DEEPEST. A detour is one excursion459+above the closest you had already been — one bump on the ridge — and DEEPEST is how far back the460+worst of them put you. Four setbacks can be four separate stumbles or two holes two deep, and only461+those two numbers say which. The XP that used to sit in that slot is a function of the ones beside462+it rather than a fact about the run, and it is on the record screen with the other lifetime figures.463+464+The record screen shows level and XP, then lifetime solved / clean / detours over efficiency /465+played / day streak — what you have solved on one row, how you play on the next — a per-family table466+of rung, solves and deepest board, a chart of recent depths, and the total number of configurations467+mapped this session, which uses `count`, `ecc` and `widths` off the sweep and is the one number that468+says why this puzzle is interesting in the first place. CLEAN is solves that never went backwards at469+all, and the recent-depth chart carries a pip under every bar that was one: the bar says how deep470+the board was, the pip says the descent was a straight line, and the second is worth about a pixel.471+Both are counted as they happen rather than derived from the log, which is capped — deriving them472+would quietly mean "of the last hundred and forty".456473 457474 ---458475
@@ -478,8 +495,32 @@ black rules, and the six-stripe rainbow as a maker's mark rather than decoration
478495 is a complete 3×3 packing, so there is a subject and no leftover holes to read as clutter, and479496 every piece size — and so every colour — appears exactly once.480497 481-`window.klotski` is exposed for inspection over `chrome://inspect`; `window.klotski.walkOptimal()`482-walks a board down the distance field to the goal.498+`window.klotski` is exposed for inspection over `chrome://inspect`. `walkOptimal()` walks a board499+down the distance field to the goal; `walkWandering({ wrong, idle, seed })` walks it badly on500+purpose — `wrong` moves that take you further away, `idle` moves that change nothing, everything501+else descending, seeded so a run can be asked for twice and arrive twice.502+503+The second one exists because the descent chart is mostly about the bumps and there was no way to504+produce one. Every screenshot of a solved board showed a perfect straight line, which is the one505+shape a real run never has, so the chart was only ever reviewed in the case it is not drawn for.506+The moves come from `walk.js`, which is also what the headless checks walk, so what a screenshot507+shows is what a check asserts — the harness feeds them through the game's own input path, and the508+trace, the history, the sounds and the record all happen as they would under a thumb.509+510+The checks assert the two identities a finished run satisfies, since a unit move changes the511+distance to the goal by exactly −1, 0 or +1:512+513+```514+descents = optimal + setbacks515+steps = optimal + 2 × setbacks + idle516+```517+518+Break either and the distance field is not a unit-step breadth-first one, or the walker is playing519+illegal moves. Two things fell out of measuring it. An idle move is not always available and how520+often it is depends entirely on the family — along classic's optimal corridor one state in eighty521+offers one, while on the 5×4 sets more than half of all moves are idle. And a *detour* is not a522+*setback*: two setbacks in a row are one excursion two deep, which is one bump on the chart and not523+two.483524 484525 The service worker is cache-first, so a stale build will be served to anything that has visited485526 before — including automated screenshots, which is worth an hour of confusion exactly once. Open
M mockup/index.html +6 -1
M mockup/src/game.js +18 -1
@@ -186,11 +186,16 @@ function beginPlay() {
186186 function win() {187187 const m = App.pz.metrics;188188 const before = levelFromXp(save.totals.xp);189+ const st = traceStats(App.trace);189190 const gain = record({190191 famId: App.fam.id, rung: App.rung,191192 optimal: m.L, steps: App.steps,192193 ms: Math.max(0, Date.now() - App.startedAt),193194 score: m.score,195+ // Kept with the solve rather than recomputed later: the trace is a session196+ // thing and the log is a capped ring buffer, so the shape of a run is only197+ // knowable at the moment it finishes.198+ detours: st.detours, worstDetour: st.worstDetour,194199 });195200 const after = levelFromXp(save.totals.xp);196201 App.lastGain = { ...gain, level: after, levelled: after > before };
@@ -426,11 +431,23 @@ function syncHud() {
426431 $('btnUndo').disabled = !App.history.length;427432 }428433 434+/* The run, in the terms the chart above it is drawn in.435+ *436+ * Steps against optimal says how much was spent; it does not say what on. A437+ * detour is one excursion above the closest you had already been -- one bump on438+ * the ridge -- and DEEPEST is how far back the worst of them put you. Neither439+ * can be worked out from the two numbers beside them, which is the test every440+ * one of these has to pass: a step back costs two steps, so "wrong moves" is441+ * just the gap between steps and optimal, halved, and printing it would be442+ * printing the same fact twice. Four bumps can be four separate stumbles or two443+ * holes two deep, and only these say which. */429444 function fillRecord() {430445 const m = App.pz.metrics;446+ const st = traceStats(App.trace);431447 $('rSteps').textContent = App.steps;432448 $('rOpt').textContent = m.L;433- $('rXp').textContent = `+${App.lastGain ? App.lastGain.xp : 0}`;449+ $('rDetours').textContent = st.detours;450+ $('rDeepest').textContent = st.worstDetour;434451 }435452 436453 /* ----------------------------------------------------------------- buttons */
M mockup/src/render.js +41 -7
@@ -1625,21 +1625,43 @@ function drawStats(c) {
16251625 { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });16261626 y += lvlH + 6;16271627 1628- // Lifetime.1629- const lifeH = Math.max(36, Math.round(VH * 0.105));1630- drawPanel(c, { x: M, y, w: W, h: lifeH });1628+ /* Lifetime, in two rows of three.1629+ *1630+ * It was one row, and adding what a run was made of to it put five captions1631+ * across a phone: EFFICIENCY and PLAYED met in the middle and the row read as1632+ * one long word. Three to a row is the same information at a size that1633+ * survives a 360-wide screen, and it splits along the meaning anyway -- what1634+ * you have solved on top, how you play underneath.1635+ *1636+ * CLEAN is solves that never went backwards at all, and DETOURS the lifetime1637+ * count of the times one did. Both are counted as they happen; the log they1638+ * could be derived from is capped, so deriving them would quietly mean "of1639+ * the last hundred and forty". */16311640 const eff = t.steps ? Math.round((t.optimal / t.steps) * 100) : 0;16321641 const cells = [16331642 [String(t.solved || 0), 'SOLVED'],1643+ [String(t.clean || 0), 'CLEAN'],1644+ [String(t.detours || 0), 'DETOURS'],16341645 [`${eff}%`, 'EFFICIENCY'],16351646 [fmtTime(t.ms || 0), 'PLAYED'],16361647 [String(save.days.streak || 0), 'DAY RUN'],16371648 ];1649+ const PER_ROW = 3;1650+ const rows = Math.ceil(cells.length / PER_ROW);1651+ const rowH2 = Math.max(30, Math.round(VH * 0.075));1652+ const lifeH = rows * rowH2 + 4;1653+ drawPanel(c, { x: M, y, w: W, h: lifeH });1654+ const cellW = W / PER_ROW;16381655 cells.forEach(([v, k2], i) => {1639- const x = M + Math.round(W * (i + 0.5) / cells.length);1640- label(v, x, y + Math.round(lifeH * 0.52), { size: snap(U * 0.048), align: 'center' });1641- label(k2, x, y + Math.round(lifeH * 0.82),1642- { size: snap(U * 0.028), face: 'SilkR', align: 'center', alpha: 0.65 });1656+ const row = Math.floor(i / PER_ROW), colI = i % PER_ROW;1657+ const x = M + Math.round(cellW * (colI + 0.5));1658+ const ry = y + 2 + row * rowH2;1659+ label(v, x, ry + Math.round(rowH2 * 0.52), { size: snap(U * 0.048), align: 'center' });1660+ // Fitted to its own cell rather than trusted to be short enough: the widest1661+ // of these is EFFICIENCY and it is the one that ran into its neighbour.1662+ label(k2, x, ry + Math.round(rowH2 * 0.82),1663+ { size: fitSize(k2, U * 0.028, 'SilkR', 0, cellW - 6),1664+ face: 'SilkR', align: 'center', alpha: 0.65 });16431665 });16441666 y += lifeH + 6;16451667
@@ -1690,6 +1712,18 @@ function drawStats(c) {
16901712 c.fillRect(x0, gy + gh - h, Math.max(1, x1 - x0 - (bwid > 3 ? 1 : 0)), h);16911713 });16921714 c.fillRect(gx, gy + gh + 1, gw, 1);1715+ /* A pip under the rule for every board that was solved without ever going1716+ * backwards. The bar says how deep the board was; the pip says the descent1717+ * was a straight one -- two facts about the same solve, and the second is1718+ * worth about a pixel.1719+ *1720+ * Solves recorded before the shape of a run was kept have no answer rather1721+ * than a zero, so they get no pip and are not claimed as clean. */1722+ shown.forEach((e, i) => {1723+ if (e.d !== 0) return;1724+ const x0 = gx + Math.round(i * bwid), x1 = gx + Math.round((i + 1) * bwid);1725+ c.fillRect(x0, gy + gh + 3, Math.max(1, x1 - x0 - (bwid > 3 ? 1 : 0)), 2);1726+ });16931727 label(String(peak), M + W - 8, y + 12,16941728 { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });16951729 } else {
M mockup/src/store.js +16 -4
@@ -25,7 +25,11 @@ const DEFAULTS = () => ({
2525 hints: 'full', // full | best | off -- how much of the solver shows2626 density: 3, // buffer pixels per layout unit -- 2 is the low rung2727 fams: {}, // id -> { rung, solved, deepest, best: {rung: steps}, ms }28- totals: { solved: 0, steps: 0, optimal: 0, ms: 0, xp: 0 },28+ // `clean` is solves finished without ever going backwards, and `detours` the29+ // lifetime count of the excursions in the rest. Counted as they happen for30+ // the same reason everything else here is: the log they could be derived from31+ // is capped, so deriving them would quietly mean "of the last 140".32+ totals: { solved: 0, steps: 0, optimal: 0, ms: 0, xp: 0, clean: 0, detours: 0 },2933 days: { first: null, last: null, streak: 0, best: 0 },3034 log: [], // { f, r, L, s, ms, t } newest last, capped3135 });
@@ -137,10 +141,10 @@ function stampDay(t) {
137141 138142 /**139143 * Record a solved board and return what it earned.140- * `solve` = { famId, rung, optimal, steps, ms, score }144+ * `solve` = { famId, rung, optimal, steps, ms, score, detours, worstDetour }141145 */142146 export function record(solve) {143- const { famId, rung, optimal, steps, ms, score } = solve;147+ const { famId, rung, optimal, steps, ms, score, detours = 0, worstDetour = 0 } = solve;144148 const fam = famStats(famId);145149 const firstClear = !(rung in fam.best);146150 const xp = xpFor({ L: optimal, score, steps, firstClear });
@@ -155,9 +159,17 @@ export function record(solve) {
155159 156160 const t = save.totals;157161 t.solved += 1; t.steps += steps; t.optimal += optimal; t.ms += ms; t.xp += xp;162+ t.detours = (t.detours || 0) + detours;163+ if (!detours) t.clean = (t.clean || 0) + 1;158164 159165 stampDay(now);160- save.log.push({ f: famId, r: rung, L: optimal, s: steps, ms, t: Math.round(now / 1000) });166+ // `d` and `w` are the shape of the run: how many times it went backwards and167+ // how far back the worst of those went. Absent on anything solved before they168+ // were recorded, which the record screen reads as unknown rather than as zero.169+ save.log.push({170+ f: famId, r: rung, L: optimal, s: steps, ms, t: Math.round(now / 1000),171+ d: detours, w: worstDetour,172+ });161173 if (save.log.length > LOG_CAP) save.log.splice(0, save.log.length - LOG_CAP);162174 163175 persist();
M mockup/sw.js +1 -1