// 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, previewRoll, DIR_LIST, } from '../src/games/bloxorz/BloxorzLogic.js'; import { EX, EY, depthOf, boxesForBlock, boxCorners, rollPoints, visibleFaces, } from '../src/games/bloxorz/BloxorzIso.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}`); const actPars = []; 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 === 'switch' && !['standing', 'any', 'lying'].includes(t.requireOrientation)) boundsOk = false; 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}`); actPars.push(def.par); } // The bank is built as six six-level acts that have to get harder. Compare act // averages rather than adjacent levels, so an act can still open with a // breather without the whole curve flattening out. const actAvg = []; for (let i = 0; i < actPars.length; i += 6) { const slice = actPars.slice(i, i + 6); actAvg.push(slice.reduce((a, b) => a + b, 0) / slice.length); } for (let i = 1; i < actAvg.length; i++) { check(`act ${i + 1} is harder than act ${i}`, actAvg[i] > actAvg[i - 1], `${actAvg[i - 1].toFixed(1)} -> ${actAvg[i].toFixed(1)}`); } check('no two levels share a name', new Set(levels.map((l) => l.name)).size === levels.length); // ── 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 softSw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'any', mode: 'toggle' }); const heavySw = (x, y, id, ids) => ({ x, y, type: 'switch', switchId: id, linkedBridgeIds: ids, requireOrientation: 'standing', mode: 'toggle' }); const holdPad = (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'); } // -- Only a standing landing drops into the goal; lying just bridges it -- { const lvl = synthLevel([floor(0, 0), goal(1, 0), floor(2, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); const res = applyMove(lvl, s, 'right'); // lands lying across the goal [1] and floor [2] check('a block lying across the goal bridges it instead of falling in', res.dead !== true && s.status === 'playing', JSON.stringify(s.block)); const lvl1b = synthLevel([floor(0, 0), goal(1, 0)], { x: 0, y: 0, orient: 'up' }); const s1b = newState(lvl1b); const res1b = applyMove(lvl1b, s1b, 'right'); // lying across the goal [1] and void [2] check('a lying landing half over the void is still fatal, goal or not', res1b.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 ground: safe lying, straight through it standing -- { const lvl = synthLevel([floor(0, 0), fragileT(1, 0), fragileT(2, 0), floor(3, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); const res = applyMove(lvl, s, 'right'); // lying across both fragile cells check('a lying block rests on fragile ground safely', res.dead !== true && s.status === 'playing'); const res2 = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3 (solid) — fine check('rolling off fragile onto solid ground is fine', res2.dead !== true && s.status === 'playing'); } { const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(2, 0), fragileT(3, 0), floor(4, 0)], { x: 0, y: 0, orient: 'up' }); const s = newState(lvl); applyMove(lvl, s, 'right'); // standing0 -> lying[1,2] const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, upright on fragile check('standing upright on fragile ground drops the block', res.dead === true && s.status === 'dead'); } { // A lone split cube is half the weight, so it may stand on fragile ground. const lvl = synthLevel([floor(0, 0), floor(1, 0), floor(1, 1), fragileT(2, 1)], { x: 0, y: 0, orient: 'up' }); const s = { block: { mode: 'split', a: { x: 0, y: 0 }, b: { x: 1, y: 1 } }, toggleBridges: new Map(), status: 'playing', }; const res = applyMove(lvl, s, 'right'); // a -> (1,0) solid, b -> (2,1) fragile check('a split cube is light enough to stand on fragile ground', res.dead !== true && s.status === 'playing' && s.block.mode === 'split', JSON.stringify(s.block)); } // -- Heavy switch: only counts standing upright on it -- { const lvl = synthLevel([ floor(0, 0), floor(1, 0), heavySw(2, 0, 's1', ['b1']), 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], LYING across the heavy switch check('a lying block does not press a heavy switch', s.toggleBridges.get('b1') !== true); const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, the still-closed bridge check('the heavy-switch bridge is still shut for a lying press', res.dead === true); const lvl2 = synthLevel([ floor(0, 0), floor(1, 0), heavySw(2, 0, 's1', ['b1']), bridgeT(3, 0, 'b1', false), floor(4, 0), floor(2, 1), floor(2, 2), floor(2, 3), ], { x: 2, y: 3, orient: 'up' }); const s2 = newState(lvl2); applyMove(lvl2, s2, 'up'); // standing (2,3) -> lying (2,1)-(2,2) applyMove(lvl2, s2, 'up'); // -> standing on (2,0), the heavy switch check('standing upright on a heavy switch opens its bridge', s2.block.orient === 'up' && s2.block.y === 0 && s2.toggleBridges.get('b1') === true, JSON.stringify(s2.block)); } // -- Soft switch: any contact, persistent -- { const lvl = synthLevel([ floor(0, 0), softSw(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 soft switch check('soft 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('soft-switch bridge stays open for a later crossing', res.dead !== true && s.status === 'playing'); } // -- Hold pad (momentary): open only while covered, recloses immediately -- { const lvl = synthLevel([ floor(0, 0), floor(1, 0), holdPad(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 hold pad (col2) const res = applyMove(lvl, s, 'right'); // lying[1,2] -> standing3, crossing the bridge in the same instant check('hold-pad bridge is passable while still covered from the prior landing', res.dead !== true && s.status === 'playing'); check('hold-pad 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(), 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)); } // -- Two cubes can never end up stacked in one cell -- { const lvl = synthLevel([floor(0, 0), floor(1, 0), wall(2, 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(), status: 'playing', }; const res = applyMove(lvl, s, 'right'); // b is wall-blocked, so a can't take its cell check('a cube cannot roll into the cell its wall-blocked partner still fills', res.moved === false && s.block.a.x === 0 && s.block.b.x === 1, 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(), 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'); } // ── Renderer geometry (BloxorzIso is Phaser-free, so it runs here) ─────────── // The rolling animation is only faithful if a 90-degree tip about the leading // ground edge lands the block exactly where the engine says it goes — this is // what ties the renderer to the single source of truth. { const canon = (pts) => pts .map((p) => [p.x, p.y, p.z].map((v) => v.toFixed(6)).join(',')) .sort() .join(' | '); for (const orient of ['up', 'x', 'y']) { for (const dir of DIR_LIST) { const block = { mode: 'joined', orient, x: 5, y: 4 }; const box = boxesForBlock(block)[0]; const rolled = canon(rollPoints(boxCorners(box), dir, box, 1)); const target = canon(boxCorners(boxesForBlock(previewRoll(block, dir))[0])); check(`rolling a '${orient}' block ${dir} lands on the engine's pose`, rolled === target); } } // Rigid body: every edge length is preserved through the whole sweep, // including past 90 degrees where the fall animation keeps turning. const box = boxesForBlock({ mode: 'joined', orient: 'up', x: 0, y: 0 })[0]; const start = boxCorners(box); const EDGES = [[0, 1], [0, 2], [0, 4], [3, 1], [3, 2], [3, 7], [5, 1], [5, 4], [5, 7], [6, 2], [6, 4], [6, 7]]; const len = (p, q) => Math.hypot(p.x - q.x, p.y - q.y, p.z - q.z); let rigid = true; for (const t of [0.13, 0.4, 0.77, 1, 1.6, 2.1]) { const pts = rollPoints(start, 'right', box, t); for (const [a, b] of EDGES) { if (Math.abs(len(start[a], start[b]) - len(pts[a], pts[b])) > 1e-9) rigid = false; } } check('the block stays rigid through the entire roll and tumble', rigid); check('a lying block is one cell tall, a standing block two', (() => { const up = boxesForBlock({ mode: 'joined', orient: 'up', x: 0, y: 0 })[0]; const lying = boxesForBlock({ mode: 'joined', orient: 'x', x: 0, y: 0 })[0]; return up.z1 === 2 && lying.z1 === 1 && lying.x1 - lying.x0 === 2; })()); check('split cubes render as two separate inset 1x1x1 pieces', (() => { const cubes = boxesForBlock({ mode: 'split', a: { x: 1, y: 1 }, b: { x: 4, y: 1 } }); return cubes.length === 2 && cubes.every((c) => c.z1 === 1 && c.x1 - c.x0 < 1 && c.x1 - c.x0 > 0.8); })()); // Painter's algorithm precondition: both grid axes must recede down-screen, // otherwise ascending depthOf() no longer draws far tiles first. check('projection is non-degenerate', Math.abs(EX.x * EY.y - EX.y * EY.x) > 1e-6); check('both grid axes point down-screen (painter order holds)', EX.y > 0 && EY.y > 0); check('depthOf increases along both axes', depthOf(1, 0) > depthOf(0, 0) && depthOf(0, 1) > depthOf(0, 0)); check('the block is not viewed at 45 degrees', Math.abs(EX.y / EX.x) < 0.35); // Convex box: culling alone must leave a non-overlapping set of front faces. let faceCountOk = visibleFaces(boxCorners(box)).length === 3; for (let t = 0; t <= 2.2; t += 0.05) { const n = visibleFaces(rollPoints(start, 'right', box, t)).length; if (n < 2 || n > 3) faceCountOk = false; } check('only front faces are drawn, at every roll angle', faceCountOk); } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`[verify] ${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);