fertig-classic-games/tools/verifyZuma.js

498 lines
25 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. Level bank lint (data/zuma.json geometry + parameters).
// 10. Aimbot soak: every banked level is winnable, and the star curve sits
// above what mechanical play achieves.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
TUNING, POWER_KINDS,
buildPath, createLevel, step, fireBall, swapBalls,
insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent,
validateLevel, validateLevelParams,
} from '../src/games/zuma/ZumaLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const T = TUNING;
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
// ── Fixtures ─────────────────────────────────────────────────────────────────
const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]];
const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]];
// Fixture chains are laid out in multiples of the tuned spacing, never in raw
// pixels — otherwise resizing the marbles silently turns "contiguous" fixtures
// into gapped ones and the insertion tests start asserting nonsense.
const SP = T.BALL_SPACING;
function mkDef(over = {}) {
return {
level: 1, name: 'Test', shape: 'line',
points: STRAIGHT, frog: [800, 900],
colors: 4, quota: 10, introBalls: 2,
pushSpeed: 100, powerUpRate: 0, seed: 42,
starScores: [100, 200, 300],
...over,
};
}
function mkState(defOver = {}, over = {}) {
const st = createLevel(mkDef(defOver));
Object.assign(st, over);
return st;
}
// ── Aimbot ───────────────────────────────────────────────────────────────────
// Deliberately mechanical: it never sets up a chain, never banks a shot off a
// gap, and never holds a colour. Whatever it clears, a player clears.
// Walks the shot ray like rayHit does, but reports which ball it lands on.
function firstHit(st, angle) {
const dx = Math.cos(angle), dy = Math.sin(angle);
const stepLen = T.BALL_RADIUS / 2;
const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H);
let x = st.frog.x + dx * T.FROG_MUZZLE;
let y = st.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
for (const b of st.balls) {
if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b;
}
x += dx * stepLen; y += dy * stepLen;
}
return null;
}
// Rank every reachable ball: landing between two of the held colour beats
// landing beside one, which beats landing on a bare match.
function chooseShot(st) {
let best = null;
for (const target of st.balls) {
const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x);
const hit = firstHit(st, angle);
if (!hit) continue;
const i = st.balls.indexOf(hit);
const prev = st.balls[i - 1], next = st.balls[i + 1];
let score = 0;
if (hit.color === st.current) {
score += 5;
if (prev?.color === st.current) score += 10;
if (next?.color === st.current) score += 10;
}
if (prev?.color === st.current && next?.color === st.current) score += 8;
if (score > 0 && (!best || score > best.score)) best = { angle, score };
}
return best;
}
function aimbotPlay(def, seed) {
const st = createLevel(def, seed ?? def.seed);
const DT = 25;
let sinceShot = 0;
for (let t = 0; t < 400000; t += DT) {
step(st, DT);
if (st.status === 'won' || st.status === 'lost') break;
if (st.status !== 'playing') continue;
sinceShot += DT;
if (sinceShot < 450) continue;
let shot = chooseShot(st);
if (!shot) { swapBalls(st); shot = chooseShot(st); } // try the on-deck colour
if (shot) { fireBall(st, shot.angle); sinceShot = 0; }
}
return { status: st.status, score: st.score };
}
// spec: [{ color, s, power? }] front-first (descending s)
function mkChain(st, spec) {
st.balls = spec.map((b) => ({
id: st.nextId++, color: b.color, power: b.power ?? null, s: b.s, x: 0, y: 0,
}));
for (const b of st.balls) {
const p = st.path.pointAt(b.s);
b.x = p.x; b.y = p.y;
}
return st;
}
function spacingOk(balls) {
for (let i = 0; i < balls.length - 1; i++) {
const d = balls[i].s - balls[i + 1].s;
if (d < T.BALL_SPACING - 0.01) return false; // overlap
if (d > T.BALL_SPACING + 0.01 && d <= T.BALL_SPACING + T.GAP_EPS) return false; // not snapped
}
return true;
}
// ── 1. Path ──────────────────────────────────────────────────────────────────
console.log('\n— Path —');
{
const p = buildPath(STRAIGHT);
check('straight path length ≈ 1600', near(p.length, 1600, 3), `got ${p.length.toFixed(1)}`);
const a = p.pointAt(0), b = p.pointAt(p.length);
check('pointAt(0) at first control point', near(a.x, 0, 1) && near(a.y, 500, 1));
check('pointAt(length) at last control point', near(b.x, 1600, 1) && near(b.y, 500, 1));
const c = buildPath(CURVY);
let maxErr = 0, maxTanErr = 0;
const stepS = 37;
for (let s = 0; s + stepS <= c.length; s += stepS) {
const u = c.pointAt(s), v = c.pointAt(s + stepS);
maxErr = Math.max(maxErr, Math.abs(Math.hypot(v.x - u.x, v.y - u.y) - stepS));
maxTanErr = Math.max(maxTanErr, Math.abs(Math.hypot(u.tx, u.ty) - 1));
}
check('curved path constant-speed (equal s → equal distance)', maxErr < stepS * 0.05, `max err ${maxErr.toFixed(2)}px`);
check('tangents unit length', maxTanErr < 1e-6);
let mono = true;
for (let i = 1; i < c.samples.length; i++) if (c.samples[i].s < c.samples[i - 1].s) mono = false;
check('sample arc lengths monotonic', mono);
}
// ── 2. Advance & spawning ────────────────────────────────────────────────────
console.log('\n— Advance & spawning —');
{
const st = mkState({ quota: 8, introBalls: 3 });
let introSpawned = -1;
for (let i = 0; i < 4000 && st.status === 'intro'; i++) {
step(st, 16);
if (st.status !== 'intro') introSpawned = st.spawned;
}
check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`);
for (let i = 0; i < 4000 && st.spawned < 8; i++) step(st, 16);
check('spawn stops at quota', st.spawned === 8 && st.balls.length === 8);
check('spacing invariant after spawning', spacingOk(st.balls));
const st2 = mkState({}, { status: 'playing', spawned: 10 });
mkChain(st2, [{ color: 0, s: 600 + 2 * SP }, { color: 1, s: 600 + SP }, { color: 2, s: 600 }]);
for (let i = 0; i < 20; i++) step(st2, 50); // 1s at pushSpeed 100
check('single segment drives at pushSpeed', near(st2.balls[0].s, 600 + 2 * SP + 100, 1), `got ${st2.balls[0].s.toFixed(1)}`);
check('spacing preserved while driving', spacingOk(st2.balls));
}
// ── 3. Insertion ─────────────────────────────────────────────────────────────
console.log('\n— Insertion —');
{
const base = () => mkChain(
mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 1, s: 600 - SP }, { color: 0, s: 600 - 2 * SP },
{ color: 1, s: 600 - 3 * SP }, { color: 2, s: 600 - 4 * SP }]
);
let st = base(); let ev = [];
insertBall(st, 3, 2, +1, ev);
check('front insert lands in front of hit ball',
st.balls[2].color === 3 && near(st.balls[2].s, 600 - SP, 0.01) && near(st.balls[3].s, 600 - 2 * SP, 0.01));
check('front insert shoves balls ahead', near(st.balls[0].s, 600 + SP, 0.01) && near(st.balls[1].s, 600, 0.01));
check('front insert keeps spacing', spacingOk(st.balls) && st.balls.length === 6);
check('non-matching insert resets combo, no pop', st.combo === 0 && !ev.some((e) => e.type === 'pop'));
st = base(); ev = [];
insertBall(st, 3, 2, -1, ev);
check('behind insert wedges after hit ball',
st.balls[3].color === 3 && near(st.balls[3].s, 600 - 2 * SP, 0.01) && near(st.balls[2].s, 600 - SP, 0.01));
check('behind insert keeps spacing', spacingOk(st.balls));
st = base(); ev = [];
insertBall(st, 3, 4, -1, ev);
check('tail attach adds at rear without shoving',
near(st.balls[5].s, 600 - 5 * SP, 0.01) && near(st.balls[0].s, 600, 0.01));
// shove closes a gap → clank, no pop (junction colors differ)
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 2, s: 900 - 2.5 * SP },
{ color: 3, s: 900 - 3.5 * SP }]);
ev = [];
insertBall(st, 3, 2, +1, ev);
check('shove-merge emits clank', ev.some((e) => e.type === 'clank'));
check('shove-merge joins segments', segmentsOf(st.balls).length === 1 && spacingOk(st.balls));
check('shove-merge without matching junction does not pop', !ev.some((e) => e.type === 'pop'));
// laser-sight ray helper
st = mkChain(mkState({}, { status: 'playing' }), [{ color: 0, s: 600 }]);
const ball = st.balls[0];
const ray = rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x));
check('rayHit finds first chain ball', ray.hit && Math.hypot(ray.x - ball.x, ray.y - ball.y) < T.BALL_SPACING);
}
// ── 4. Matching ──────────────────────────────────────────────────────────────
console.log('\n— Matching —');
{
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 0, s: 600 - SP }, { color: 1, s: 600 - 2 * SP }, { color: 1, s: 600 - 3 * SP }]);
let ev = [];
insertBall(st, 0, 1, +1, ev);
const pop = ev.find((e) => e.type === 'pop');
check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.balls.length === 2);
check('3-pop score = 3 × SCORE_BALL', pop?.score === 3 * T.SCORE_BALL && st.score === 3 * T.SCORE_BALL);
check('shot pop sets combo to 1', pop?.combo === 1);
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 + 2 * SP }, { color: 0, s: 600 + SP }, { color: 0, s: 600 }, { color: 0, s: 600 - SP }]);
ev = [];
insertBall(st, 0, 1, -1, ev);
const pop5 = ev.find((e) => e.type === 'pop');
check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.balls.length === 0);
check('5-pop score', pop5?.score === 5 * T.SCORE_BALL);
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 0, s: 600 - 2.5 * SP }, { color: 1, s: 600 - 3.5 * SP }]);
ev = [];
insertBall(st, 0, 1, +1, ev);
check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4);
const run = findRun(st.balls, 1);
check('findRun bounded by the gap', run.lo === 1 && run.hi === 2);
}
// ── 5. Pull-back & catch-up ──────────────────────────────────────────────────
console.log('\n— Pull-back & catch-up —');
{
// matching gap edges → front segment retreats, contact pops with chain bonus
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 1, s: 900 - 5 * SP }, { color: 0, s: 900 - 6 * SP }]);
let popEv = null, clankSeen = false, retreated = false;
for (let i = 0; i < 200 && !popEv; i++) {
const ev = step(st, 25);
if (st.balls.length && st.balls[0].s < 900 - 1) retreated = true;
if (ev.some((e) => e.type === 'clank')) clankSeen = true;
popEv = ev.find((e) => e.type === 'pop') ?? popEv;
}
check('matching gap pulls front segment backward', retreated);
check('pull-back contact clanks and pops', clankSeen && !!popEv && popEv.ids.length === 3);
check('chain pop scores chain bonus', popEv?.cause === 'chain'
&& popEv?.score === 3 * T.SCORE_BALL + T.SCORE_CHAIN_BONUS);
check('chain pop increments combo', popEv?.combo === 1);
check('survivor remains after chain pop', st.balls.length === 1 && st.balls[0].color === 0);
// non-matching gap → rear catches up, front stays put, clank without pop
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 5 * SP }, { color: 1, s: 900 - 6 * SP }]);
let clankAt = null;
for (let i = 0; i < 200 && !clankAt; i++) {
const frontBefore = st.balls[0].s;
const ev = step(st, 25);
if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.balls[2].s };
}
check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 900 - 2 * SP, 1.5),
clankAt ? `rear front at ${clankAt.rearFront.toFixed(1)}` : 'no clank');
check('non-matching gap: front segment stays put', !!clankAt && near(clankAt.frontBefore, 900, 0.01));
check('no pop on non-matching junction', st.balls.length === 4);
const before = st.balls[0].s;
for (let i = 0; i < 8; i++) step(st, 50);
check('merged chain resumes driving', st.balls[0].s > before + 30);
}
// ── 6. Power-ups ─────────────────────────────────────────────────────────────
console.log('\n— Power-ups —');
{
// slow: popped slow ball sets the timer; drive rate drops to SLOW_MULT
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 800 - 3 * SP }]);
let ev = [];
popRun(st, 0, 0, 'shot', ev);
check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS
&& ev.some((e) => e.type === 'powerup' && e.kind === 'slow'));
const s0 = st.balls[0].s;
for (let i = 0; i < 20; i++) step(st, 50);
check('slow halves the drive (SLOW_MULT)', near(st.balls[0].s - s0, 100 * T.SLOW_MULT, 1.5),
`moved ${(st.balls[0].s - s0).toFixed(1)}`);
// reverse: chain rolls backward, then resumes forward when expired
// (elapsedMs increments before the effect check, so 501 covers exactly 10 ticks)
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [{ color: 0, s: 800 }]);
st.effects.reverseUntil = st.elapsedMs + 501;
for (let i = 0; i < 10; i++) step(st, 50);
check('reverse rolls the chain backward', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2),
`at ${st.balls[0].s.toFixed(1)}`);
for (let i = 0; i < 10; i++) step(st, 50);
check('drive resumes after reverse expires', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2),
`at ${st.balls[0].s.toFixed(1)}`);
// accuracy: flag set on pop; fired flights move faster
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 800 - 3 * SP }]);
ev = [];
popRun(st, 0, 0, 'shot', ev);
check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS);
const flight = fireBall(st, -Math.PI / 2);
check('accuracy speeds up shots', !!flight && near(flight.speed, T.SHOT_SPEED * T.ACCURACY_SHOT_MULT, 0.01));
// explosion: blast radius around the popped ball, nothing beyond
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [
{ color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 2 * SP, power: 'explosion' },
{ color: 2, s: 900 - 3 * SP }, { color: 2, s: 900 - 4 * SP },
{ color: 3, s: 900 - 5 * SP }, { color: 3, s: 900 - 6 * SP },
]);
ev = [];
popRun(st, 2, 2, 'shot', ev);
const boom = ev.find((e) => e.type === 'explosion');
check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.balls.length === 2);
check('explosion spares balls beyond radius', st.balls.every((b) => b.color === 3));
}
// ── 7. State machine ─────────────────────────────────────────────────────────
console.log('\n— State machine —');
{
let st = mkState({}, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: st.path.length - 20 }]);
let lostEv = false;
for (let i = 0; i < 10 && st.status === 'playing'; i++) {
if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true;
}
check('ball reaching the hole loses', st.status === 'lost' && lostEv);
check('terminal state ignores further steps', step(st, 50).length === 0);
st = mkState({}, { status: 'playing', spawned: 10, balls: [], flights: [] });
const ev = step(st, 16);
const won = ev.find((e) => e.type === 'won');
const expectBonus = Math.max(0, Math.ceil((10 * T.TIME_PAR_MS_PER_BALL - st.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
check('cleared board after quota wins', st.status === 'won' && !!won);
check('win time bonus math', won?.timeBonus === expectBonus && st.score === expectBonus,
`bonus ${won?.timeBonus} expected ${expectBonus}`);
st = mkState(); // status 'intro'
check('firing rejected during intro', fireBall(st, 0) === null);
st.status = 'won';
check('firing rejected after game over', fireBall(st, 0) === null);
const c0 = st.current, n0 = st.next;
swapBalls(st);
check('swap rejected after game over', st.current === c0 && st.next === n0);
st.status = 'playing';
swapBalls(st);
check('swap exchanges current and next', st.current === n0 && st.next === c0);
// recolor: shooter colors must exist on the board after a pop
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 2, s: 504 }, { color: 2, s: 456 }]);
st.current = 0; st.next = 0;
const ev2 = [];
popRun(st, 0, 1, 'shot', ev2);
check('shooter recolors to colors still present', st.current === 2 && st.next === 2
&& ev2.filter((e) => e.type === 'recolor').length === 2);
// last-call: final spawns only deal colors still on the board
st = mkChain(mkState({}, { status: 'playing', spawned: 5 }),
[{ color: 3, s: 2 * SP }, { color: 3, s: SP }]);
for (let i = 0; i < 400 && st.spawned < 10; i++) step(st, 50);
check('last-call spawns restrict to present colors',
st.spawned === 10 && st.balls.every((b) => b.color === 3));
}
// ── 8. Determinism ───────────────────────────────────────────────────────────
console.log('\n— Determinism —');
{
const run = () => {
const st = createLevel(mkDef({ quota: 20, powerUpRate: 0.1, seed: 777 }));
const log = [];
for (let tick = 0; tick < 600 && st.status !== 'lost' && st.status !== 'won'; tick++) {
if (tick === 80) fireBall(st, -Math.PI / 2 + 0.3);
if (tick === 160) swapBalls(st);
if (tick === 200) fireBall(st, -Math.PI / 2 - 0.2);
if (tick === 300) fireBall(st, -Math.PI / 2);
for (const e of step(st, 25)) log.push(e.type + (e.ids ? `:${e.ids.length}` : ''));
}
return { log: log.join(','), score: st.score, status: st.status, balls: st.balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|') };
};
const a = run(), b = run();
check('identical seed + script → identical events', a.log === b.log);
check('identical final score and chain', a.score === b.score && a.balls === b.balls && a.status === b.status);
check('scripted run produced activity', a.log.includes('pop') || a.log.includes('inserted'));
}
// ── 9. Level bank lint ───────────────────────────────────────────────────────
console.log('\n— Level bank —');
{
let bank = null;
try {
bank = JSON.parse(readFileSync(join(__dirname, '../data/zuma.json'), 'utf8'));
} catch (_) { /* handled below */ }
check('bank exists (run genZuma.js)', !!bank);
if (bank) {
const levels = bank.levels ?? [];
check('bank has 20 levels', levels.length === 20);
check('levels numbered 1..N contiguously', levels.every((l, i) => l.level === i + 1));
// Geometry and parameters both come from ZumaLogic — the same lint
// genZuma.js gates on and the editor shows live, so the three can't drift.
let geomOk = true, paramOk = true, geomDetail = '', paramDetail = '';
for (const l of levels) {
const perr = validateLevelParams(l);
if (perr.length) { paramOk = false; paramDetail = `level ${l.level}: ${perr[0]}`; }
const gerr = validateLevel(l).errs;
if (gerr.length) { geomOk = false; geomDetail = `level ${l.level}: ${gerr[0]}`; }
}
check('level parameters in range', paramOk, paramDetail);
check(`geometry lint (length, bounds, curvature ≥ ${(T.BALL_RADIUS * 1.7).toFixed(0)}px, frog clear ≥ ${T.FROG_CLEARANCE}px)`,
geomOk, geomDetail);
// ── 10. Winnability soak ─────────────────────────────────────────────────
// If a bot that only ever takes the best immediately available shot can
// clear a level, a player can. This is what catches a quota the path can
// technically hold but nobody could actually survive.
//
// Over SEEDS seeds, not one: a single seed makes this check hostage to
// chain luck, so any unrelated tuning nudge (moving the muzzle by 26px,
// say) flips levels between pass and fail for no real reason.
console.log('\n— Aimbot soak —');
const SEEDS = 8;
const FLOOR = 5; // per-level clears required out of SEEDS
const rows = levels.map((l) => {
const runs = [];
for (let k = 0; k < SEEDS; k++) runs.push(aimbotPlay(l, l.seed + k * 7919));
return {
level: l.level,
wins: runs.filter((r) => r.status === 'won').length,
best: Math.max(...runs.map((r) => r.score)),
mean: runs.reduce((a, r) => a + r.score, 0) / SEEDS,
};
});
const weak = rows.filter((r) => r.wins < FLOOR);
check(`aimbot clears every level at least ${FLOOR}/${SEEDS} times`, weak.length === 0,
weak.map((r) => `L${r.level} ${r.wins}/${SEEDS}`).join(', '));
const totalWins = rows.reduce((a, r) => a + r.wins, 0);
const rate = totalWins / (rows.length * SEEDS);
console.log(` .. aimbot clear rate: ${(rate * 100).toFixed(0)}% (worst level ${Math.min(...rows.map((r) => r.wins))}/${SEEDS})`);
check('bank-wide clear rate ≥ 80%', rate >= 0.8, `${(rate * 100).toFixed(0)}%`);
const perMarble = rows.reduce((a, r, i) => a + r.mean / levels[i].quota, 0) / rows.length;
console.log(` .. mean score per quota marble: ${perMarble.toFixed(1)}`);
// Medals are judged on the bot's MEAN run, not its best: best-of-N is a
// measure of chain luck, and the question here is where the star curve
// sits relative to ordinary mechanical play.
const threeStar = rows.filter((r, i) => r.mean >= levels[i].starScores[2]).length;
const twoStar = rows.filter((r, i) => r.mean >= levels[i].starScores[1]).length;
const ceiling = rows.filter((r, i) => r.best >= levels[i].starScores[2]).length;
console.log(` .. aimbot medals (mean run): ★★★ on ${threeStar}, ★★+ on ${twoStar} of ${levels.length}`
+ ` — ★★★ within reach on a lucky run for ${ceiling}`);
check('three stars stays a stretch for the aimbot', threeStar < levels.length / 2, `${threeStar}/${levels.length}`);
check('three stars is not out of reach either', ceiling >= 3, `only ${ceiling}/${levels.length} even on a best run`);
check('two stars is within the aimbot\'s reach', twoStar >= levels.length / 2, `${twoStar}/${levels.length}`);
}
}
// ── Result ───────────────────────────────────────────────────────────────────
console.log('');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('All Zuma checks passed.');