fertig-classic-games/tools/verifyPuddingMonsters.js

847 lines
38 KiB
JavaScript

// 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);