fertig-classic-games/tools/verifyZuma.js

754 lines
38 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. 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.');