410 lines
20 KiB
JavaScript
410 lines
20 KiB
JavaScript
// Headless verification for Zuma.
|
||
// node server/scripts/verifyZuma.js
|
||
// Exits non-zero on any failure.
|
||
//
|
||
// 1. Path construction (arc-length parameterization).
|
||
// 2. Chain advance, spawning, intro transition, spacing invariant.
|
||
// 3. Insertion (front/behind/tail wedges, shove-merge clank).
|
||
// 4. Match detection and scoring.
|
||
// 5. Pull-back chains and catch-up clanks.
|
||
// 6. Power-ups (slow, reverse, accuracy, explosion).
|
||
// 7. Win/lose state machine, recolor, last-call spawns.
|
||
// 8. Determinism (seeded replay).
|
||
// 9. Level bank lint (public/data/zuma.json geometry + parameters).
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
import {
|
||
TUNING, POWER_KINDS,
|
||
buildPath, createLevel, step, fireBall, swapBalls,
|
||
insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent,
|
||
} from '../../public/src/games/zuma/ZumaLogic.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const T = TUNING;
|
||
|
||
let failures = 0;
|
||
function check(name, cond, detail = '') {
|
||
if (cond) { console.log(` ok ${name}`); }
|
||
else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
|
||
}
|
||
const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
|
||
|
||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||
|
||
const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]];
|
||
const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]];
|
||
|
||
function mkDef(over = {}) {
|
||
return {
|
||
level: 1, name: 'Test', shape: 'line',
|
||
points: STRAIGHT, frog: [800, 900],
|
||
colors: 4, quota: 10, introBalls: 2,
|
||
pushSpeed: 100, powerUpRate: 0, seed: 42,
|
||
starScores: [100, 200, 300],
|
||
...over,
|
||
};
|
||
}
|
||
|
||
function mkState(defOver = {}, over = {}) {
|
||
const st = createLevel(mkDef(defOver));
|
||
Object.assign(st, over);
|
||
return st;
|
||
}
|
||
|
||
// spec: [{ color, s, power? }] front-first (descending s)
|
||
function mkChain(st, spec) {
|
||
st.balls = spec.map((b) => ({
|
||
id: st.nextId++, color: b.color, power: b.power ?? null, s: b.s, x: 0, y: 0,
|
||
}));
|
||
for (const b of st.balls) {
|
||
const p = st.path.pointAt(b.s);
|
||
b.x = p.x; b.y = p.y;
|
||
}
|
||
return st;
|
||
}
|
||
|
||
function spacingOk(balls) {
|
||
for (let i = 0; i < balls.length - 1; i++) {
|
||
const d = balls[i].s - balls[i + 1].s;
|
||
if (d < T.BALL_SPACING - 0.01) return false; // overlap
|
||
if (d > T.BALL_SPACING + 0.01 && d <= T.BALL_SPACING + T.GAP_EPS) return false; // not snapped
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── 1. Path ──────────────────────────────────────────────────────────────────
|
||
console.log('\n— Path —');
|
||
{
|
||
const p = buildPath(STRAIGHT);
|
||
check('straight path length ≈ 1600', near(p.length, 1600, 3), `got ${p.length.toFixed(1)}`);
|
||
const a = p.pointAt(0), b = p.pointAt(p.length);
|
||
check('pointAt(0) at first control point', near(a.x, 0, 1) && near(a.y, 500, 1));
|
||
check('pointAt(length) at last control point', near(b.x, 1600, 1) && near(b.y, 500, 1));
|
||
|
||
const c = buildPath(CURVY);
|
||
let maxErr = 0, maxTanErr = 0;
|
||
const stepS = 37;
|
||
for (let s = 0; s + stepS <= c.length; s += stepS) {
|
||
const u = c.pointAt(s), v = c.pointAt(s + stepS);
|
||
maxErr = Math.max(maxErr, Math.abs(Math.hypot(v.x - u.x, v.y - u.y) - stepS));
|
||
maxTanErr = Math.max(maxTanErr, Math.abs(Math.hypot(u.tx, u.ty) - 1));
|
||
}
|
||
check('curved path constant-speed (equal s → equal distance)', maxErr < stepS * 0.05, `max err ${maxErr.toFixed(2)}px`);
|
||
check('tangents unit length', maxTanErr < 1e-6);
|
||
let mono = true;
|
||
for (let i = 1; i < c.samples.length; i++) if (c.samples[i].s < c.samples[i - 1].s) mono = false;
|
||
check('sample arc lengths monotonic', mono);
|
||
}
|
||
|
||
// ── 2. Advance & spawning ────────────────────────────────────────────────────
|
||
console.log('\n— Advance & spawning —');
|
||
{
|
||
const st = mkState({ quota: 8, introBalls: 3 });
|
||
let introSpawned = -1;
|
||
for (let i = 0; i < 4000 && st.status === 'intro'; i++) {
|
||
step(st, 16);
|
||
if (st.status !== 'intro') introSpawned = st.spawned;
|
||
}
|
||
check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`);
|
||
for (let i = 0; i < 4000 && st.spawned < 8; i++) step(st, 16);
|
||
check('spawn stops at quota', st.spawned === 8 && st.balls.length === 8);
|
||
check('spacing invariant after spawning', spacingOk(st.balls));
|
||
|
||
const st2 = mkState({}, { status: 'playing', spawned: 10 });
|
||
mkChain(st2, [{ color: 0, s: 696 }, { color: 1, s: 648 }, { color: 2, s: 600 }]);
|
||
for (let i = 0; i < 20; i++) step(st2, 50); // 1s at pushSpeed 100
|
||
check('single segment drives at pushSpeed', near(st2.balls[0].s, 796, 1), `got ${st2.balls[0].s.toFixed(1)}`);
|
||
check('spacing preserved while driving', spacingOk(st2.balls));
|
||
}
|
||
|
||
// ── 3. Insertion ─────────────────────────────────────────────────────────────
|
||
console.log('\n— Insertion —');
|
||
{
|
||
const base = () => mkChain(
|
||
mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 600 }, { color: 1, s: 552 }, { color: 0, s: 504 }, { color: 1, s: 456 }, { color: 2, s: 408 }]
|
||
);
|
||
|
||
let st = base(); let ev = [];
|
||
insertBall(st, 3, 2, +1, ev);
|
||
check('front insert lands in front of hit ball',
|
||
st.balls[2].color === 3 && near(st.balls[2].s, 552, 0.01) && near(st.balls[3].s, 504, 0.01));
|
||
check('front insert shoves balls ahead', near(st.balls[0].s, 648, 0.01) && near(st.balls[1].s, 600, 0.01));
|
||
check('front insert keeps spacing', spacingOk(st.balls) && st.balls.length === 6);
|
||
check('non-matching insert resets combo, no pop', st.combo === 0 && !ev.some((e) => e.type === 'pop'));
|
||
|
||
st = base(); ev = [];
|
||
insertBall(st, 3, 2, -1, ev);
|
||
check('behind insert wedges after hit ball',
|
||
st.balls[3].color === 3 && near(st.balls[3].s, 504, 0.01) && near(st.balls[2].s, 552, 0.01));
|
||
check('behind insert keeps spacing', spacingOk(st.balls));
|
||
|
||
st = base(); ev = [];
|
||
insertBall(st, 3, 4, -1, ev);
|
||
check('tail attach adds at rear without shoving',
|
||
near(st.balls[5].s, 360, 0.01) && near(st.balls[0].s, 600, 0.01));
|
||
|
||
// shove closes a gap → clank, no pop (junction colors differ)
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 900 }, { color: 1, s: 852 }, { color: 2, s: 780 }, { color: 3, s: 732 }]);
|
||
ev = [];
|
||
insertBall(st, 3, 2, +1, ev);
|
||
check('shove-merge emits clank', ev.some((e) => e.type === 'clank'));
|
||
check('shove-merge joins segments', segmentsOf(st.balls).length === 1 && spacingOk(st.balls));
|
||
check('shove-merge without matching junction does not pop', !ev.some((e) => e.type === 'pop'));
|
||
|
||
// laser-sight ray helper
|
||
st = mkChain(mkState({}, { status: 'playing' }), [{ color: 0, s: 600 }]);
|
||
const ball = st.balls[0];
|
||
const ray = rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x));
|
||
check('rayHit finds first chain ball', ray.hit && Math.hypot(ray.x - ball.x, ray.y - ball.y) < T.BALL_SPACING);
|
||
}
|
||
|
||
// ── 4. Matching ──────────────────────────────────────────────────────────────
|
||
console.log('\n— Matching —');
|
||
{
|
||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 1, s: 504 }, { color: 1, s: 456 }]);
|
||
let ev = [];
|
||
insertBall(st, 0, 1, +1, ev);
|
||
const pop = ev.find((e) => e.type === 'pop');
|
||
check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.balls.length === 2);
|
||
check('3-pop score = 3 × SCORE_BALL', pop?.score === 3 * T.SCORE_BALL && st.score === 3 * T.SCORE_BALL);
|
||
check('shot pop sets combo to 1', pop?.combo === 1);
|
||
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 696 }, { color: 0, s: 648 }, { color: 0, s: 600 }, { color: 0, s: 552 }]);
|
||
ev = [];
|
||
insertBall(st, 0, 1, -1, ev);
|
||
const pop5 = ev.find((e) => e.type === 'pop');
|
||
check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.balls.length === 0);
|
||
check('5-pop score', pop5?.score === 5 * T.SCORE_BALL);
|
||
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 600 }, { color: 0, s: 450 }, { color: 1, s: 402 }]);
|
||
ev = [];
|
||
insertBall(st, 0, 1, +1, ev);
|
||
check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4);
|
||
|
||
const run = findRun(st.balls, 1);
|
||
check('findRun bounded by the gap', run.lo === 1 && run.hi === 2);
|
||
}
|
||
|
||
// ── 5. Pull-back & catch-up ──────────────────────────────────────────────────
|
||
console.log('\n— Pull-back & catch-up —');
|
||
{
|
||
// matching gap edges → front segment retreats, contact pops with chain bonus
|
||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 1, s: 900 }, { color: 1, s: 852 }, { color: 1, s: 600 }, { color: 0, s: 552 }]);
|
||
let popEv = null, clankSeen = false, retreated = false;
|
||
for (let i = 0; i < 200 && !popEv; i++) {
|
||
const ev = step(st, 25);
|
||
if (st.balls.length && st.balls[0].s < 900 - 1) retreated = true;
|
||
if (ev.some((e) => e.type === 'clank')) clankSeen = true;
|
||
popEv = ev.find((e) => e.type === 'pop') ?? popEv;
|
||
}
|
||
check('matching gap pulls front segment backward', retreated);
|
||
check('pull-back contact clanks and pops', clankSeen && !!popEv && popEv.ids.length === 3);
|
||
check('chain pop scores chain bonus', popEv?.cause === 'chain'
|
||
&& popEv?.score === 3 * T.SCORE_BALL + T.SCORE_CHAIN_BONUS);
|
||
check('chain pop increments combo', popEv?.combo === 1);
|
||
check('survivor remains after chain pop', st.balls.length === 1 && st.balls[0].color === 0);
|
||
|
||
// non-matching gap → rear catches up, front stays put, clank without pop
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 900 }, { color: 1, s: 852 }, { color: 0, s: 600 }, { color: 1, s: 552 }]);
|
||
let clankAt = null;
|
||
for (let i = 0; i < 200 && !clankAt; i++) {
|
||
const frontBefore = st.balls[0].s;
|
||
const ev = step(st, 25);
|
||
if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.balls[2].s };
|
||
}
|
||
check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 804, 1.5),
|
||
clankAt ? `rear front at ${clankAt.rearFront.toFixed(1)}` : 'no clank');
|
||
check('non-matching gap: front segment stays put', !!clankAt && near(clankAt.frontBefore, 900, 0.01));
|
||
check('no pop on non-matching junction', st.balls.length === 4);
|
||
const before = st.balls[0].s;
|
||
for (let i = 0; i < 8; i++) step(st, 50);
|
||
check('merged chain resumes driving', st.balls[0].s > before + 30);
|
||
}
|
||
|
||
// ── 6. Power-ups ─────────────────────────────────────────────────────────────
|
||
console.log('\n— Power-ups —');
|
||
{
|
||
// slow: popped slow ball sets the timer; drive rate drops to SLOW_MULT
|
||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 656 }]);
|
||
let ev = [];
|
||
popRun(st, 0, 0, 'shot', ev);
|
||
check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS
|
||
&& ev.some((e) => e.type === 'powerup' && e.kind === 'slow'));
|
||
const s0 = st.balls[0].s;
|
||
for (let i = 0; i < 20; i++) step(st, 50);
|
||
check('slow halves the drive (SLOW_MULT)', near(st.balls[0].s - s0, 100 * T.SLOW_MULT, 1.5),
|
||
`moved ${(st.balls[0].s - s0).toFixed(1)}`);
|
||
|
||
// reverse: chain rolls backward, then resumes forward when expired
|
||
// (elapsedMs increments before the effect check, so 501 covers exactly 10 ticks)
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [{ color: 0, s: 800 }]);
|
||
st.effects.reverseUntil = st.elapsedMs + 501;
|
||
for (let i = 0; i < 10; i++) step(st, 50);
|
||
check('reverse rolls the chain backward', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2),
|
||
`at ${st.balls[0].s.toFixed(1)}`);
|
||
for (let i = 0; i < 10; i++) step(st, 50);
|
||
check('drive resumes after reverse expires', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2),
|
||
`at ${st.balls[0].s.toFixed(1)}`);
|
||
|
||
// accuracy: flag set on pop; fired flights move faster
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 656 }]);
|
||
ev = [];
|
||
popRun(st, 0, 0, 'shot', ev);
|
||
check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS);
|
||
const flight = fireBall(st, -Math.PI / 2);
|
||
check('accuracy speeds up shots', !!flight && near(flight.speed, T.SHOT_SPEED * T.ACCURACY_SHOT_MULT, 0.01));
|
||
|
||
// explosion: blast radius around the popped ball, nothing beyond
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [
|
||
{ color: 1, s: 900 }, { color: 1, s: 852 }, { color: 0, s: 804, power: 'explosion' },
|
||
{ color: 2, s: 756 }, { color: 2, s: 708 }, { color: 3, s: 660 }, { color: 3, s: 612 },
|
||
]);
|
||
ev = [];
|
||
popRun(st, 2, 2, 'shot', ev);
|
||
const boom = ev.find((e) => e.type === 'explosion');
|
||
check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.balls.length === 2);
|
||
check('explosion spares balls beyond radius', st.balls.every((b) => b.color === 3));
|
||
}
|
||
|
||
// ── 7. State machine ─────────────────────────────────────────────────────────
|
||
console.log('\n— State machine —');
|
||
{
|
||
let st = mkState({}, { status: 'playing', spawned: 10 });
|
||
mkChain(st, [{ color: 0, s: st.path.length - 20 }]);
|
||
let lostEv = false;
|
||
for (let i = 0; i < 10 && st.status === 'playing'; i++) {
|
||
if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true;
|
||
}
|
||
check('ball reaching the hole loses', st.status === 'lost' && lostEv);
|
||
check('terminal state ignores further steps', step(st, 50).length === 0);
|
||
|
||
st = mkState({}, { status: 'playing', spawned: 10, balls: [], flights: [] });
|
||
const ev = step(st, 16);
|
||
const won = ev.find((e) => e.type === 'won');
|
||
const expectBonus = Math.max(0, Math.ceil((10 * T.TIME_PAR_MS_PER_BALL - st.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
|
||
check('cleared board after quota wins', st.status === 'won' && !!won);
|
||
check('win time bonus math', won?.timeBonus === expectBonus && st.score === expectBonus,
|
||
`bonus ${won?.timeBonus} expected ${expectBonus}`);
|
||
|
||
st = mkState(); // status 'intro'
|
||
check('firing rejected during intro', fireBall(st, 0) === null);
|
||
st.status = 'won';
|
||
check('firing rejected after game over', fireBall(st, 0) === null);
|
||
const c0 = st.current, n0 = st.next;
|
||
swapBalls(st);
|
||
check('swap rejected after game over', st.current === c0 && st.next === n0);
|
||
st.status = 'playing';
|
||
swapBalls(st);
|
||
check('swap exchanges current and next', st.current === n0 && st.next === c0);
|
||
|
||
// recolor: shooter colors must exist on the board after a pop
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 2, s: 504 }, { color: 2, s: 456 }]);
|
||
st.current = 0; st.next = 0;
|
||
const ev2 = [];
|
||
popRun(st, 0, 1, 'shot', ev2);
|
||
check('shooter recolors to colors still present', st.current === 2 && st.next === 2
|
||
&& ev2.filter((e) => e.type === 'recolor').length === 2);
|
||
|
||
// last-call: final spawns only deal colors still on the board
|
||
st = mkChain(mkState({}, { status: 'playing', spawned: 5 }),
|
||
[{ color: 3, s: 96 }, { color: 3, s: 48 }]);
|
||
for (let i = 0; i < 400 && st.spawned < 10; i++) step(st, 50);
|
||
check('last-call spawns restrict to present colors',
|
||
st.spawned === 10 && st.balls.every((b) => b.color === 3));
|
||
}
|
||
|
||
// ── 8. Determinism ───────────────────────────────────────────────────────────
|
||
console.log('\n— Determinism —');
|
||
{
|
||
const run = () => {
|
||
const st = createLevel(mkDef({ quota: 20, powerUpRate: 0.1, seed: 777 }));
|
||
const log = [];
|
||
for (let tick = 0; tick < 600 && st.status !== 'lost' && st.status !== 'won'; tick++) {
|
||
if (tick === 80) fireBall(st, -Math.PI / 2 + 0.3);
|
||
if (tick === 160) swapBalls(st);
|
||
if (tick === 200) fireBall(st, -Math.PI / 2 - 0.2);
|
||
if (tick === 300) fireBall(st, -Math.PI / 2);
|
||
for (const e of step(st, 25)) log.push(e.type + (e.ids ? `:${e.ids.length}` : ''));
|
||
}
|
||
return { log: log.join(','), score: st.score, status: st.status, balls: st.balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|') };
|
||
};
|
||
const a = run(), b = run();
|
||
check('identical seed + script → identical events', a.log === b.log);
|
||
check('identical final score and chain', a.score === b.score && a.balls === b.balls && a.status === b.status);
|
||
check('scripted run produced activity', a.log.includes('pop') || a.log.includes('inserted'));
|
||
}
|
||
|
||
// ── 9. Level bank lint ───────────────────────────────────────────────────────
|
||
console.log('\n— Level bank —');
|
||
{
|
||
let bank = null;
|
||
try {
|
||
bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/zuma.json'), 'utf8'));
|
||
} catch (_) { /* handled below */ }
|
||
check('bank exists (run genZuma.js)', !!bank);
|
||
if (bank) {
|
||
const levels = bank.levels ?? [];
|
||
check('bank has 20 levels', levels.length === 20);
|
||
check('levels numbered 1..N contiguously', levels.every((l, i) => l.level === i + 1));
|
||
let geomOk = true, paramOk = true, clearOk = true, curveOk = true, detail = '';
|
||
for (const l of levels) {
|
||
if (!(l.colors >= 4 && l.colors <= 6 && l.quota >= 20 && l.introBalls < l.quota
|
||
&& l.pushSpeed >= 10 && l.pushSpeed <= 80
|
||
&& l.powerUpRate >= 0 && l.powerUpRate <= 0.2
|
||
&& Array.isArray(l.starScores) && l.starScores.length === 3
|
||
&& l.starScores[0] < l.starScores[1] && l.starScores[1] < l.starScores[2])) {
|
||
paramOk = false; detail = `level ${l.level} params`;
|
||
}
|
||
const path = buildPath(l.points);
|
||
if (path.length < l.quota * T.BALL_SPACING * 1.6) {
|
||
geomOk = false; detail = `level ${l.level} too short (${path.length.toFixed(0)} for quota ${l.quota})`;
|
||
}
|
||
let minFrog = Infinity, minRadius = Infinity;
|
||
for (let i = 0; i < path.samples.length; i++) {
|
||
const p = path.samples[i];
|
||
minFrog = Math.min(minFrog, Math.hypot(p.x - l.frog[0], p.y - l.frog[1]));
|
||
if (p.s > 200 && (p.x < 40 || p.x > 1880 || p.y < 40 || p.y > 1040)) {
|
||
geomOk = false; detail = `level ${l.level} sample out of bounds at s=${p.s.toFixed(0)}`;
|
||
}
|
||
if (i > 0 && i < path.samples.length - 1 && p.s > 200) {
|
||
const a = path.samples[i - 1], c = path.samples[i + 1];
|
||
const v1x = p.x - a.x, v1y = p.y - a.y, v2x = c.x - p.x, v2y = c.y - p.y;
|
||
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
|
||
if (l1 > 0.01 && l2 > 0.01) {
|
||
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
|
||
const theta = Math.acos(cos);
|
||
if (theta > 1e-4) minRadius = Math.min(minRadius, l1 / theta);
|
||
}
|
||
}
|
||
}
|
||
if (minFrog < 140) { clearOk = false; detail = `level ${l.level} frog ${minFrog.toFixed(0)}px from path`; }
|
||
if (minRadius < T.BALL_RADIUS * 1.7) { curveOk = false; detail = `level ${l.level} min radius ${minRadius.toFixed(0)}px`; }
|
||
}
|
||
check('level parameters in range', paramOk, detail);
|
||
check('paths long enough and in bounds', geomOk, detail);
|
||
check('frog clear of every path sample (≥140px)', clearOk, detail);
|
||
check('curvature radius ≥ 1.7 × ball radius', curveOk, detail);
|
||
}
|
||
}
|
||
|
||
// ── Result ───────────────────────────────────────────────────────────────────
|
||
console.log('');
|
||
if (failures) {
|
||
console.error(`${failures} failure(s)`);
|
||
process.exit(1);
|
||
}
|
||
console.log('All Zuma checks passed.');
|