fertig-classic-games/tools/verifyBloxorz.js

387 lines
19 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, 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);