847 lines
38 KiB
JavaScript
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);
|