// Verifier for Pudding Monsters / "Jell-o Monsters" (Node only — no browser). // // 1. Schema-lints data/puddingmonsters.json (bounds, overlaps, target legality). // 2. Re-solves every level fresh from the JSON (independent of whatever // genPuddingMonsters.js asserted at generation time), checks `par`, and // replays the optimal path to confirm it really wins with 3 stars. // 3. Unit-tests the engine primitives against small synthetic levels: slide // stopping, edge/spike death, merging, no-ops, and the solver. // 4. Exercises the mutable-board plumbing (state.board layers, per-blob asleep // flags) that Waves 1-2 hang their elements off — see // docs/puddingmonsters-mechanics-plan.md. These checks are what stop a new // element from silently escaping stateKey() and corrupting `par`. // // Usage: node tools/verifyPuddingMonsters.js import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DIRS, DIR_LIST, newState, cloneState, slide, computeSlide, legalMoves, solve, planFlick, applyPlan, stateKey, boardKey, cloneBoard, blobAt, repCell, targetsCovered, } from '../src/games/puddingmonsters/PuddingMonstersLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FILE = path.join(__dirname, '../data/puddingmonsters.json'); let passes = 0; let failures = 0; function check(name, cond, detail = '') { if (cond) { passes += 1; console.log(` ok ${name}`); } else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } } const keyOf = (x, y) => `${x},${y}`; // ── 1. Bank schema ─────────────────────────────────────────────────────────── const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); const levels = raw.levels ?? []; console.log(`[verify] ${FILE}`); console.log(`[verify] ${levels.length} levels`); check('bank is non-empty', levels.length > 0, `found ${levels.length}`); check('bank count matches the levels array', raw.count === levels.length); check('levels are numbered 1..N in order', levels.every((l, i) => l.level === i + 1)); for (const def of levels) { const inBounds = ([x, y]) => x >= 0 && y >= 0 && x < def.cols && y < def.rows; const walls = def.walls ?? []; const spikes = def.spikes ?? []; const targets = def.targets ?? []; const monsters = def.monsters ?? []; const all = [...walls, ...spikes, ...targets, ...monsters]; check(`L${def.level}: every cell is in bounds`, all.every(inBounds)); const wallKeys = new Set(walls.map(([x, y]) => keyOf(x, y))); const spikeKeys = new Set(spikes.map(([x, y]) => keyOf(x, y))); const monsterKeys = new Set(monsters.map(([x, y]) => keyOf(x, y))); check(`L${def.level}: walls, spikes and monsters do not overlap`, wallKeys.size === walls.length && spikeKeys.size === spikes.length && monsterKeys.size === monsters.length && [...wallKeys].every((k) => !spikeKeys.has(k) && !monsterKeys.has(k)) && [...spikeKeys].every((k) => !monsterKeys.has(k))); check(`L${def.level}: has 3 distinct target squares`, targets.length === 3 && new Set(targets.map(([x, y]) => keyOf(x, y))).size === 3); check(`L${def.level}: no target sits on a wall or spike`, targets.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y)))); check(`L${def.level}: par is a sane move count`, Number.isInteger(def.par) && def.par >= 2, `par=${def.par}`); // Wave 1 elements are optional per level. const ice = def.ice ?? []; check(`L${def.level}: ice is in bounds and clear of other terrain`, ice.every(inBounds) && ice.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y)) && !monsterKeys.has(keyOf(x, y)))); for (const kind of ['sleepers', 'green', 'hypno']) { const list = def[kind] ?? []; if (!list.length) continue; check(`L${def.level}: every ${kind} entry is one of the level's monsters`, list.every(([x, y]) => monsterKeys.has(keyOf(x, y)))); check(`L${def.level}: ${kind} entries are distinct`, new Set(list.map(([x, y]) => keyOf(x, y))).size === list.length); } check(`L${def.level}: not every monster is asleep`, (def.sleepers ?? []).length < monsters.length); check(`L${def.level}: a hive needs at least two members`, !def.hypno || def.hypno.length >= 2); // Wave 2 elements. const occupied = new Set([...wallKeys, ...spikeKeys, ...monsterKeys, ...ice.map(([x, y]) => keyOf(x, y))]); const clearCells = (list, name) => { check(`L${def.level}: ${name} is in bounds and clear of other terrain`, list.every(inBounds) && list.every(([x, y]) => !occupied.has(keyOf(x, y)))); for (const [x, y] of list) occupied.add(keyOf(x, y)); }; clearCells(def.springs ?? [], 'springs'); clearCells((def.bricks ?? []).map(([x, y]) => [x, y]), 'bricks'); clearCells((def.buttons ?? []).map(([x, y]) => [x, y]), 'buttons'); clearCells(def.crates ?? [], 'crates'); if (def.tunnels?.length) { check(`L${def.level}: every tunnel links two distinct in-bounds cells`, def.tunnels.every(([ax, ay, bx, by]) => inBounds([ax, ay]) && inBounds([bx, by]) && keyOf(ax, ay) !== keyOf(bx, by))); const mouths = def.tunnels.flatMap(([ax, ay, bx, by]) => [[ax, ay], [bx, by]]); check(`L${def.level}: tunnel mouths are distinct and clear of other terrain`, new Set(mouths.map(([x, y]) => keyOf(x, y))).size === mouths.length && mouths.every(([x, y]) => !occupied.has(keyOf(x, y)))); } if (def.bricks?.length) { const groups = new Set(def.bricks.map(([, , g]) => g)); check(`L${def.level}: every brick group has a button`, [...groups].every((g) => (def.buttons ?? []).some(([, , bg]) => bg === g))); check(`L${def.level}: brick and button groups are defined`, def.bricks.every(([, , g]) => g !== undefined) && (def.buttons ?? []).every(([, , g]) => g !== undefined)); } check(`L${def.level}: buttons without bricks would do nothing`, !(def.buttons ?? []).length || (def.bricks ?? []).length > 0); if (def.powerlifters?.length) { check(`L${def.level}: every powerlifter is one of the level's monsters`, def.powerlifters.every(([x, y]) => monsterKeys.has(keyOf(x, y)))); check(`L${def.level}: crates without a powerlifter are just walls`, (def.crates ?? []).length > 0); } } // ── 2. Every level re-solved and replayed ──────────────────────────────────── let parOk = 0; let winOk = 0; let starsOk = 0; for (const def of levels) { const state = newState(def); if (state.blobs.length !== (def.monsters ?? []).length) { check(`L${def.level}: monsters do not start already merged`, false); continue; } const res = solve(state, { maxStates: 200000 }); if (res.moves === def.par) parOk += 1; else check(`L${def.level}: solver par matches the bank`, false, `solver=${res.moves} bank=${def.par}`); // Replay the optimal path move-by-move on a fresh state. const play = newState(def); let replayed = true; for (const mv of res.path ?? []) { const idx = blobAt(play, mv.cell[0], mv.cell[1]); if (idx < 0) { replayed = false; break; } const out = slide(play, idx, mv.dir); if (!out.moved || out.dead) { replayed = false; break; } } if (replayed && play.state === 'won' && play.blobs.length === 1) winOk += 1; else check(`L${def.level}: the optimal path replays to a win`, false); if (targetsCovered(play, def.targets) === 3) starsOk += 1; else check(`L${def.level}: the optimal solution covers all 3 targets`, false, `covered=${targetsCovered(play, def.targets)}`); } check(`all ${levels.length} levels: solver par matches the bank`, parOk === levels.length, `${parOk}/${levels.length}`); check(`all ${levels.length} levels: optimal path replays to a win`, winOk === levels.length, `${winOk}/${levels.length}`); check(`all ${levels.length} levels: optimal solution is a 3-star clear`, starsOk === levels.length, `${starsOk}/${levels.length}`); // Levels that advertise a mechanic must actually need it: strip the element and // the level has to solve in a different number of moves, or not at all. Every // element of a combination level is checked on its own. Re-checked here rather // than trusted from generation time. { const tagged = levels.filter((l) => l.elements?.length); let bearing = 0; let pairs = 0; for (const def of tagged) { let allOk = true; for (const el of def.elements) { pairs += 1; const stripped = { ...def }; delete stripped[el]; const st = newState(stripped); const res = st.blobs.length === 1 ? { moves: 0 } : solve(st, { maxStates: 200000 }); if (res.moves === def.par) { allOk = false; check(`L${def.level} "${def.name}": its ${el} is load-bearing`, false, `same par (${def.par}) without it`); } } if (allOk) bearing += 1; } check(`all ${tagged.length} element levels: every mechanic in them is load-bearing`, bearing === tagged.length, `${bearing}/${tagged.length} levels, ${pairs} element checks`); check('the bank exercises every mechanic', ['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters'] .every((e) => tagged.some((l) => l.elements.includes(e)))); } // ── 2b. The curriculum ─────────────────────────────────────────────────────── { const chapters = raw.chapters ?? []; check('the bank declares chapters', chapters.length > 0); check('chapters cover every level exactly once, in order', (() => { let expect = 1; for (const c of chapters) { if (c.from !== expect) return false; if (c.to < c.from) return false; expect = c.to + 1; } return expect === levels.length + 1; })()); check('every chapter has a name and a blurb', chapters.every((c) => typeof c.name === 'string' && c.name && typeof c.blurb === 'string' && c.blurb)); check('every level knows which chapter it is in', levels.every((l) => chapters.some((c) => c.id === l.chapter && l.level >= c.from && l.level <= c.to))); check('every level is named', levels.every((l) => typeof l.name === 'string' && l.name.length > 0)); check('level names are unique', new Set(levels.map((l) => l.name)).size === levels.length); // Every mechanic gets exactly one teaching level, and it comes before any // other level that uses that mechanic. const taught = levels.filter((l) => l.teaching); check('every mechanic has exactly one teaching level', (() => { const seenEls = taught.map((l) => l.element); return new Set(seenEls).size === seenEls.length && ['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters'] .every((e) => seenEls.includes(e)); })(), taught.map((l) => l.element).join(',')); for (const t of taught) { const firstUse = levels.find((l) => l.elements?.includes(t.element)); check(`the ${t.element} lesson (L${t.level} "${t.name}") comes before any level using it`, firstUse.level === t.level, `first use is L${firstUse.level}`); check(`L${t.level} "${t.name}": a lesson is short (par <= 3)`, t.par <= 3, `par=${t.par}`); check(`L${t.level} "${t.name}": a lesson explains itself`, typeof t.tip === 'string' && t.tip.length > 0); } // The promise of a teaching level: you cannot lose it on move one. let gentle = 0; for (const t of taught) { const st = newState(t); let fatal = 0; st.blobs.forEach((blob, idx) => { if (blob.asleep) return; for (const dir of DIR_LIST) { const plan = planFlick(st, idx, dir); if (plan.legal && plan.dead) fatal += 1; } }); if (fatal === 0) gentle += 1; else check(`L${t.level} "${t.name}": no opening flick is fatal`, false, `${fatal} fatal openings`); } check(`all ${taught.length} teaching levels: no opening flick is fatal`, gentle === taught.length); // Difficulty should trend upward across the game, even if not monotonically. const avgPar = (from, to) => { const inRange = levels.filter((l) => l.level >= from && l.level <= to); return inRange.reduce((t, l) => t + l.par, 0) / inRange.length; }; const firstChapter = chapters[0]; const lastChapter = chapters[chapters.length - 1]; check('the last chapter is harder than the first', avgPar(lastChapter.from, lastChapter.to) > avgPar(firstChapter.from, firstChapter.to), `${avgPar(firstChapter.from, firstChapter.to).toFixed(1)} -> ${avgPar(lastChapter.from, lastChapter.to).toFixed(1)}`); check('the game opens gently', avgPar(1, 5) <= 3.5, `${avgPar(1, 5).toFixed(1)}`); } // ── 3. Engine primitives ───────────────────────────────────────────────────── // Helper: a level literal, monsters listed as [x,y]. const L = (o) => ({ cols: 5, rows: 5, walls: [], spikes: [], targets: [], monsters: [], ...o }); { // . . . . . A slides right until the wall at (3,0) stops it at (2,0). const s = newState(L({ monsters: [[0, 0]], walls: [[3, 0]] })); const r = computeSlide(s, 0, 'right'); check('a slide stops in front of a wall', r.maxSteps === 2 && r.deathStep === 0, `maxSteps=${r.maxSteps}`); check('offset agrees with the step count on a straight slide', r.offset[0] === 2 && r.offset[1] === 0); slide(s, 0, 'right'); check('slide() moves the blob to the computed rest cell', s.blobs[0].cells[0][0] === 2 && s.blobs[0].cells[0][1] === 0); } { // Nothing in the way -> the blob runs off the open edge and dies. const s = newState(L({ monsters: [[0, 0], [4, 4]] })); const r = computeSlide(s, 0, 'left'); check('sliding off the open edge is fatal', r.deathStep === 1 && r.deathCause === 'edge'); check('a fatal walk reports stopReason "dead"', r.stopReason === 'dead'); const out = slide(s, 0, 'left'); check('a fatal slide marks the run dead and leaves positions untouched', out.dead === true && s.state === 'dead' && s.blobs[0].cells[0][0] === 0 && s.blobs[0].cells[0][1] === 0); } { // Spike two cells right of the monster, wall beyond it. const s = newState(L({ monsters: [[0, 0]], spikes: [[2, 0]], walls: [[4, 0]] })); const r = computeSlide(s, 0, 'right'); check('a slide across a spike is fatal at the spike', r.deathStep === 2 && r.deathCause === 'spike', `deathStep=${r.deathStep} cause=${r.deathCause}`); } { // A wall immediately to the left -> the flick is a no-op, not a death. const s = newState(L({ monsters: [[1, 0], [4, 4]], walls: [[0, 0]] })); const r = computeSlide(s, 0, 'left'); check('a blocked flick is a no-op', r.maxSteps === 0 && r.deathStep === 0); check('slide() reports a no-op without mutating', slide(s, 0, 'left').moved === false); check('a no-op flick is not offered as a legal move', !legalMoves(s).some((m) => m.idx === 0 && m.dir === 'left')); check('a fatal flick is not offered as a legal move', !legalMoves(s).some((m) => m.idx === 0 && m.dir === 'up')); } { // Two monsters in a row; A slides into B and sticks. const s = newState(L({ monsters: [[0, 0], [3, 0]] })); const out = slide(s, 0, 'right'); check('a blob stops against another blob and merges', out.merged === true && s.blobs.length === 1 && s.blobs[0].cells.length === 2); check('merged cells keep their original monster index', new Set(s.blobs[0].cells.map((c) => c[2])).size === 2); check('merging every monster wins the level', s.state === 'won'); } { // Transitive merge: C is already adjacent to B, A arrives -> one blob of 3. const s = newState(L({ monsters: [[0, 0], [3, 0], [3, 1]] })); check('monsters that start adjacent merge at newState', s.blobs.length === 2); slide(s, blobAt(s, 0, 0), 'right'); check('merges are transitive', s.blobs.length === 1 && s.blobs[0].cells.length === 3); } { // A merged blob moves rigidly: both cells travel the same distance. const s = newState(L({ monsters: [[1, 1], [2, 1]], walls: [[1, 4]] })); const idx = blobAt(s, 1, 1); slide(s, idx, 'down'); const ys = s.blobs[0].cells.map((c) => c[1]); check('a merged blob slides as one rigid piece', ys.every((y) => y === 3) && s.blobs[0].cells.length === 2, `ys=${ys}`); } { const s = newState(L({ monsters: [[2, 2]] })); check('a level that starts merged is already won', s.state === 'won' && s.blobs.length === 1); check('solve() returns 0 moves for an already-won level', solve(s).moves === 0); } { // repCell is the top-left-most cell, stable regardless of cell order. const blob = { cells: [[3, 1, 0], [2, 1, 1], [2, 0, 2]] }; const rc = repCell(blob); check('repCell picks the top-left-most cell', rc[0] === 2 && rc[1] === 0); } // ── 4. State identity: the mutable board and blob flags ────────────────────── // // Waves 1-2 add slime, ice, bricks and crates as layers on state.board, and an // `asleep` flag on blobs. Both are mutable, so both MUST change stateKey — if // they do not, the BFS solver merges genuinely different positions and reports // a par lower than the level can actually be solved in. { const s = newState(L({ monsters: [[0, 0], [4, 4]] })); check('a level with no elements has an empty board key', boardKey(s.board) === ''); const before = stateKey(s); const withIce = cloneState(s); withIce.board.ice = new Set(['2,2']); check('a new board layer changes the state key', stateKey(withIce) !== before); const sameIce = cloneState(s); sameIce.board.ice = new Set(['2,2']); check('equal board layers hash identically', stateKey(sameIce) === stateKey(withIce)); const otherIce = cloneState(s); otherIce.board.ice = new Set(['3,3']); check('different board layers hash differently', stateKey(otherIce) !== stateKey(withIce)); const orderA = cloneState(s); orderA.board.slime = new Set(['1,1', '2,2']); const orderB = cloneState(s); orderB.board.slime = new Set(['2,2', '1,1']); check('board layers hash independently of insertion order', stateKey(orderA) === stateKey(orderB)); const emptied = cloneState(s); emptied.board.slime = new Set(); check('an empty layer leaves the key unchanged', stateKey(emptied) === before); const mapLayer = cloneState(s); mapLayer.board.crates = new Map([['1,1', 'a']]); const mapLayer2 = cloneState(s); mapLayer2.board.crates = new Map([['1,1', 'b']]); check('Map layers hash by entry, not identity', stateKey(mapLayer) !== stateKey(mapLayer2)); // Layers must be COPIED by cloneState, or the solver's search would write // through every state it has already visited. const parent = cloneState(s); parent.board.slime = new Set(['1,1']); const child = cloneState(parent); child.board.slime.add('2,2'); check('cloneState deep-copies board layers', parent.board.slime.size === 1 && child.board.slime.size === 2); const copied = cloneBoard(parent.board); check('cloneBoard copies Sets rather than sharing them', copied.slime !== parent.board.slime); } { // Per-blob asleep flags: identity + merge semantics (waking is Wave 1's // gating, but the state model has to carry it correctly first). const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[3, 0]] })); const sleeperIdx = blobAt(s, 3, 0); check('a level can declare a sleeping monster', s.blobs[sleeperIdx].asleep === true); check('other monsters stay awake', s.blobs[blobAt(s, 0, 0)].asleep !== true); const awakeCopy = cloneState(s); delete awakeCopy.blobs[sleeperIdx].asleep; check('an asleep blob and an awake one hash differently', stateKey(awakeCopy) !== stateKey(s)); check('cloneState preserves the asleep flag', cloneState(s).blobs[sleeperIdx].asleep === true); slide(s, blobAt(s, 0, 0), 'right'); check('merging an awake blob into a sleeper wakes the whole blob', s.blobs.length === 1 && s.blobs[0].asleep !== true); const both = newState(L({ monsters: [[0, 0], [1, 0]], sleepers: [[0, 0], [1, 0]] })); check('a blob merged from sleepers only stays asleep', both.blobs[0].asleep === true); } // ── 4b. Wave 1 mechanics ───────────────────────────────────────────────────── // Sleeping monsters: cannot be flicked, wake by being merged into. { const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0]] })); const sleeper = blobAt(s, 0, 0); const awake = blobAt(s, 3, 0); check('a sleeper offers no legal moves', !legalMoves(s).some((m) => m.idx === sleeper)); check('the awake monster still has moves', legalMoves(s).some((m) => m.idx === awake)); check('flicking a sleeper is a no-op', slide(s, sleeper, 'right').moved === false); check('a refused flick leaves the sleeper where it was', s.blobs[sleeper].cells[0][0] === 0 && s.blobs[sleeper].cells[0][1] === 0); slide(s, awake, 'left'); check('sliding into a sleeper sticks and wakes the merged blob', s.blobs.length === 1 && s.blobs[0].asleep !== true && s.blobs[0].cells.length === 2); } { const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0], [3, 0]] })); check('a board of only sleepers has no legal moves', legalMoves(s).length === 0); check('a board of only sleepers is unsolvable', solve(s, { maxStates: 5000 }).moves === -1); } // Ice blocks: stop a slide like a wall, then shatter. { const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[3, 0]] })); const r = computeSlide(s, 0, 'right'); check('ice blocks a slide like a wall', r.maxSteps === 2 && r.deathStep === 0); check('the struck ice is reported', r.hitIce.length === 1 && r.hitIce[0] === '3,0'); slide(s, 0, 'right'); check('the blob comes to rest in front of the ice it broke', s.blobs[blobAt(s, 2, 0)].cells[0][0] === 2); check('struck ice is gone from the board', !(s.board.ice?.has('3,0'))); // With the ice gone the very same flick now runs off the open edge. const again = computeSlide(s, blobAt(s, 2, 0), 'right'); check('ice is a one-use shield — the next flick runs off the edge', again.deathStep > 0 && again.deathCause === 'edge'); } { // Ice you are already touching: the flick shatters it without moving. const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[1, 0]] })); const r = computeSlide(s, 0, 'right'); check('a blob against ice cannot move', r.maxSteps === 0); const plan = planFlick(s, 0, 'right'); check('a shatter-only flick is still a legal move', plan.legal === true && plan.parts.length === 0); check('a shatter-only flick is offered by legalMoves', legalMoves(s).some((m) => m.idx === 0 && m.dir === 'right')); const out = applyPlan(s, plan); check('applying a shatter-only flick counts as a move that travels 0 cells', out.moved === true && out.steps === 0); check('the touched ice shattered', !(s.board.ice?.has('1,0'))); check('the blob did not move', s.blobs[blobAt(s, 0, 0)] !== undefined); } { // A 2-cell blob striking two ice blocks at once breaks both. const s = newState(L({ monsters: [[0, 0], [0, 1], [4, 4]], ice: [[2, 0], [2, 1]] })); const idx = blobAt(s, 0, 0); check('the two starting monsters merged into one 2-cell blob', s.blobs[idx].cells.length === 2); const r = computeSlide(s, idx, 'right'); check('a wide blob stops one cell short of the ice wall', r.maxSteps === 1); check('both struck ice blocks are reported', new Set(r.hitIce).size === 2); slide(s, idx, 'right'); check('both struck ice blocks shattered', !s.board.ice || s.board.ice.size === 0); } // Slime trails: green monsters lay them, everyone stops ON them. { const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], walls: [[3, 0]] })); check('a green monster is flagged green', s.blobs[blobAt(s, 0, 0)].green === true); const out = slide(s, blobAt(s, 0, 0), 'right'); check('a green blob slides normally over clean floor', out.steps === 2); check('the trail covers the start cell and every cell travelled', s.board.slime?.size === 3 && ['0,0', '1,0', '2,0'].every((k) => s.board.slime.has(k))); check('a green blob is not stopped by its own fresh trail', blobAt(s, 2, 0) >= 0); } { // Slime laid last move stops a blob that would otherwise fall off the table. const s = newState(L({ monsters: [[0, 0], [1, 4]], green: [[0, 0]], walls: [[3, 0]] })); slide(s, blobAt(s, 0, 0), 'right'); // slimes (0,0) (1,0) (2,0) const other = blobAt(s, 1, 4); const bare = computeSlide(s, other, 'up'); check('a blob stops ON the slimed cell, not before it', bare.maxSteps === 4 && bare.stopReason === 'stop-on', `steps=${bare.maxSteps} reason=${bare.stopReason}`); check('stopping on slime is not fatal', bare.deathStep === 0); slide(s, other, 'up'); check('the slimed blob rests on the slime', blobAt(s, 1, 0) >= 0); } { // Pre-existing slime (declared by the level) stops a green blob too. const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], slime: [[2, 0]], walls: [[4, 0]] })); const r = computeSlide(s, blobAt(s, 0, 0), 'right'); check('slime stops the green monster that did not lay it', r.maxSteps === 2 && r.stopReason === 'stop-on'); slide(s, blobAt(s, 0, 0), 'right'); check('a green blob extends the trail it stopped on', s.board.slime.size === 3); } { const s = newState(L({ monsters: [[0, 0], [3, 0]], green: [[0, 0]] })); slide(s, blobAt(s, 0, 0), 'right'); check('a blob merged with a green monster is green', s.blobs[0].green === true); } // Hypno goos: one flick moves the whole hive. { const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] })); slide(s, blobAt(s, 0, 0), 'right'); check('flicking one hypno slides every hypno the same way', blobAt(s, 2, 0) >= 0 && blobAt(s, 2, 2) >= 0); } { // The hive resolves leader-first, so the follower can close the gap and stick. const s = newState(L({ monsters: [[0, 0], [2, 0]], hypno: [[0, 0], [2, 0]], walls: [[4, 0]] })); slide(s, blobAt(s, 0, 0), 'right'); check('hypno blobs resolve furthest-first and merge behind the leader', s.blobs.length === 1 && s.blobs[0].cells.length === 2 && blobAt(s, 2, 0) === 0 && blobAt(s, 3, 0) === 0); check('a blob merged from hypno parts stays hypno', s.blobs[0].hypno === true); } { const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] })); slide(s, blobAt(s, 0, 0), 'right'); check('a hypno flick leaves ordinary monsters alone', blobAt(s, 2, 0) >= 0 && blobAt(s, 0, 2) >= 0); const s2 = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] })); slide(s2, blobAt(s2, 0, 2), 'right'); check('flicking an ordinary monster leaves the hive alone', blobAt(s2, 2, 2) >= 0 && blobAt(s2, 0, 0) >= 0); } { // Only 'right' is survivable here: every other direction runs a hive member // off the table, and the hive is offered once per direction, not per blob. const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] })); const moves = legalMoves(s); check('the hive offers one move per direction, fatal ones excluded', moves.length === 1 && moves[0].dir === 'right', `moves=${moves.map((m) => m.dir).join(',')}`); check('a hive move that would kill a member is fatal when forced', planFlick(s, 0, 'left').dead === true); } // ── 4c. Wave 2 mechanics ───────────────────────────────────────────────────── // Springs: bounce the blob back the way it came and keep it sliding. { // (2,0) wall · blob starts (2,2) · spring (2,4) // Down into the spring, back up past the start, stopped by the wall at (2,0). const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] })); const r = computeSlide(s, blobAt(s, 2, 2), 'down', { trace: true }); check('a spring reverses the slide instead of stopping it', r.offset[0] === 0 && r.offset[1] === -1, `offset=${r.offset}`); check('the bounce counts only the cells actually travelled', r.maxSteps === 3, `steps=${r.maxSteps}`); check('the path bends rather than running straight', (r.path ?? []).length === 3); slide(s, blobAt(s, 2, 2), 'down'); check('the blob comes to rest past where it started', blobAt(s, 2, 1) >= 0); } { // Two springs facing each other: the first is spent by the time the blob // comes back to it, so it blocks — which is what makes the walk terminate. const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] })); const r = computeSlide(s, blobAt(s, 2, 2), 'down'); check('a spent spring blocks instead of bouncing again', r.offset[1] === 1 && r.stopReason === 'blocked', `offset=${r.offset} reason=${r.stopReason}`); check('the walk between two springs still terminates', r.maxSteps < 12); } { // A spring can just as easily bounce you off the far edge. const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]] })); const r = computeSlide(s, blobAt(s, 2, 2), 'down'); check('a bounce can throw a blob off the open edge', r.deathStep > 0 && r.deathCause === 'edge'); } // Tunnels: teleport the blob, then keep it sliding. { const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]], })); const idx = blobAt(s, 0, 0); const r = computeSlide(s, idx, 'right', { trace: true }); check('a blob entering a tunnel comes out of its partner', r.offset[0] === 4 && r.offset[1] === 0, `offset=${r.offset}`); check('the path marks the teleport as a jump', (r.path ?? []).some((p) => p[2] === 1)); slide(s, idx, 'right'); check('the blob rests where the far mouth led it', blobAt(s, 4, 0) >= 0); } { // A whole multi-cell blob goes through rigidly ("Tunnel Master"). const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [0, 1], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0], [5, 1]], })); const idx = blobAt(s, 0, 0); check('the two monsters merged before the trip', s.blobs[idx].cells.length === 2); slide(s, idx, 'right'); check('a 2-cell blob teleports rigidly and stays whole', blobAt(s, 4, 0) >= 0 && blobAt(s, 4, 1) === blobAt(s, 4, 0) && s.blobs[blobAt(s, 4, 0)].cells.length === 2); } { // Blocked exit: the tunnel is inert and the blob slides straight past it. const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[4, 0]], })); slide(s, blobAt(s, 0, 0), 'right'); check('a teleport onto something solid simply does not happen', blobAt(s, 3, 0) >= 0); } // Buttons and retractable bricks. { const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [0, 2], [5, 5]], bricks: [[3, 0, 1]], buttons: [[1, 2, 1]], walls: [[4, 2], [5, 0]], })); check('bricks start raised', s.board.bricksUp.has(1)); slide(s, blobAt(s, 0, 0), 'right'); check('a raised brick blocks like a wall', blobAt(s, 2, 0) >= 0); slide(s, blobAt(s, 0, 2), 'right'); check('sliding over a button lowers its brick group', !s.board.bricksUp.has(1)); slide(s, blobAt(s, 2, 0), 'right'); check('with the bricks down the way is open', blobAt(s, 4, 0) >= 0); } { // The button is pressed mid-slide, so the brick it raises can stop the very // same slide. const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 2], [5, 5]], bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]], })); check('a level can start with its bricks lowered', !s.board.bricksUp.has(1)); slide(s, blobAt(s, 0, 2), 'right'); check('a button pressed mid-slide raises bricks in time to stop that slide', s.board.bricksUp.has(1) && blobAt(s, 2, 2) >= 0); } { // Bricks must not rise through a monster: that toggle jams. const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 2], [3, 2]], bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]], })); slide(s, blobAt(s, 0, 2), 'right'); check('a brick cannot rise through a monster — the toggle jams', !s.board.bricksUp.has(1)); } // Powerlifters and crates. { const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0]], walls: [[5, 0]], })); const idx = blobAt(s, 0, 0); check('a powerlifter is flagged', s.blobs[idx].lifter === true); slide(s, idx, 'right'); check('a powerlifter shoves the crate along ahead of it', s.board.crates.has('4,0') && !s.board.crates.has('2,0'), `crates=${[...(s.board.crates ?? [])]}`); check('the pusher stops when the crate can go no further', blobAt(s, 3, 0) >= 0); } { // The same level without the powerlifter flag: the crate is just a wall. const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], crates: [[2, 0]], walls: [[5, 0]], })); slide(s, blobAt(s, 0, 0), 'right'); check('an ordinary monster is stopped dead by a crate', blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0')); } { // No crate trains: a crate backed by another crate cannot move. const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0], [3, 0]], })); slide(s, blobAt(s, 0, 0), 'right'); check('a crate backed by another crate will not budge', blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0') && s.board.crates.has('3,0')); } { // A crate shoved past the rim is gone. (The pusher usually follows it off — // that is the walk's business, this checks only the crate bookkeeping.) const s = newState(L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], powerlifters: [[0, 0]], crates: [[5, 0]], })); const r = computeSlide(s, blobAt(s, 0, 0), 'right'); check('a crate pushed off the table is removed from the board', !r.board.crates?.has('5,0') && !r.board.crates?.has('6,0')); } // The scene animates each part along its `path` and never slices it by step // count (a teleport adds a point without advancing a step). So every path must // end exactly on the part's offset, or the blob would visibly land in the wrong // cell before snapping. { const fixtures = [ ['plain slide', L({ monsters: [[0, 0], [3, 0]] }), 'right'], ['spring bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] }), 'down'], ['double bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] }), 'down'], ['teleport', L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]] }), 'right'], ['crate push', L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0]], walls: [[5, 0]] }), 'right'], ['fatal edge', L({ monsters: [[2, 2], [0, 0]] }), 'down'], ]; let ok = 0; for (const [name, def, dir] of fixtures) { const st = newState(def); const plan = planFlick(st, blobAt(st, def.monsters[0][0], def.monsters[0][1]), dir); const part = plan.parts[0]; if (!part) { check(`${name}: the fixture produces a moving part`, false); continue; } const last = part.path?.[part.path.length - 1]; if (last && last[0] === part.offset[0] && last[1] === part.offset[1]) ok += 1; else check(`${name}: path ends on the part's offset`, false, `path end=${last} offset=${part.offset}`); check(`${name}: the path is at least as long as the steps taken`, (part.path?.length ?? 0) >= part.steps); } check('every route ends where the walk says it does', ok === fixtures.length, `${ok}/${fixtures.length}`); } // planFlick / applyPlan: the scene animates a plan, so it must never disagree // with what slide() would have done, and planning must not mutate. { const def = L({ monsters: [[0, 0], [3, 0], [0, 3]], green: [[0, 0]], ice: [[3, 3]], walls: [[4, 0]] }); for (const dir of DIR_LIST) { const a = newState(def); const b = newState(def); const before = stateKey(a); const plan = planFlick(a, 0, dir); check(`planFlick(${dir}) does not mutate the state`, stateKey(a) === before); const viaPlan = applyPlan(a, plan); const viaSlide = slide(b, 0, dir); check(`plan+apply matches slide() for ${dir}`, stateKey(a) === stateKey(b) && viaPlan.moved === viaSlide.moved && viaPlan.dead === viaSlide.dead); } } // ── 5. Solver ──────────────────────────────────────────────────────────────── { // One flick away from a win. const s = newState(L({ monsters: [[0, 0], [3, 0]] })); const res = solve(s); check('solver finds the one-move win', res.moves === 1 && res.path.length === 1); check('solver returns the final footprint', res.footprint?.length === 2); } { // Unsolvable: two monsters alone on an open board — any flick falls off. const s = newState(L({ monsters: [[0, 0], [4, 4]] })); check('solver reports unsolvable boards', solve(s, { maxStates: 20000 }).moves === -1); } { // The solver's answer is a true minimum: no shorter path exists by brute // force over the same move set. const def = L({ cols: 5, rows: 5, monsters: [[0, 0], [4, 0], [0, 4]], walls: [[2, 2], [4, 4], [1, 3]] }); const res = solve(newState(def)); if (res.moves > 0) { const shorter = (() => { const seen = new Set(); let frontier = [newState(def)]; for (let d = 1; d < res.moves; d++) { const next = []; for (const st of frontier) { for (const mv of legalMoves(st)) { const ns = cloneState(st); slide(ns, mv.idx, mv.dir); if (ns.blobs.length === 1) return true; const k = stateKey(ns); if (seen.has(k)) continue; seen.add(k); next.push(ns); } } frontier = next; } return false; })(); check('solver par is a true minimum (no shorter path exists)', !shorter, `par=${res.moves}`); } else { check('the minimality fixture is solvable', false, `moves=${res.moves}`); } } { // DIRS / DIR_LIST agree, and every direction is reachable from the map. check('DIR_LIST covers exactly the four directions', DIR_LIST.length === 4 && DIR_LIST.every((d) => Array.isArray(DIRS[d]))); } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`[verify] ${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);