clone

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

A walk can be bad on purpose now

Dami committed 57bd148
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>
5 files changed, +271 -3
Mmockup/src/game.js33+++++
Mmockup/src/main.js4+-
Amockup/src/walk.js182++++++++++++++++++++++++++++++
Mmockup/sw.js3+-
Mmockup/tools/check.mjs52+++++++++
M mockup/src/game.js +33 -0
@@ -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 };
M mockup/src/main.js +2 -2
A mockup/src/walk.js +182 -0
@@ -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+}
M mockup/sw.js +2 -1
M mockup/tools/check.mjs +52 -0
@@ -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');