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