fertig-classic-games/tools/verifyZuma.js

627 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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. Level bank lint (data/zuma.json geometry + parameters).
// 11. 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;
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 (isHidden(st.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 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) {
if (isHidden(st.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 = 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. 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.balls[0]);
check('a shot flies over a submerged ball', runFlights(st) && st.balls.length === 1);
st = mkState({ pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
shootAt(st, st.balls[0]);
check('the same shot lands once the tunnel is gone', !runFlights(st) && st.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.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, 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.balls.length === 2,
`caught ${boom?.ids.length}`);
check('the submerged pair survives the blast', st.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.balls[0].s, 500, 1) && spacingOk(st.balls), `front at ${st.balls[0].s.toFixed(1)}`);
check('every ball inside is submerged', st.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. 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);
const tunnelled = levels.filter((l) => (l.tunnels?.length ?? 0) > 0);
console.log(` .. ${tunnelled.length} of ${levels.length} levels carry tunnels`
+ ` (${tunnelled.map((l) => `L${l.level}×${l.tunnels.length}`).join(' ')})`);
// ── 11. 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.');