627 lines
32 KiB
JavaScript
627 lines
32 KiB
JavaScript
// 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.');
|