fertig-classic-games/tools/verifyBloxorz.js

256 lines
12 KiB
JavaScript

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