// Headless verification for Zuma. // node tools/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 (data/zuma.json geometry + parameters). // 10. Aimbot soak: every banked level is winnable, and the star curve sits // above what mechanical play achieves. 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, validateLevel, validateLevelParams, } from '../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]]; // Fixture chains are laid out in multiples of the tuned spacing, never in raw // pixels — otherwise resizing the marbles silently turns "contiguous" fixtures // into gapped ones and the insertion tests start asserting nonsense. const SP = T.BALL_SPACING; 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; } // ── Aimbot ─────────────────────────────────────────────────────────────────── // Deliberately mechanical: it never sets up a chain, never banks a shot off a // gap, and never holds a colour. Whatever it clears, a player clears. // Walks the shot ray like rayHit does, but reports which ball it lands on. function firstHit(st, angle) { const dx = Math.cos(angle), dy = Math.sin(angle); const stepLen = T.BALL_RADIUS / 2; const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H); let x = st.frog.x + dx * T.FROG_MUZZLE; let y = st.frog.y + dy * T.FROG_MUZZLE; for (let d = 0; d < max; d += stepLen) { for (const b of st.balls) { if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b; } x += dx * stepLen; y += dy * stepLen; } return null; } // Rank every reachable ball: landing between two of the held colour beats // landing beside one, which beats landing on a bare match. function chooseShot(st) { let best = null; for (const target of st.balls) { const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x); const hit = firstHit(st, angle); if (!hit) continue; const i = st.balls.indexOf(hit); const prev = st.balls[i - 1], next = st.balls[i + 1]; let score = 0; if (hit.color === st.current) { score += 5; if (prev?.color === st.current) score += 10; if (next?.color === st.current) score += 10; } if (prev?.color === st.current && next?.color === st.current) score += 8; if (score > 0 && (!best || score > best.score)) best = { angle, score }; } return best; } function aimbotPlay(def, seed) { const st = createLevel(def, seed ?? def.seed); const DT = 25; let sinceShot = 0; for (let t = 0; t < 400000; t += DT) { step(st, DT); if (st.status === 'won' || st.status === 'lost') break; if (st.status !== 'playing') continue; sinceShot += DT; if (sinceShot < 450) continue; let shot = chooseShot(st); if (!shot) { swapBalls(st); shot = chooseShot(st); } // try the on-deck colour if (shot) { fireBall(st, shot.angle); sinceShot = 0; } } return { status: st.status, score: st.score }; } // 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: 600 + 2 * SP }, { color: 1, s: 600 + SP }, { 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, 600 + 2 * SP + 100, 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: 600 - SP }, { color: 0, s: 600 - 2 * SP }, { color: 1, s: 600 - 3 * SP }, { color: 2, s: 600 - 4 * SP }] ); 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, 600 - SP, 0.01) && near(st.balls[3].s, 600 - 2 * SP, 0.01)); check('front insert shoves balls ahead', near(st.balls[0].s, 600 + SP, 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, 600 - 2 * SP, 0.01) && near(st.balls[2].s, 600 - SP, 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, 600 - 5 * SP, 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: 900 - SP }, { color: 2, s: 900 - 2.5 * SP }, { color: 3, s: 900 - 3.5 * SP }]); 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: 600 - SP }, { color: 1, s: 600 - 2 * SP }, { color: 1, s: 600 - 3 * SP }]); 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: 600 + 2 * SP }, { color: 0, s: 600 + SP }, { color: 0, s: 600 }, { color: 0, s: 600 - SP }]); 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: 600 - 2.5 * SP }, { color: 1, s: 600 - 3.5 * SP }]); 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: 900 - SP }, { color: 1, s: 900 - 5 * SP }, { color: 0, s: 900 - 6 * SP }]); 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: 900 - SP }, { color: 0, s: 900 - 5 * SP }, { color: 1, s: 900 - 6 * SP }]); 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, 900 - 2 * SP, 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: 800 - 3 * SP }]); 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: 800 - 3 * SP }]); 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: 900 - SP }, { color: 0, s: 900 - 2 * SP, power: 'explosion' }, { color: 2, s: 900 - 3 * SP }, { color: 2, s: 900 - 4 * SP }, { color: 3, s: 900 - 5 * SP }, { color: 3, s: 900 - 6 * SP }, ]); 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: 2 * SP }, { color: 3, s: SP }]); 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, '../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)); // Geometry and parameters both come from ZumaLogic — the same lint // genZuma.js gates on and the editor shows live, so the three can't drift. let geomOk = true, paramOk = true, geomDetail = '', paramDetail = ''; for (const l of levels) { const perr = validateLevelParams(l); if (perr.length) { paramOk = false; paramDetail = `level ${l.level}: ${perr[0]}`; } const gerr = validateLevel(l).errs; if (gerr.length) { geomOk = false; geomDetail = `level ${l.level}: ${gerr[0]}`; } } check('level parameters in range', paramOk, paramDetail); check(`geometry lint (length, bounds, curvature ≥ ${(T.BALL_RADIUS * 1.7).toFixed(0)}px, frog clear ≥ ${T.FROG_CLEARANCE}px)`, geomOk, geomDetail); // ── 10. Winnability soak ───────────────────────────────────────────────── // If a bot that only ever takes the best immediately available shot can // clear a level, a player can. This is what catches a quota the path can // technically hold but nobody could actually survive. // // Over SEEDS seeds, not one: a single seed makes this check hostage to // chain luck, so any unrelated tuning nudge (moving the muzzle by 26px, // say) flips levels between pass and fail for no real reason. console.log('\n— Aimbot soak —'); const SEEDS = 8; const FLOOR = 5; // per-level clears required out of SEEDS const rows = levels.map((l) => { const runs = []; for (let k = 0; k < SEEDS; k++) runs.push(aimbotPlay(l, l.seed + k * 7919)); return { level: l.level, wins: runs.filter((r) => r.status === 'won').length, best: Math.max(...runs.map((r) => r.score)), mean: runs.reduce((a, r) => a + r.score, 0) / SEEDS, }; }); const weak = rows.filter((r) => r.wins < FLOOR); check(`aimbot clears every level at least ${FLOOR}/${SEEDS} times`, weak.length === 0, weak.map((r) => `L${r.level} ${r.wins}/${SEEDS}`).join(', ')); const totalWins = rows.reduce((a, r) => a + r.wins, 0); const rate = totalWins / (rows.length * SEEDS); console.log(` .. aimbot clear rate: ${(rate * 100).toFixed(0)}% (worst level ${Math.min(...rows.map((r) => r.wins))}/${SEEDS})`); check('bank-wide clear rate ≥ 80%', rate >= 0.8, `${(rate * 100).toFixed(0)}%`); const perMarble = rows.reduce((a, r, i) => a + r.mean / levels[i].quota, 0) / rows.length; console.log(` .. mean score per quota marble: ${perMarble.toFixed(1)}`); // Medals are judged on the bot's MEAN run, not its best: best-of-N is a // measure of chain luck, and the question here is where the star curve // sits relative to ordinary mechanical play. const threeStar = rows.filter((r, i) => r.mean >= levels[i].starScores[2]).length; const twoStar = rows.filter((r, i) => r.mean >= levels[i].starScores[1]).length; const ceiling = rows.filter((r, i) => r.best >= levels[i].starScores[2]).length; console.log(` .. aimbot medals (mean run): ★★★ on ${threeStar}, ★★+ on ${twoStar} of ${levels.length}` + ` — ★★★ within reach on a lucky run for ${ceiling}`); check('three stars stays a stretch for the aimbot', threeStar < levels.length / 2, `${threeStar}/${levels.length}`); check('three stars is not out of reach either', ceiling >= 3, `only ${ceiling}/${levels.length} even on a best run`); check('two stars is within the aimbot\'s reach', twoStar >= levels.length / 2, `${twoStar}/${levels.length}`); } } // ── Result ─────────────────────────────────────────────────────────────────── console.log(''); if (failures) { console.error(`${failures} failure(s)`); process.exit(1); } console.log('All Zuma checks passed.');