The walker only ever took steps that reduced the distance to the goal,
which made it useless for the one thing the record screen exists to
show. A descent that only descends is a straight line. Every real run
has bumps in it, and there was no way to produce one -- so every
screenshot of a solved board, and every check, saw the chart in the only
case it was not drawn for.
So a walk takes a budget instead of a rule: how many moves should take
you further from the goal, how many should achieve nothing, everything
else descends. Seeded, so a run can be asked for twice and arrive twice.
The budget is spread across the run rather than spent off the top --
front-loading it was the first attempt and it produces a shape nobody
plays, three mistakes in the first three moves and then a flawless
eighty-step descent.
It lives in its own module because two callers need the same walk: the
headless checks walk their own scratch grid with it, and the browser
harness feeds the same moves through performMove, which is the game's
own input path -- so the trace, the history, the sounds and the record
all happen exactly as they would under a thumb, and what a screenshot
shows is what a check asserts.
The checks that came with it are the two identities a finished run
satisfies, since a unit move changes the distance by exactly -1, 0 or +1:
descents = optimal + setbacks
steps = optimal + 2*setbacks + idle
If either breaks, either the distance field is not a unit-step
breadth-first one or the walker is playing illegal moves -- and any
"wasted moves" number is a guess rather than an account.
Two things fell out of measuring it. An idle move -- one that leaves the
distance exactly where it was -- is not always available, and how often
it is depends entirely on the family: along classic's optimal corridor
one state in eighty offers one, while on the 5x4 sets more than half of
all moves are idle. And a detour is not a setback: two setbacks in a row
are one excursion two deep, which is one bump on the chart and not two.
Both are counted, because they answer different questions.- commit
- 57bd1489932199b7093d1e0f613107510d348090
- parent
- 85f4bddbf0a8e04973965f83de49cd863bdc4a41
- author
- Dami <dami.etoile@gmail.com>
| M | mockup/ | 33 | +++++ |
| M | mockup/ | 4 | +- |
| A | mockup/ | 182 | ++++++++++++++++++++++++++++++ |
| M | mockup/ | 3 | +- |
| M | mockup/ | 52 | +++++++++ |
@@ -28,6 +28,7 @@ import {
2828 } from './render.js';2929 import { THEMES } from './theme.js';3030 import { PATTERNS } from './shading.js';31+import { makeWalker, traceStats } from './walk.js';3132 3233 const $ = id => document.getElementById(id);3334 const cvs = $('board');@@ -626,4 +627,36 @@ export function walkOptimal(limit = 9999) {
626627 return App.steps;627628 }628629 630+/* Walk it badly, on purpose.631+ *632+ * The same debugging aid, with a budget of mistakes: `wrong` moves that take633+ * the board further from the goal and `idle` ones that change nothing. Seeded,634+ * so a run can be asked for twice and arrive twice.635+ *636+ * This exists because the descent chart is mostly about the bumps and there was637+ * no way to produce one. Every screenshot of a solved board showed a perfect638+ * straight line, which is the one shape a real run never has -- so the chart639+ * was only ever reviewed in the case it was not drawn for.640+ *641+ * The moves come from walk.js, which is also what the headless checks walk, so642+ * what a screenshot shows and what a check asserts are the same walk. They are643+ * played through performMove, which is the game's own input path: the trace,644+ * the history, the sounds and the record all happen exactly as they would under645+ * a thumb. */646+export function walkWandering({ wrong = 3, idle = 2, seed = 1, limit = 4000 } = {}) {647+ if (App.screen !== 'play') return 'not playing';648+ const w = makeWalker(App.sv.dist, { wrong, idle, seed });649+ let guard = 0;650+ for (;;) {651+ const m = w.next(App.board, App.grid);652+ if (!m || guard++ > limit) break;653+ // The walker speaks in coordinates; performMove wants the live piece that654+ // is standing there.655+ const p = App.pieces.find(q => q.x === m.x && q.y === m.y && q.c === m.c);656+ if (!p) break;657+ performMove(p, m.dx, m.dy, 1);658+ }659+ return traceStats(App.trace);660+}661+629662 export { placeDom, grade, openFamily, depthForRung, levelProgress };@@ -11,7 +11,7 @@ import { depthForRung, levelProgress, puzzleFor } from './progress.js';
1111 import { THEMES, themeById, comboFor } from './theme.js';1212 import { GROUND_NAMES } from './ground.js';1313 import { PATTERN_NAMES, RULING_NAMES, effectiveRuling } from './shading.js';14-import { start, placeDom, walkOptimal, openFamily } from './game.js';14+import { start, placeDom, walkOptimal, walkWandering, openFamily } from './game.js';1515 1616 /* Pin a look, or pass nothing to read the current one back, or null to hand it1717 * to the seed again.@@ -34,7 +34,7 @@ function combo(backdrop, groundName, shading, ruling = 'fine') {
3434 * and the handle the screenshot harness drives the game through. */3535 window.klotski = {3636 App, view, LY, save, FAMILIES,37- walkOptimal, openFamily,37+ walkOptimal, walkWandering, openFamily,3838 atlasGeometry, familyRowRect,3939 depthForRung, levelProgress, puzzleFor,4040 THEMES, comboFor, combo, setTheme, lookFor, placeDom, requestRender, renderFrame, setDensity,@@ -0,0 +1,182 @@
1+/* Walking a board -- well, or deliberately badly.2+ *3+ * The solver walk that existed before this only ever took a step that reduced4+ * the distance to the goal, which made it useless for the one thing the record5+ * screen is actually about. A descent that only descends is a straight line;6+ * every interesting run has bumps in it, and there was no way to produce one on7+ * purpose. Screenshots of a real board therefore never showed a climb, and8+ * neither did any check.9+ *10+ * So a walk is described by a budget rather than by a rule: how many moves11+ * should take you further away, and how many should achieve nothing at all.12+ * Everything else descends. That is enough to reproduce any shape of run --13+ * a perfect line, one early stumble, a run that thrashes at the end -- and it14+ * is seeded, so the same numbers give the same board twice.15+ *16+ * Pure, and deliberately so: it takes a board descriptor and a grid and hands17+ * back a move, touching no application state. The headless checks walk their18+ * own scratch grid with it and the browser harness feeds the same moves through19+ * the game's own input path, so what a screenshot shows is what a check20+ * asserts.21+ */22+23+import { decode, piecesOf, canMove, applyMove, encode } from './klotski.js';24+import { DIRS } from './klotski.js';25+import { mulberry, hashString } from './families.js';26+27+/* Every legal move from here, with what each does to the distance.28+ *29+ * `delta` is the only classification there is and it has exactly three values:30+ * a unit move changes the distance to the goal by -1, 0 or +1, never more,31+ * because the distance field is a breadth-first one over these very moves. */32+export function movesFrom(b, g, dist) {33+ const ps = piecesOf(b, g, []);34+ const here = dist.get(encode(b, g));35+ const out = [];36+ for (const p of ps) {37+ for (const [dx, dy] of DIRS) {38+ if (!canMove(b, g, p, dx, dy)) continue;39+ applyMove(b, g, p, dx, dy);40+ const k = encode(b, g);41+ applyMove(b, g, { x: p.x + dx, y: p.y + dy, w: p.w, h: p.h, c: p.c }, -dx, -dy);42+ const d = dist.get(k);43+ if (d == null) continue; // unreachable, which cannot happen44+ out.push({ x: p.x, y: p.y, w: p.w, h: p.h, c: p.c, dx, dy, delta: d - here, to: k });45+ }46+ }47+ return out;48+}49+50+/**51+ * A walker with a budget of mistakes.52+ *53+ * `wrong` moves take you further from the goal and `idle` moves leave the54+ * distance exactly where it was. Everything else descends.55+ *56+ * The budget is spread across the run rather than spent off the top. Front-57+ * loading it was the first attempt and it produces a shape nobody plays: three58+ * mistakes in the first three moves and then a flawless eighty-step descent.59+ * Each step takes a setback with probability weighted by what is left to spend60+ * against what is left to walk, so the bumps land through the run the way real61+ * ones do -- and when the remaining descent is only just long enough to still62+ * fit the budget, the walk stops rolling and spends it, so asking for four63+ * setbacks does not quietly give you one.64+ *65+ * A setback is never taken within two steps of the goal: a run that stumbles on66+ * the last move is a shape the chart cannot show properly, and it is not what a67+ * real one looks like either.68+ *69+ * An idle move is not always on offer, and how often it is depends entirely on70+ * the family. Along classic's optimal corridor almost every state has only71+ * descents and setbacks to choose from -- one state in eighty offers a move72+ * that changes nothing -- while on the 5x4 sets more than half of all moves are73+ * idle ones. So an idle budget is a ceiling rather than a promise, and a walk74+ * that could not spend it says so in the numbers rather than inventing them.75+ *76+ * Returns { next(b, g) } giving the move to play, or null when there is nothing77+ * left to do because the board is solved.78+ */79+export function makeWalker(dist, { wrong = 0, idle = 0, seed = 1 } = {}) {80+ const rng = mulberry(hashString(`walk|${wrong}|${idle}|${seed}`));81+ let budgetUp = wrong, budgetFlat = idle;82+ const pick = list => list[Math.min(list.length - 1, (rng() * list.length) | 0)];83+84+ return {85+ next(b, g) {86+ const here = dist.get(encode(b, g));87+ if (!here) return null; // solved88+ const all = movesFrom(b, g, dist);89+ if (!all.length) return null; // stuck, which cannot happen90+ const up = [], flat = [], down = [];91+ for (const m of all) (m.delta > 0 ? up : m.delta < 0 ? down : flat).push(m);92+93+ // What is left to walk if every remaining mistake is made: each setback94+ // costs its own step and the one that undoes it, each idle move costs one.95+ const left = here + 2 * budgetUp + budgetFlat;96+ if (budgetUp > 0 && up.length && here > 2 &&97+ (here <= 2 * budgetUp || rng() < (2 * budgetUp) / left)) {98+ budgetUp--; return pick(up);99+ }100+ if (budgetFlat > 0 && flat.length &&101+ (here <= budgetFlat || rng() < budgetFlat / left)) {102+ budgetFlat--; return pick(flat);103+ }104+ return down.length ? pick(down) : pick(all);105+ },106+ };107+}108+109+/**110+ * Walk a whole board without touching the game, and hand back the trace.111+ *112+ * The trace is what the record screen draws: the distance to the goal after113+ * every step, starting with the distance before any.114+ */115+export function walkTrace(b, startKey, dist, opts) {116+ const g = decode(b, startKey, new Uint8Array(b.n));117+ const w = makeWalker(dist, opts);118+ const trace = [dist.get(startKey)];119+ const moves = [];120+ let guard = 0;121+ for (;;) {122+ const m = w.next(b, g);123+ if (!m || guard++ > 4000) break;124+ applyMove(b, g, m, m.dx, m.dy);125+ moves.push(m);126+ trace.push(dist.get(encode(b, g)));127+ }128+ return { trace, moves };129+}130+131+/* What a run was actually made of.132+ *133+ * A unit move changes the distance by exactly -1, 0 or +1, so a finished run134+ * decomposes without any interpretation at all: every step either closed the135+ * gap, opened it, or did nothing. Two identities fall straight out and are136+ * worth stating because they are what makes the numbers honest rather than137+ * decorative --138+ *139+ * descents = optimal + setbacks every step back is re-walked140+ * steps = optimal + 2*setbacks + idle141+ *142+ * -- so `wasted` is not a residual anybody has to trust. A setback costs two143+ * steps and an idle move costs one, and those two numbers add up to the gap144+ * between what a run took and what it needed, exactly, every time.145+ *146+ * A `detour` is one excursion above the closest you had already been, from147+ * leaving that mark to regaining it. It is not the same number as `setbacks`148+ * and the difference is the whole reason to count both: two setbacks in a row149+ * are one detour two deep, and on the chart they are one bump, not two. What a150+ * person reads off the ridge is detours; what the arithmetic above accounts for151+ * is setbacks. `worstDetour` is the depth of the deepest of them and152+ * `longestRun` the longest unbroken descent.153+ */154+export function traceStats(trace) {155+ let setbacks = 0, idle = 0, descents = 0, detours = 0;156+ let run = 0, longestRun = 0, worstDetour = 0, firstSetback = -1;157+ let best = trace.length ? trace[0] : 0;158+ let inDetour = false;159+ for (let i = 1; i < trace.length; i++) {160+ const d = trace[i] - trace[i - 1];161+ if (d > 0) {162+ setbacks++; run = 0;163+ if (firstSetback < 0) firstSetback = i;164+ } else if (d < 0) {165+ descents++; run++;166+ if (run > longestRun) longestRun = run;167+ } else {168+ idle++; run = 0;169+ }170+ if (trace[i] > best && !inDetour) { inDetour = true; detours++; }171+ if (trace[i] <= best) inDetour = false;172+ if (trace[i] < best) best = trace[i];173+ if (trace[i] - best > worstDetour) worstDetour = trace[i] - best;174+ }175+ return {176+ steps: Math.max(0, trace.length - 1),177+ optimal: trace.length ? trace[0] : 0,178+ setbacks, idle, descents, detours,179+ wasted: setbacks * 2 + idle,180+ longestRun, worstDetour, firstSetback,181+ };182+}@@ -19,7 +19,7 @@
1919 * mid-puzzle to ship a CSS tweak is a way to lose them.2020 */2121 22-const VERSION = 'klotski-2026-08-07-86586fa4';22+const VERSION = 'klotski-2026-08-07-a3a23308';2323 2424 const SHELL = [2525 './',@@ -40,6 +40,7 @@ const SHELL = [
4040 './src/ground.js',4141 './src/theme.js',4242 './src/progress.js',43+ './src/walk.js',4344 './fonts/silk.woff2',4445 './fonts/silk-bold.woff2',4546 './fonts/mono.woff2',@@ -6,6 +6,7 @@ import {
66 } from '../src/theme.js';77 import { GROUND_TILES } from '../src/ground.js';88 import { PATTERNS, RULINGS, effectiveRuling, screenOf } from '../src/shading.js';9+import { walkTrace, traceStats } from '../src/walk.js';910 1011 let fail = 0;1112 const ok = (cond, msg) => { if (!cond) { console.log(' FAIL', msg); fail++; } };@@ -53,6 +54,57 @@ const deep = new Set([60,61,62,63,64,65].map(r => puzzleFor(classic, sv, r).stat
5354 ok(deep.size >= 4, `ceiling rungs repeat: only ${deep.size} distinct of 6`);5455 console.log(`six rungs at the ceiling gave ${deep.size} distinct boards`);5556 57+/* 3b. walks that are not perfect.58+ *59+ * The old walker only ever descended, so a run with a bump in it was a thing60+ * neither a check nor a screenshot could produce -- and the record screen is61+ * mostly about the bumps. A walk now takes a budget of mistakes, and what the62+ * chart draws decomposes exactly:63+ *64+ * descents = optimal + setbacks65+ * steps = optimal + 2*setbacks + idle66+ *67+ * If either identity breaks, either the distance field is not a unit-step68+ * breadth-first one or the walker is not playing legal moves -- and the69+ * "wasted" number on the record screen is a guess rather than an account.70+ */71+{72+ const pz = puzzleFor(classic, sv, 12);73+ const shapes = [74+ { wrong: 0, idle: 0 }, { wrong: 1, idle: 0 }, { wrong: 3, idle: 2 },75+ { wrong: 6, idle: 4 }, { wrong: 0, idle: 5 },76+ ];77+ for (const shape of shapes) {78+ for (const seed of [1, 2, 3]) {79+ const { trace } = walkTrace(classic.board, pz.state, sv.dist, { ...shape, seed });80+ const st = traceStats(trace);81+ const L = pz.metrics.L;82+ ok(trace[trace.length - 1] === 0, `${JSON.stringify(shape)}/${seed}: walk never solved it`);83+ ok(st.optimal === L, `${JSON.stringify(shape)}/${seed}: started at ${st.optimal}, not ${L}`);84+ ok(st.descents === L + st.setbacks,85+ `${JSON.stringify(shape)}/${seed}: ${st.descents} descents, expected ${L + st.setbacks}`);86+ ok(st.steps === L + 2 * st.setbacks + st.idle,87+ `${JSON.stringify(shape)}/${seed}: ${st.steps} steps, expected ${L + st.wasted}`);88+ ok(st.setbacks <= shape.wrong && st.idle <= shape.idle,89+ `${JSON.stringify(shape)}/${seed}: spent more than the budget`);90+ // Every step is a unit step, in one direction or the other.91+ for (let i = 1; i < trace.length; i++) {92+ ok(Math.abs(trace[i] - trace[i - 1]) === 1 || trace[i] === trace[i - 1],93+ `${JSON.stringify(shape)}/${seed}: step ${i} jumped ${trace[i] - trace[i - 1]}`);94+ }95+ }96+ }97+ const asked = { wrong: 3, idle: 2, seed: 7 };98+ const a = walkTrace(classic.board, pz.state, sv.dist, asked);99+ const b2 = walkTrace(classic.board, pz.state, sv.dist, asked);100+ ok(a.trace.join() === b2.trace.join(), 'the same seed gave two different walks');101+ const st = traceStats(a.trace);102+ ok(st.setbacks > 0 && st.worstDetour > 0, 'a walk asked for setbacks produced a flat descent');103+ console.log(`a 3-wrong 2-idle walk of a ${st.optimal}-step board: ${st.steps} steps, ` +104+ `${st.setbacks} setbacks, ${st.idle} idle, worst detour ${st.worstDetour}, ` +105+ `longest run ${st.longestRun}`);106+}107+56108 // 4. xp and levels57109 ok(xpFor({ L: 20, score: 2, steps: 20, firstClear: true }) >58110 xpFor({ L: 20, score: 2, steps: 60, firstClear: true }), 'efficiency must pay');