fertig-classic-games/server/scripts/verifyZuma.js

410 lines
20 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 server/scripts/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 (public/data/zuma.json geometry + parameters).
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,
} from '../../public/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]];
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;
}
// 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: 696 }, { color: 1, s: 648 }, { 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, 796, 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: 552 }, { color: 0, s: 504 }, { color: 1, s: 456 }, { color: 2, s: 408 }]
);
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, 552, 0.01) && near(st.balls[3].s, 504, 0.01));
check('front insert shoves balls ahead', near(st.balls[0].s, 648, 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, 504, 0.01) && near(st.balls[2].s, 552, 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, 360, 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: 852 }, { color: 2, s: 780 }, { color: 3, s: 732 }]);
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: 552 }, { color: 1, s: 504 }, { color: 1, s: 456 }]);
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: 696 }, { color: 0, s: 648 }, { color: 0, s: 600 }, { color: 0, s: 552 }]);
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: 450 }, { color: 1, s: 402 }]);
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: 852 }, { color: 1, s: 600 }, { color: 0, s: 552 }]);
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: 852 }, { color: 0, s: 600 }, { color: 1, s: 552 }]);
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, 804, 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: 656 }]);
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: 656 }]);
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: 852 }, { color: 0, s: 804, power: 'explosion' },
{ color: 2, s: 756 }, { color: 2, s: 708 }, { color: 3, s: 660 }, { color: 3, s: 612 },
]);
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: 96 }, { color: 3, s: 48 }]);
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, '../../public/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));
let geomOk = true, paramOk = true, clearOk = true, curveOk = true, detail = '';
for (const l of levels) {
if (!(l.colors >= 4 && l.colors <= 6 && l.quota >= 20 && l.introBalls < l.quota
&& l.pushSpeed >= 10 && l.pushSpeed <= 80
&& l.powerUpRate >= 0 && l.powerUpRate <= 0.2
&& Array.isArray(l.starScores) && l.starScores.length === 3
&& l.starScores[0] < l.starScores[1] && l.starScores[1] < l.starScores[2])) {
paramOk = false; detail = `level ${l.level} params`;
}
const path = buildPath(l.points);
if (path.length < l.quota * T.BALL_SPACING * 1.6) {
geomOk = false; detail = `level ${l.level} too short (${path.length.toFixed(0)} for quota ${l.quota})`;
}
let minFrog = Infinity, minRadius = Infinity;
for (let i = 0; i < path.samples.length; i++) {
const p = path.samples[i];
minFrog = Math.min(minFrog, Math.hypot(p.x - l.frog[0], p.y - l.frog[1]));
if (p.s > 200 && (p.x < 40 || p.x > 1880 || p.y < 40 || p.y > 1040)) {
geomOk = false; detail = `level ${l.level} sample out of bounds at s=${p.s.toFixed(0)}`;
}
if (i > 0 && i < path.samples.length - 1 && p.s > 200) {
const a = path.samples[i - 1], c = path.samples[i + 1];
const v1x = p.x - a.x, v1y = p.y - a.y, v2x = c.x - p.x, v2y = c.y - p.y;
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
if (l1 > 0.01 && l2 > 0.01) {
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
const theta = Math.acos(cos);
if (theta > 1e-4) minRadius = Math.min(minRadius, l1 / theta);
}
}
}
if (minFrog < 140) { clearOk = false; detail = `level ${l.level} frog ${minFrog.toFixed(0)}px from path`; }
if (minRadius < T.BALL_RADIUS * 1.7) { curveOk = false; detail = `level ${l.level} min radius ${minRadius.toFixed(0)}px`; }
}
check('level parameters in range', paramOk, detail);
check('paths long enough and in bounds', geomOk, detail);
check('frog clear of every path sample (≥140px)', clearOk, detail);
check('curvature radius ≥ 1.7 × ball radius', curveOk, detail);
}
}
// ── Result ───────────────────────────────────────────────────────────────────
console.log('');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('All Zuma checks passed.');