// Verifier for Bloxorz (Node only — no browser). // // 1. Schema-lints data/bloxorz.json (bounds, bridge/switch/teleport linkage). // 2. Re-solves every level fresh from the JSON (independent of whatever // genBloxorz.js already asserted at generation time) and checks par. // 3. Unit-tests the engine primitives directly against small synthetic // levels: tip-over physics, death cases, switches, teleport, walls, and // the split/merge mechanic. // // Usage: node tools/verifyBloxorz.js import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadLevel, newState, applyMove, solve, } from '../src/games/bloxorz/BloxorzLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FILE = path.join(__dirname, '../data/bloxorz.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}` : ''}`); } } // ── Bank checks ────────────────────────────────────────────────────────────── const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); const levels = raw.levels ?? []; console.log(`[verify] ${FILE}`); console.log(`[verify] ${levels.length} levels`); check('bank has 36 levels', levels.length === 36, `found ${levels.length}`); let prevPar = 0; for (const def of levels) { const inBounds = (x, y) => x >= 0 && x < def.cols && y >= 0 && y < def.rows; let boundsOk = inBounds(def.start.x, def.start.y) && inBounds(def.goal.x, def.goal.y); const bridgeIds = new Set(); const switchLinks = new Map(); // bridgeId -> Set of switch modes const teleportCounts = new Map(); let splitCount = 0; for (const t of def.tiles) { if (!inBounds(t.x, t.y)) boundsOk = false; if (t.type === 'bridge') bridgeIds.add(t.bridgeId); if (t.type === 'switch') { for (const bid of t.linkedBridgeIds) { if (!switchLinks.has(bid)) switchLinks.set(bid, new Set()); switchLinks.get(bid).add(t.mode); } } if (t.type === 'teleport') teleportCounts.set(t.teleportId, (teleportCounts.get(t.teleportId) ?? 0) + 1); if (t.split) splitCount++; } check(`L${def.level}: all tiles/start/goal in bounds`, boundsOk); let bridgesLinked = true; for (const bid of bridgeIds) if (!switchLinks.has(bid)) bridgesLinked = false; check(`L${def.level}: every bridge has a linking switch`, bridgesLinked); let noMixedModes = true; for (const modes of switchLinks.values()) if (modes.size > 1) noMixedModes = false; check(`L${def.level}: no bridge mixes toggle+momentary switches`, noMixedModes); let teleportsPaired = true; for (const count of teleportCounts.values()) if (count !== 2) teleportsPaired = false; check(`L${def.level}: every teleporter id appears exactly twice`, teleportsPaired); check(`L${def.level}: split-flagged tile count is even`, splitCount % 2 === 0, `count=${splitCount}`); // Fresh solver re-run, independent of genBloxorz.js's own check at write time. const compiled = loadLevel(def); const { moves } = solve(compiled, { maxStates: 300000 }); check(`L${def.level} "${def.name}": solvable`, moves >= 0, `solve() returned moves=${moves}`); check(`L${def.level}: par matches fresh solve`, moves === def.par, `par=${def.par} solve=${moves}`); prevPar = def.par; } void prevPar; // ── Engine unit tests ──────────────────────────────────────────────────────── function synthLevel(tiles, start, cols = 12, rows = 12) { return loadLevel({ cols, rows, start, goal: { x: 0, y: 0 }, tiles }); } const floor = (x, y, extra = {}) => ({ x, y, type: 'floor', ...extra }); const wall = (x, y) => ({ x, y, type: 'wall' }); const goal = (x, y) => ({ x, y, type: 'goal' }); const fragileT = (x, y) => ({ x, y, type: 'fragile' }); const bridgeT = (x, y, id, open = false) => ({ x, y, type: 'bridge', bridgeId: id, initiallyOpen: open }); const hardSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' }); const softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'lying', mode: 'momentary' }); const tp = (x, y, id) => ({ x, y, type: 'teleport', teleportId: id }); // -- Tip-over physics: standing -> lying -- { const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), floor(0, 1), floor(0, 2)], { x: 0, y: 0, orient: 'up' }); let s = newState(lvl); applyMove(lvl, s, 'right'); check('standing rolls right into lying-x at [1,2]', s.block.mode === 'joined' && s.block.orient === 'x' && s.block.x === 1 && s.block.y === 0, JSON.stringify(s.block)); s = newState(lvl); applyMove(lvl, s, 'down'); check('standing rolls down into lying-y at [1,2]', s.block.orient === 'y' && s.block.x === 0 && s.block.y === 1, JSON.stringify(s.block)); } // -- Tip-over physics: lying -> standing (along axis) and lying -> lying (perpendicular shift) -- { const lvl = synthLevel([ floor(1, 0), floor(2, 0), floor(3, 0), floor(4, 0), floor(2, 1), floor(3, 1), ], { x: 1, y: 0, orient: 'up' }); let s = newState(lvl); applyMove(lvl, s, 'right'); // standing1 -> lying[2,3] applyMove(lvl, s, 'right'); // lying[2,3] -> standing (anchor2+2=4) check('lying-x tips right back to standing (x+2)', s.block.orient === 'up' && s.block.x === 4, JSON.stringify(s.block)); s = newState(lvl); applyMove(lvl, s, 'right'); // standing1 -> lying[2,3] applyMove(lvl, s, 'down'); // perpendicular shift: stays lying-x, y+1 check('lying-x shifts perpendicular (down) without changing orientation', s.block.orient === 'x' && s.block.x === 2 && s.block.y === 1, JSON.stringify(s.block)); } // -- Falling off the edge is fatal -- { const lvl = synthLevel([floor(0, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); const res = applyMove(lvl, s, 'right'); check('rolling off the platform edge is fatal', res.dead === true && s.status === 'dead'); } // -- Goal only supports a standing landing -- { const lvl = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); const res = applyMove(lvl, s, 'right'); // lands lying across [1,2]; 2 is void, 1 is goal (needs 'up') check('a lying landing on/through the goal falls through (fatal)', res.dead === true); const lvl2 = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), goal(3, 0)], { x: 0, y: 0, orient: 'up' }); const s2 = newState(lvl2); applyMove(lvl2, s2, 'right'); // standing0 -> lying[1,2] applyMove(lvl2, s2, 'right'); // lying[1,2] -> standing3 (goal) check('a standing landing exactly on the goal wins', s2.status === 'won'); } // -- Fragile tiles: safe once, fatal on a second (already-broken) visit -- { const lvl = synthLevel([floor(0, 0), fragileT(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' }); const s = { block: { mode: 'joined', orient: 'up', x: 0, y: 0 }, toggleBridges: new Map(), broken: new Set(), status: 'playing' }; // Simulate having already broken (1,0) on a prior visit, then try to land on it again. s.broken.add('1,0'); const res = applyMove(lvl, s, 'right'); // lying[1,2] overlaps the broken cell check('landing on an already-broken fragile tile is fatal', res.dead === true); } { const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0), floor(5, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (fragile) — first visit, safe check('first standing visit to a fragile tile survives', s.status === 'playing'); check('fragile tile is marked broken after the first standing visit', s.broken.has('3,0')); } // -- Hard switch: any orientation, persistent -- { const lvl = synthLevel([ floor(0, 0), hardSw(1, 0, 's1', ['b1']), floor(2, 0), bridgeT(3, 0, 'b1', false), floor(4, 0), ], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the hard switch check('hard switch opens its bridge on contact (any orientation)', s.toggleBridges.get('b1') === true); const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, onto the now-open bridge check('hard-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing'); } // -- Soft (momentary) switch: open only while covered, recloses immediately -- { const lvl = synthLevel([ floor(0, 0), floor(1, 0), softSw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(5, 0), ], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2], covers the soft switch (col2) const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, crossing the bridge in the same instant check('soft-switch bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing'); check('soft-switch bridge recloses once the block leaves', s.toggleBridges.get('b1') !== true); } // -- Teleport relocates a standing landing -- { const lvl = synthLevel([ floor(0, 0), floor(1, 0), floor(2, 0), tp(3, 0, 't1'), tp(3, 3, 't1'), floor(4, 3), ], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (teleport source) -> relocated to (3,3) check('standing on a teleporter relocates to its paired tile', s.block.x === 3 && s.block.y === 3, JSON.stringify(s.block)); } // -- Split trigger + auto-merge -- { const lvl = synthLevel([ floor(0, 0), floor(1, 0), floor(2, 0), floor(3, 0), floor(0, 0), { x: 4, y: 0, type: 'floor', split: true }, { x: 5, y: 0, type: 'floor', split: true }, floor(6, 0), floor(7, 0), ], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 applyMove(lvl, s, 'right'); // standing3 -> candidate lying[4,5] both split-flagged -> SPLIT check('a lying landing on two split-flagged tiles splits the block', s.block.mode === 'split', JSON.stringify(s.block)); applyMove(lvl, s, 'right'); // both units step +1, become adjacent again -> auto-merge check('two adjacent split units auto-merge back into a joined lying block', s.block.mode === 'joined' && s.block.orient === 'x', JSON.stringify(s.block)); } // -- Wall: illegal move, never fatal -- { const lvl = synthLevel([floor(0, 0), wall(1, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); const res = applyMove(lvl, s, 'right'); check('rolling into a wall is a no-op, not fatal', res.moved === false && s.status === 'playing'); check('the block does not move when blocked by a wall', s.block.x === 0 && s.block.y === 0 && s.block.orient === 'up'); } // -- Split units move independently: one blocked by a wall, the other proceeds -- { const lvl = synthLevel([floor(5, 5), wall(6, 5), floor(6, 6), floor(7, 5)], { x: 5, y: 5, orient: 'up' }); const s = { block: { mode: 'split', a: { x: 5, y: 5 }, b: { x: 6, y: 5 } }, toggleBridges: new Map(), broken: new Set(), status: 'playing', }; applyMove(lvl, s, 'right'); const { a, b } = s.block; check('a wall-blocked split unit stays put while its partner moves on', a.x === 5 && a.y === 5 && b.x === 7 && b.y === 5, JSON.stringify(s.block)); } // -- Either split unit falling is fatal for the whole run -- { const lvl = synthLevel([floor(0, 0), floor(1, 0)], { x: 0, y: 0, orient: 'up' }); const s = { block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 0 } }, toggleBridges: new Map(), broken: new Set(), status: 'playing', }; const res = applyMove(lvl, s, 'down'); // both units roll off into the void check('either split unit falling ends the run', res.dead === true && s.status === 'dead'); } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`[verify] ${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);