// 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. Tunnels: submerged chain is inert to flights, the laser and explosions. // 10. Multi-path levels: independent spawners, shared shooter color pool, // physics-based shot targeting, either-path-loses, every-path-to-win. // 11. Level bank lint (assets/gamedata/zuma/ manifest + per-level geometry/parameters). // 12. 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, TUNNEL, buildPath, createLevel, step, fireBall, swapBalls, insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent, validateLevel, validateLevelParams, normalizeTunnels, isHidden, visibilityAt, pathSpans, sampleRange, nearestS, } from '../src/games/zuma/ZumaLogic.js'; import { PORTAL_REACH } from '../src/games/zuma/ZumaPortal.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; // Every existing single-path test passes flat overrides (points/tunnels/quota/ // introBalls/pushSpeed/powerUpRate) exactly as before genZuma.js's `paths` // array existed — mkDef wraps them into a one-element paths array so none of // those call sites need to change. function mkDef(over = {}) { const { points, tunnels, quota, introBalls, pushSpeed, powerUpRate, shape, paths, ...rest } = over; return { level: 1, name: 'Test', frog: [800, 900], colors: 4, seed: 42, starScores: [100, 200, 300], paths: paths ?? [{ shape: shape ?? 'line', points: points ?? STRAIGHT, tunnels, quota: quota ?? 10, introBalls: introBalls ?? 2, pushSpeed: pushSpeed ?? 100, powerUpRate: powerUpRate ?? 0, }], ...rest, }; } // `over` used to be Object.assign'd straight onto state (back when `spawned` // and `balls` lived at the top level); both now live on paths[0], so they're // special-cased here and every existing call site keeps working unchanged. function mkState(defOver = {}, over = {}) { const st = createLevel(mkDef(defOver)); const { spawned, balls, ...rest } = over; Object.assign(st, rest); if (spawned !== undefined) st.paths[0].spawned = spawned; if (balls !== undefined) st.paths[0].balls = balls; 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. Scans // every path for a target — the multi-path story is "physics decides which // chain a shot lands on," so the bot has to consider all of them too. // Walks the shot ray like rayHit does, but reports which ball (and which path) // 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 p of st.paths) { for (const b of p.balls) { if (isHidden(p.tunnels, b.s)) continue; // submerged: the shot flies over if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { ball: b, path: p }; } } x += dx * stepLen; y += dy * stepLen; } return null; } // Rank every reachable ball across every path: 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 p of st.paths) { for (const target of p.balls) { if (isHidden(p.tunnels, target.s)) continue; const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x); const hit = firstHit(st, angle); if (!hit) continue; const i = hit.path.balls.indexOf(hit.ball); const prev = hit.path.balls[i - 1], next = hit.path.balls[i + 1]; let score = 0; if (hit.ball.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). Defaults to // paths[0] — every pre-multi-path fixture is single-path. function mkChain(st, spec, pathIdx = 0) { const p = st.paths[pathIdx]; p.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 p.balls) { const pt = p.path.pointAt(b.s); b.x = pt.x; b.y = pt.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.paths[0].spawned; } check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`); for (let i = 0; i < 4000 && st.paths[0].spawned < 8; i++) step(st, 16); check('spawn stops at quota', st.paths[0].spawned === 8 && st.paths[0].balls.length === 8); check('spacing invariant after spawning', spacingOk(st.paths[0].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.paths[0].balls[0].s, 600 + 2 * SP + 100, 1), `got ${st2.paths[0].balls[0].s.toFixed(1)}`); check('spacing preserved while driving', spacingOk(st2.paths[0].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, st.paths[0], 3, 2, +1, ev); const balls = st.paths[0].balls; check('front insert lands in front of hit ball', balls[2].color === 3 && near(balls[2].s, 600 - SP, 0.01) && near(balls[3].s, 600 - 2 * SP, 0.01)); check('front insert shoves balls ahead', near(balls[0].s, 600 + SP, 0.01) && near(balls[1].s, 600, 0.01)); check('front insert keeps spacing', spacingOk(balls) && 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, st.paths[0], 3, 2, -1, ev); check('behind insert wedges after hit ball', st.paths[0].balls[3].color === 3 && near(st.paths[0].balls[3].s, 600 - 2 * SP, 0.01) && near(st.paths[0].balls[2].s, 600 - SP, 0.01)); check('behind insert keeps spacing', spacingOk(st.paths[0].balls)); st = base(); ev = []; insertBall(st, st.paths[0], 3, 4, -1, ev); check('tail attach adds at rear without shoving', near(st.paths[0].balls[5].s, 600 - 5 * SP, 0.01) && near(st.paths[0].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, st.paths[0], 3, 2, +1, ev); check('shove-merge emits clank', ev.some((e) => e.type === 'clank')); check('shove-merge joins segments', segmentsOf(st.paths[0].balls).length === 1 && spacingOk(st.paths[0].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.paths[0].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, st.paths[0], 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.paths[0].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, st.paths[0], 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.paths[0].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, st.paths[0], 0, 1, +1, ev); check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.paths[0].balls.length === 4); const run = findRun(st.paths[0].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.paths[0].balls.length && st.paths[0].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.paths[0].balls.length === 1 && st.paths[0].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.paths[0].balls[0].s; const ev = step(st, 25); if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.paths[0].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.paths[0].balls.length === 4); const before = st.paths[0].balls[0].s; for (let i = 0; i < 8; i++) step(st, 50); check('merged chain resumes driving', st.paths[0].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, st.paths[0], 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.paths[0].balls[0].s; for (let i = 0; i < 20; i++) step(st, 50); check('slow halves the drive (SLOW_MULT)', near(st.paths[0].balls[0].s - s0, 100 * T.SLOW_MULT, 1.5), `moved ${(st.paths[0].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.paths[0].balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2), `at ${st.paths[0].balls[0].s.toFixed(1)}`); for (let i = 0; i < 10; i++) step(st, 50); check('drive resumes after reverse expires', near(st.paths[0].balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2), `at ${st.paths[0].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, st.paths[0], 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, st.paths[0], 2, 2, 'shot', ev); const boom = ev.find((e) => e.type === 'explosion'); check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.paths[0].balls.length === 2); check('explosion spares balls beyond radius', st.paths[0].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.paths[0].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: [] }); 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, st.paths[0], 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.paths[0].spawned < 10; i++) step(st, 50); check('last-call spawns restrict to present colors', st.paths[0].spawned === 10 && st.paths[0].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.paths[0].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. Tunnels ─────────────────────────────────────────────────────────────── console.log('\n— Tunnels —'); { // normalization: the stored form is loose, the runtime form is not check('tunnels accept [enter, exit] pairs and object form', JSON.stringify(normalizeTunnels([[200, 300]], 1000)) === JSON.stringify(normalizeTunnels([{ enter: 200, exit: 300 }], 1000))); check('tunnels are sorted by entrance', normalizeTunnels([[600, 700], [100, 200]], 1000).map((t) => t.enter).join() === '100,600'); check('tunnels clamp to the path', JSON.stringify(normalizeTunnels([[-50, 1200]], 1000)) === JSON.stringify([{ enter: 0, exit: 1000 }])); check('degenerate tunnels are dropped', normalizeTunnels([[300, 200], [400, 400]], 1000).length === 0); check('a missing tunnels field is no tunnels', normalizeTunnels(undefined, 1000).length === 0); // the gameplay predicate: an open interval, so a mouth itself stays fair game const tun = normalizeTunnels([[200, 400], [700, 900]], 1200); check('inside a tunnel is hidden', isHidden(tun, 300) && isHidden(tun, 800)); check('outside every tunnel is visible', !isHidden(tun, 100) && !isHidden(tun, 550) && !isHidden(tun, 1100)); check('a ball exactly on a mouth is still hittable', !isHidden(tun, 200) && !isHidden(tun, 400)); // the cosmetic ramp is a separate thing and must not leak into the hit rule check('visibility is 1 outside a tunnel', visibilityAt(tun, 100) === 1); check('visibility ramps to 0 within TUNNEL.FADE of a mouth', visibilityAt(tun, 200 + TUNNEL.FADE) === 0 && near(visibilityAt(tun, 200 + TUNNEL.FADE / 2), 0.5, 1e-9)); check('visibility is 0 deep inside', visibilityAt(tun, 300) === 0); // spans partition the path exactly const spans = pathSpans(1200, tun); check('visible spans fill the gaps between tunnels', JSON.stringify(spans.visible) === JSON.stringify([[0, 200], [400, 700], [900, 1200]])); check('hidden spans are the tunnels', JSON.stringify(spans.hidden) === JSON.stringify([[200, 400], [700, 900]])); const covered = [...spans.visible, ...spans.hidden].reduce((a, [s0, s1]) => a + (s1 - s0), 0); check('spans cover the whole path exactly once', near(covered, 1200, 1e-9)); check('no tunnels means one span', pathSpans(1200, []).visible.length === 1); const sp = buildPath(STRAIGHT); const range = sampleRange(sp, 300, 700); check('sampleRange stops dead on its endpoints', near(range[0].x, 300, 0.5) && near(range[range.length - 1].x, 700, 0.5)); check('nearestS projects a point onto the path', near(nearestS(sp, 700, 560).s, 700, 4)); // ── the three interaction rules ── const shootAt = (st, ball) => fireBall(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x)); const runFlights = (st) => { let missed = false; for (let i = 0; i < 200 && st.flights.length; i++) { if (step(st, 25).some((e) => e.type === 'missed')) missed = true; } return missed; }; let st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 }); mkChain(st, [{ color: 0, s: 600 }]); shootAt(st, st.paths[0].balls[0]); check('a shot flies over a submerged ball', runFlights(st) && st.paths[0].balls.length === 1); st = mkState({ pushSpeed: 0 }, { status: 'playing', spawned: 10 }); mkChain(st, [{ color: 0, s: 600 }]); shootAt(st, st.paths[0].balls[0]); check('the same shot lands once the tunnel is gone', !runFlights(st) && st.paths[0].balls.length === 2); st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 }); mkChain(st, [{ color: 0, s: 600 }]); step(st, 16); const ball = st.paths[0].balls[0]; check('a submerged ball is flagged hidden and invisible', ball.hidden === true && ball.vis === 0); check('the laser sight ignores submerged chain', !rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x)).hit); // a blast is stopped by the ground, not just by distance st = mkState({ tunnels: [[620, 720]], pushSpeed: 0 }, { status: 'playing', spawned: 10 }); mkChain(st, [ { color: 1, s: 900 }, { color: 1, s: 836 }, { color: 0, s: 772, power: 'explosion' }, { color: 2, s: 708 }, { color: 2, s: 644 }, ]); step(st, 16); const ev = []; popRun(st, st.paths[0], 2, 2, 'shot', ev); const boom = ev.find((e) => e.type === 'explosion'); check('an explosion cannot reach through a tunnel', !!boom && boom.ids.length === 2 && st.paths[0].balls.length === 2, `caught ${boom?.ids.length}`); check('the submerged pair survives the blast', st.paths[0].balls.every((b) => b.color === 2)); // and the chain itself notices nothing st = mkState({ tunnels: [[400, 700]] }, { status: 'playing', spawned: 10 }); mkChain(st, [{ color: 0, s: 300 }, { color: 1, s: 300 - SP }, { color: 2, s: 300 - 2 * SP }]); for (let i = 0; i < 40; i++) step(st, 50); // 2s at pushSpeed 100 check('the chain rolls straight on through a tunnel', near(st.paths[0].balls[0].s, 500, 1) && spacingOk(st.paths[0].balls), `front at ${st.paths[0].balls[0].s.toFixed(1)}`); check('every ball inside is submerged', st.paths[0].balls.every((b) => b.hidden === (b.s > 400))); // ── lint ── // A 4000px straight: TUNNEL.MIN_LEN plus the mouth clearances no longer fit // two tunnels on the 1600px fixture the rest of the suite uses. It runs off // the canvas, so only the tunnel errors are read here — the bounds and // curvature rules are what the bank lint below covers. const LONG = [[0, 500], [1000, 500], [2000, 500], [3000, 500], [4000, 500]]; const lint = (tunnels) => validateLevel(mkDef({ points: LONG, tunnels })).errs .filter((e) => /tunnel/i.test(e)).join(' | '); check('lint accepts a well-spaced pair', lint([[400, 1000], [1400, 2000]]) === '', lint([[400, 1000], [1400, 2000]])); check('lint rejects a stub tunnel', lint([[400, 700]]).includes('only 300px long')); check('lint rejects a mouth in the spawn lead-in', lint([[100, 700]]).includes('spawn lead-in')); check('lint rejects an exit on top of the hole', lint([[3300, 3900]]).includes('crowds the hole')); check('lint rejects crowded tunnels', lint([[400, 1000], [1100, 1700]]).includes('crowd at')); check('lint rejects burying too much of the path', lint([[400, 1300], [1600, 2500]]).includes('is tunnelled')); check('lint rejects an inverted tunnel', lint([[900, 400]]).includes('exit <= enter')); // TUNNEL.MIN_LEN exists to keep a tunnel's own two maws from growing through // each other, so it has to track the art. Asserted rather than imported: the // engine has no business depending on a drawing module. check(`MIN_LEN (${TUNNEL.MIN_LEN}) clears two portal heads (2 × ${PORTAL_REACH})`, TUNNEL.MIN_LEN >= 2 * PORTAL_REACH); } // ── 10. Multi-path ─────────────────────────────────────────────────────────── console.log('\n— Multi-path —'); { // Two independent straight lanes, well apart on screen, frog centred between // them — the "opposite sides" layout the feature is built around. const LANE_A = [[0, 200], [400, 200], [800, 200], [1200, 200], [1600, 200]]; const LANE_B = [[0, 800], [400, 800], [800, 800], [1200, 800], [1600, 800]]; const twoLanes = (pushSpeed = 100) => ({ level: 1, name: 'Two Lanes', frog: [800, 500], colors: 4, seed: 99, starScores: [100, 200, 300], paths: [ { shape: 'line', points: LANE_A, tunnels: [], quota: 10, introBalls: 2, pushSpeed, powerUpRate: 0 }, { shape: 'line', points: LANE_B, tunnels: [], quota: 10, introBalls: 2, pushSpeed, powerUpRate: 0 }, ], }); // freeze both spawners at their quota so a hand-built chain fixture isn't // added to mid-test — the same spawned:quota convention the single-path // fixtures use, just applied to both paths. const freeze = (st) => { for (const p of st.paths) p.spawned = p.quota; }; // independent spawners: both paths reach their own quota on their own clock let st = createLevel(twoLanes(100)); for (let i = 0; i < 4000 && (st.paths[0].spawned < 10 || st.paths[1].spawned < 10); i++) step(st, 16); check('each path spawns to its own quota independently', st.paths[0].spawned === 10 && st.paths[1].spawned === 10 && st.paths[0].balls.length === 10 && st.paths[1].balls.length === 10); // shooter color pool unions across paths: path A all color 0, path B all // color 1 — current/next must be reassignable to whatever is on EITHER lane st = createLevel(twoLanes()); freeze(st); mkChain(st, [{ color: 0, s: 300 }, { color: 0, s: 300 - SP }], 0); mkChain(st, [{ color: 1, s: 300 }, { color: 1, s: 300 - SP }], 1); st.status = 'playing'; st.current = 2; st.next = 3; // neither color is present anywhere popRun(st, st.paths[0], 0, 0, 'shot', []); check('shooter recolors from the union of both paths\' present colors', (st.current === 0 || st.current === 1) && (st.next === 0 || st.next === 1)); // a shot lands on whichever path it is physically aimed at — no special // targeting, purely the nearest ball to the ray st = createLevel(twoLanes(0)); freeze(st); mkChain(st, [{ color: 0, s: 600 }], 0); mkChain(st, [{ color: 1, s: 600 }], 1); st.status = 'playing'; const ballB = st.paths[1].balls[0]; st.current = 1; fireBall(st, Math.atan2(ballB.y - st.frog.y, ballB.x - st.frog.x)); for (let i = 0; i < 200 && st.flights.length; i++) step(st, 16); check('a shot lands on the path it is aimed at, not always path 0', st.paths[1].balls.length === 2 && st.paths[0].balls.length === 1); // either path (not just path 0) reaching its own pit loses the whole level st = createLevel(twoLanes(100)); freeze(st); mkChain(st, [{ color: 1, s: st.paths[1].path.length - 20 }], 1); mkChain(st, [{ color: 0, s: 400 }], 0); st.status = 'playing'; 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('either path reaching the hole loses the whole level', st.status === 'lost' && lostEv); // winning requires EVERY path cleared, not just one st = createLevel(twoLanes()); freeze(st); st.status = 'playing'; mkChain(st, [{ color: 0, s: 400 }], 1); let won = step(st, 16).some((e) => e.type === 'won'); check('one path cleared but not the other does not win', !won && st.status === 'playing'); st.paths[1].balls = []; won = step(st, 16).some((e) => e.type === 'won'); check('every path cleared wins', won && st.status === 'won'); } // ── 11. Level bank lint ────────────────────────────────────────────────────── console.log('\n— Level bank —'); { const bankDir = join(__dirname, '../assets/gamedata/zuma'); let manifest = null; try { manifest = JSON.parse(readFileSync(join(bankDir, 'levels.json'), 'utf8')); } catch (_) { /* handled below */ } check('manifest exists (run genZuma.js)', !!manifest); let levels = []; let manifestMismatch = ''; if (manifest) { levels = (manifest.levels ?? []).map((entry) => { const def = JSON.parse(readFileSync(join(bankDir, entry.file), 'utf8')); if (!manifestMismatch && (entry.level !== def.level || entry.name !== def.name)) { manifestMismatch = `${entry.file}: manifest says L${entry.level} "${entry.name}", ` + `file says L${def.level} "${def.name}"`; } return def; }); } check('every manifest entry matches its level file', !manifestMismatch, manifestMismatch); if (manifest) { 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); const tunnelCount = (l) => l.paths.reduce((a, p) => a + (p.tunnels?.length ?? 0), 0); const tunnelled = levels.filter((l) => tunnelCount(l) > 0); console.log(` .. ${tunnelled.length} of ${levels.length} levels carry tunnels` + ` (${tunnelled.map((l) => `L${l.level}×${tunnelCount(l)}`).join(' ')})`); // ── 12. 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 totalQuota = (l) => l.paths.reduce((a, p) => a + p.quota, 0); 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 / totalQuota(levels[i]), 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.');