// Headless verification for Peggle. // node tools/verifyPeggle.js // Exits non-zero on any failure. // // 1. Physics invariants (bounce restitution, wall reflection, anti-tunnel bound, // bucket catch direction, stuck nudge). // 2. Rules (peg color assignment, multiplier ladder, free-ball thresholds, // long shot, fever + bonus buckets, win/lose, purple re-roll). // 3. Powers (superguide, multiball, spaceblast, fireball, zenball). // 4. Determinism (seeded replay, frame-rate independence of the substep). // 5. Data lint (levels.json manifest ↔ level files ↔ opponents.json ↔ POWERS). // // Rendering, input, camera fever zoom, portrait overlays, and the editor's // Blob export are browser-only and must be smoke-tested manually. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { TUNING, SCORING, POWERS, mulberry32, multiplierFor, createRound, cloneState, clampAim, aimToVelocity, bucketX, bucketWidth, tessellateCurves, launchBall, stepSim, resolveFever, simulatePreview, scoreShot, zenBallOptimize, applyPower, } from '../src/games/peggle/PeggleLogic.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle'); const T = TUNING; let failures = 0; let passes = 0; function check(name, cond, detail = '') { if (cond) { passes += 1; console.log(` ok ${name}`); } else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } } const near = (a, b, tol) => Math.abs(a - b) <= tol; // ── Fixtures ───────────────────────────────────────────────────────────────── function mkLevel(pegs, over = {}) { return { board: { width: T.BOARD_W, height: T.BOARD_H }, orangeCount: 0, greenCount: 0, powerId: over.powerId ?? null, pegs, ...over, }; } function mkState(pegs, over = {}, stateOver = {}) { const st = createRound(mkLevel(pegs, over), { seed: over.seed ?? 7 }); Object.assign(st, stateOver); return st; } // Put a single ball in flight manually (bypasses launch bookkeeping). function inject(st, ball) { st.phase = 'flight'; st.balls.push({ stuckFor: 0, fireball: false, ...ball }); } function run(st, seconds, dt = 1 / 60) { const log = []; for (let t = 0; t < seconds && (st.phase === 'flight' || st.phase === 'fever'); t += dt) { log.push(...stepSim(st, dt)); } return log; } // ── 1. Physics ─────────────────────────────────────────────────────────────── console.log('― physics'); check('anti-tunnel: MAX_SPEED × SUBSTEP_DT < BALL_R', T.MAX_SPEED * T.SUBSTEP_DT < T.BALL_R, `${T.MAX_SPEED * T.SUBSTEP_DT} !< ${T.BALL_R}`); { // Ball dropped straight onto a lone peg bounces back upward. const st = mkState([{ x: 600, y: 500 }]); inject(st, { x: 600, y: 430, vx: 0, vy: 200 }); let hit = null; let vyAfter = null; let vyBefore = null; for (let i = 0; i < 600 && !hit; i++) { vyBefore = st.balls[0]?.vy; const evs = stepSim(st, 1 / 240); hit = evs.find((e) => e.type === 'pegHit') ?? null; if (hit) vyAfter = st.balls[0].vy; } check('peg bounce: hit registered', !!hit); check('peg bounce: ball reflects upward', vyAfter != null && vyAfter < 0, `vy after ${vyAfter}`); check('peg bounce: restitution ratio ≈ PEG_RESTITUTION', vyAfter != null && near(Math.abs(vyAfter / vyBefore), T.PEG_RESTITUTION, 0.06), `|${vyAfter}/${vyBefore}| = ${Math.abs(vyAfter / vyBefore)}`); check('peg lit but still present until shot ends', st.pegs[0].lit && !st.pegs[0].removed); } { // A lit peg clears out (and stops colliding) PEG_FADE_S after the hit. const st = mkState([{ x: 600, y: 500 }]); inject(st, { x: 600, y: 430, vx: 0, vy: 200 }); const log = run(st, T.PEG_FADE_S + 2); const faded = log.find((e) => e.type === 'pegsCleared' && e.faded); check('lit peg fades mid-flight after PEG_FADE_S', !!faded && faded.pegIds.includes(0)); check('faded peg no longer collides', st.pegs[0].removed); } { // Wall reflection preserves |vx|·restitution; ball never escapes the walls. const st = mkState([]); inject(st, { x: 100, y: 300, vx: -900, vy: 0 }); let wallHit = false; let vxAfter = null; for (let i = 0; i < 200 && !wallHit; i++) { const evs = stepSim(st, 1 / 240); if (evs.some((e) => e.type === 'wallHit')) { wallHit = true; vxAfter = st.balls[0].vx; } } check('wall bounce: reflects rightward', wallHit && vxAfter > 0, `vx ${vxAfter}`); check('wall bounce: restitution ratio ≈ WALL_RESTITUTION', vxAfter != null && near(vxAfter / 900, T.WALL_RESTITUTION, 0.05), `${vxAfter / 900}`); } { // Ball inside the bucket opening while descending → catch + free ball. const st = mkState([]); st.time = 0; const bx = bucketX(st); const ballsBefore = st.ballsLeft; inject(st, { x: bx, y: T.BUCKET_Y - 30, vx: 0, vy: 300 }); const log = run(st, 1); check('bucket catch while descending', log.some((e) => e.type === 'bucketCatch')); check('bucket catch grants a free ball', st.ballsLeft === ballsBefore + 1); // Same spot moving upward → no catch. const st2 = mkState([]); st2.time = 0; inject(st2, { x: bucketX(st2), y: T.BUCKET_Y - 30, vx: 0, vy: -1200 }); let caught = false; for (let i = 0; i < 30; i++) { if (stepSim(st2, 1 / 240).some((e) => e.type === 'bucketCatch')) caught = true; } check('no bucket catch while ascending', !caught); } { // Bucket kinematics: pure function of the clock, periodic, within travel. const st = mkState([]); st.time = 1.234; const a = bucketX(st); st.time = 1.234 + T.BUCKET_PERIOD; const b = bucketX(st); check('bucket periodic', near(a, b, 0.001)); let ok = true; for (let t = 0; t < T.BUCKET_PERIOD; t += 0.05) { st.time = t; const x = bucketX(st); if (x < T.BUCKET_MARGIN || x > st.board.width - T.BUCKET_MARGIN) ok = false; } check('bucket stays within travel bounds', ok); } { // A ball resting on a peg gets nudged free. const st = mkState([{ x: 600, y: 500 }]); inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 }); const log = run(st, 6); check('stuck ball receives a nudge', log.some((e) => e.type === 'nudge')); } { // Failsafe: flight never exceeds MAX_FLIGHT_S. const st = mkState([{ x: 600, y: 500 }]); inject(st, { x: 600, y: 500 - T.PEG_R - T.BALL_R + 1, vx: 0, vy: 0 }); const log = run(st, T.MAX_FLIGHT_S + 10); check('shot always resolves (failsafe or exit)', st.balls.length === 0 && st.phase !== 'flight' || st.phase === 'aim', `phase ${st.phase}, balls ${st.balls.length}`); } // ── 2. Rules ───────────────────────────────────────────────────────────────── console.log('― rules'); { // Color assignment on a real generated level. const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8')); const st = createRound(lvl, { seed: 123 }); const count = (c) => st.pegs.filter((p) => p.color === c).length; check('createRound assigns exactly orangeCount orange', count('orange') === lvl.orangeCount, `${count('orange')}`); check('createRound assigns exactly greenCount green', count('green') === lvl.greenCount, `${count('green')}`); check('createRound assigns exactly one purple', count('purple') === 1); // Peg ids run over circles then tessellated bricks, in order. const defs = [...lvl.pegs, ...tessellateCurves(lvl.curves ?? [])]; check('orange pegs only on eligible slots', st.pegs.every((p) => p.color !== 'orange' || defs[p.id].orangeEligible)); check('purple/green never on the same peg', st.pegs[st.purpleId].color === 'purple'); check('10 balls to start', st.ballsLeft === T.BALLS_PER_LEVEL); } { // Orange draw is weighted: circle pegs are twice as likely as brick sections // (TUNING.ORANGE_BRICK_WEIGHT). 20 circles + 20 bricks, all eligible. const defs = []; for (let i = 0; i < 20; i++) defs.push({ x: 100 + i * 50, y: 300, orangeEligible: true }); for (let i = 0; i < 20; i++) defs.push({ x: 100 + i * 50, y: 500, shape: 'brick', angle: 0, orangeEligible: true }); let circleOr = 0; let brickOr = 0; const SEEDS = 500; for (let s = 0; s < SEEDS; s++) { const st = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: s * 7919 + 1 }); for (const p of st.pegs) { if (p.color !== 'orange') continue; if (p.shape === 'brick') brickOr++; else circleOr++; } } // Sampling without replacement depletes circles as a shot's worth is drawn, // so the realized per-peg ratio sits a touch under the 2.0 weight ratio. const ratio = circleOr / brickOr; check('orange favors circles ~2:1 over bricks', ratio > 1.6 && ratio < 2.1, ratio.toFixed(2)); const a = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: 42 }); const b = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: 42 }); check('weighted orange draw is seed-deterministic', a.pegs.every((p, i) => p.color === b.pegs[i].color)); check('weighted draw still fills orangeCount exactly', a.pegs.filter((p) => p.color === 'orange').length === 10); } check('multiplier ladder ×1 →', multiplierFor(0) === 1 && multiplierFor(9) === 1); check('multiplier ladder ×2 @10', multiplierFor(10) === 2 && multiplierFor(14) === 2); check('multiplier ladder ×3 @15', multiplierFor(15) === 3 && multiplierFor(19) === 3); check('multiplier ladder ×5 @20', multiplierFor(20) === 5 && multiplierFor(21) === 5); check('multiplier ladder ×10 @22', multiplierFor(22) === 10 && multiplierFor(25) === 10); { // Peg values scale with the multiplier in effect before the hit. const st = mkState([{ x: 600, y: 500, orangeEligible: true }], { orangeCount: 1 }, { orangeCleared: 0 }); st.orangeTotal = 5; // avoid fever for this test st.orangeCleared = 22; inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const hit = log.find((e) => e.type === 'pegHit'); check('orange peg at ×10 scores 1000', hit && hit.points === SCORING.PEG_BASE.orange * 10, JSON.stringify(hit)); } { // Free-ball thresholds fire exactly once each. const st = mkState([{ x: 600, y: 500 }], {}, { score: 24995 }); inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const fb = log.filter((e) => e.type === 'freeBall' && e.reason === 'threshold'); check('crossing 25k grants one free ball', fb.length === 1 && fb[0].threshold === 25000, JSON.stringify(fb)); check('threshold not re-awarded', st.freeBallsGiven === 1); const st2 = mkState([{ x: 600, y: 500 }], {}, { score: 130000, freeBallsGiven: 0 }); inject(st2, { x: 600, y: 450, vx: 0, vy: 200 }); const log2 = run(st2, 2); const fb2 = log2.filter((e) => e.type === 'freeBall' && e.reason === 'threshold'); check('multiple owed thresholds granted together', fb2.length === 3 && st2.freeBallsGiven === 3); } { // Long Shot: orange hit ≥ LONG_SHOT_DIST from previous peg contact with a // wall bounce in between. const st = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 }); st.orangeTotal = 5; inject(st, { x: 900, y: 450, vx: 0, vy: 200 }); st.lastPegHit = { x: 200, y: 300 }; st.wallBouncesSincePeg = 1; const log = run(st, 2); check('long shot bonus awarded', log.some((e) => e.type === 'longShot')); const st2 = mkState([{ x: 900, y: 500, orangeEligible: true }], { orangeCount: 1 }); st2.orangeTotal = 5; inject(st2, { x: 900, y: 450, vx: 0, vy: 200 }); st2.lastPegHit = { x: 200, y: 300 }; st2.wallBouncesSincePeg = 0; const log2 = run(st2, 2); check('no long shot without a wall bounce', !log2.some((e) => e.type === 'longShot')); } { // Fever: lighting the last orange starts fever; bottom exit resolves it into // a bonus bucket; every leftover peg is swept. const st = mkState([ { x: 600, y: 500, orangeEligible: true }, { x: 200, y: 700 }, { x: 1000, y: 700 }, ], { orangeCount: 1 }); inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 10); check('fever starts on last orange', log.some((e) => e.type === 'feverStart')); const res = log.find((e) => e.type === 'feverResolve'); check('fever resolves with a bucket', !!res && SCORING.FEVER_BUCKETS.includes(res.bucketPoints)); check('fever sweeps leftover pegs', !!res && res.pegIds.length === 2, res && JSON.stringify(res.pegIds)); check('fever ends in a win', st.phase === 'won' && log.some((e) => e.type === 'win')); } { // Fever bucket mapping across the bottom fifths. const n = SCORING.FEVER_BUCKETS.length; let ok = true; for (let i = 0; i < n; i++) { const st = mkState([]); st.phase = 'fever'; const x = ((i + 0.5) / n) * st.board.width; const evs = resolveFever(st, x); const r = evs.find((e) => e.type === 'feverResolve'); if (!r || r.bucketIndex !== i || r.bucketPoints !== SCORING.FEVER_BUCKETS[i]) ok = false; } check('fever bucket x → index mapping', ok); } { // Lose: last ball drained with orange remaining. const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 }); st.ballsLeft = 1; const evs = launchBall(st, 0); // straight down the middle, missing the peg const log = run(st, 10); check('losing shot ends the round', st.phase === 'lost' && log.some((e) => e.type === 'lose'), `phase ${st.phase}`); } { // Purple re-rolls between shots; lit pegs removed at shot end. An orange peg // far off the drop path keeps the round alive (orangeTotal 0 means instant win). const st = mkState( [{ x: 600, y: 500 }, { x: 300, y: 400 }, { x: 900, y: 400 }, { x: 450, y: 640 }, { x: 100, y: 780, orangeEligible: true }], { orangeCount: 1 }, { seed: 5 }); inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 10); const clearedEv = log.find((e) => e.type === 'pegsCleared'); check('lit pegs removed at shot end', !!clearedEv && clearedEv.pegIds.includes(0) && st.pegs[0].removed); check('back to aim with a purple somewhere', st.phase === 'aim' && st.purpleId != null && st.pegs[st.purpleId].color === 'purple'); } // ── 3. Powers ──────────────────────────────────────────────────────────────── console.log('― powers'); { const st = mkState([]); applyPower(st, 'superguide'); check('superguide charges 3 shots', st.superGuideShots === 3); launchBall(st, 0); check('superguide decrements per launch', st.superGuideShots === 2); } { const st = mkState([]); inject(st, { x: 500, y: 400, vx: 320, vy: -100 }); const evs = applyPower(st, 'multiball', { ball: st.balls[0] }); check('multiball spawns three more balls', st.balls.length === 4 && evs.some((e) => e.type === 'multiball')); check('multiball mirrors vx', st.balls[1].vx === -320 && st.balls[1].vy === -100); check('multiball clones preserve speed', [2, 3].every((i) => { const b = st.balls[i]; return Math.abs(Math.hypot(b.vx, b.vy) - Math.hypot(320, 100)) < 1e-9; })); const mbEvent = evs.find((e) => e.type === 'multiball'); check('multiball event carries source position', mbEvent?.x === 500 && mbEvent?.y === 400); } { const pegs = [ { x: 600, y: 500 }, // green (via forced color below) { x: 600, y: 560 }, // within 150 { x: 700, y: 450 }, // within 150 { x: 1100, y: 200 }, // far away ]; const st = mkState(pegs, { powerId: 'spaceblast' }); st.pegs[0].color = 'green'; inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const blast = log.find((e) => e.type === 'spaceBlast'); check('green peg triggers space blast', !!blast); check('space blast lights only nearby pegs', !!blast && blast.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, blast && JSON.stringify(blast.pegIds)); check('blast pegs are scored', log.filter((e) => e.type === 'pegHit' && e.fromBlast).length === 2); } { // Fireball burns through a column; a normal ball reflects off the top peg. const pegs = [{ x: 600, y: 400 }, { x: 600, y: 480 }, { x: 600, y: 560 }]; const stNorm = mkState(pegs); launchBall(stNorm, 0); run(stNorm, 12); const normHits = stNorm.pegs.filter((p) => p.removed).length; const stFire = mkState(pegs); applyPower(stFire, 'fireball'); check('fireball charges next ball', stFire.fireballNext === 1); launchBall(stFire, 0); check('fireball flag applied on launch', stFire.balls[0].fireball === true && stFire.fireballNext === 0); run(stFire, 12); check('fireball lights the whole column', stFire.pegs.every((p) => p.removed), `${stFire.pegs.filter((p) => p.removed).length}/3 vs normal ${normHits}`); } { // Zen ball: substituted angle never scores worse than the raw aim. const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-001.json'), 'utf8')); const st = createRound(lvl, { seed: 99 }); const rawAngle = 0.35; const rawScore = scoreShot(st, rawAngle); const best = zenBallOptimize(st, rawAngle, { samples: 9 }); check('zen ball never scores worse', best.score >= rawScore, `${best.score} < ${rawScore}`); applyPower(st, 'zenball'); check('zen ball grants 3 stacking charges', st.zenNext === 3); const evs = launchBall(st, rawAngle); check('zen launch consumes one charge and fires', st.zenNext === 2 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); check('zen launch sets cosmetic ball.zen flag', st.balls[0]?.zen === true); // Run each shot to resolution (flights may last up to MAX_FLIGHT_S). run(st, T.MAX_FLIGHT_S + 1); launchBall(st, rawAngle); run(st, T.MAX_FLIGHT_S + 1); launchBall(st, rawAngle); check('zen charges deplete after 3 shots', st.zenNext === 0); } // ── 3a. Brick curves ───────────────────────────────────────────────────────── console.log('― brick curves'); { // Straight segment: evenly spaced bricks along the chord, angle ≈ 0. const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 560, y: 400 }], bends: [0], orangeEligible: true }]); check('straight curve brick count', bricks.length === Math.floor(360 / T.BRICK_LEN), `${bricks.length}`); check('straight curve bricks level and flat', bricks.every((b) => near(b.y, 400, 0.5) && near(b.angle, 0, 0.01))); const gaps = bricks.slice(1).map((b, i) => b.x - bricks[i].x); check('straight curve spacing = BRICK_LEN', gaps.every((g) => near(g, T.BRICK_LEN, 0.5)), gaps.join(',')); check('curve bricks inherit eligibility', bricks.every((b) => b.orangeEligible)); } { // Bent segment: the middle brick bows toward the bend side (+perp is +y // for a left→right chord), ends stay near the anchors. const bricks = tessellateCurves([{ anchors: [{ x: 200, y: 400 }, { x: 800, y: 400 }], bends: [100], orangeEligible: false }]); const mid = bricks[Math.floor(bricks.length / 2)]; check('bent curve bows at the middle', mid.y > 435 && mid.y < 465, `${mid.y}`); check('bent curve stays anchored at the ends', bricks[0].y < 420 && bricks[bricks.length - 1].y < 420); check('bent curve bricks rotate along the tangent', bricks[0].angle > 0.05 && bricks[bricks.length - 1].angle < -0.05, `${bricks[0].angle} .. ${bricks[bricks.length - 1].angle}`); } { // Ball bounces off a horizontal brick face like a peg (lights + restitution). const lvl = mkLevel([], { curves: [{ anchors: [{ x: 450, y: 500 }, { x: 750, y: 500 }], bends: [0], orangeEligible: true }], }); const st = createRound(lvl, { seed: 7 }); check('bricks join the peg list', st.pegs.length >= 8 && st.pegs.every((p) => p.shape === 'brick')); st.orangeTotal = 99; // keep the round alive inject(st, { x: 600, y: 430, vx: 0, vy: 200 }); let vyBefore = null; let hit = null; for (let i = 0; i < 600 && !hit; i++) { vyBefore = st.balls[0]?.vy; hit = stepSim(st, 1 / 240).find((e) => e.type === 'pegHit') ?? null; } check('brick hit registered and lit', !!hit && st.pegs[hit.pegId].lit); check('brick bounce reflects upward with peg restitution', st.balls[0].vy < 0 && near(Math.abs(st.balls[0].vy / vyBefore), T.PEG_RESTITUTION, 0.06), `${Math.abs(st.balls[0]?.vy / vyBefore)}`); } { // A 45° brick wall deflects a vertical drop sideways. const lvl = mkLevel([], { curves: [{ anchors: [{ x: 480, y: 380 }, { x: 720, y: 620 }], bends: [0], orangeEligible: false }], }); const st = createRound(lvl, { seed: 7 }); st.orangeTotal = 99; inject(st, { x: 600, y: 420, vx: 0, vy: 300 }); let hit = false; for (let i = 0; i < 600 && !hit; i++) { hit = stepSim(st, 1 / 240).some((e) => e.type === 'pegHit'); } check('45° brick deflects the ball sideways', hit && Math.abs(st.balls[0].vx) > 60, `vx ${st.balls[0]?.vx}`); } { // Round integration: brick curves join orange assignment and the fever sweep. const lvl = mkLevel([], { orangeCount: 5, curves: [{ anchors: [{ x: 300, y: 500 }, { x: 900, y: 500 }], bends: [-60], orangeEligible: true }], }); const st = createRound(lvl, { seed: 11 }); const orange = st.pegs.filter((p) => p.color === 'orange').length; check('bricks receive orange assignment', orange === 5, `${orange}`); check('bricks host the purple bonus', st.purpleId != null && st.pegs[st.purpleId].color === 'purple'); // Determinism replay over a level with curves. const logOf = () => { const s = createRound(lvl, { seed: 999 }); const log = [...launchBall(s, 0.3)]; for (let t = 0; t < 25 && s.phase === 'flight'; t += 1 / 60) log.push(...stepSim(s, 1 / 60)); return log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|'); }; check('curved levels replay deterministically', logOf() === logOf()); } // ── 3b. New powers (for the next wave of friends) ──────────────────────────── console.log('― powers (new wave)'); { // Body Slam: big heavy ball that keeps its speed off pegs. const st = mkState([{ x: 600, y: 500 }]); applyPower(st, 'bodyslam'); check('bodyslam charges', st.heavyNext === 1); launchBall(st, 0); const ball = st.balls[0]; check('slam ball is oversized', ball.heavy && ball.r === T.BALL_R * POWERS.bodyslam.params.radiusScale); check('slam charge consumed', st.heavyNext === 0); check('slam ball launches slower', near(Math.hypot(ball.vx, ball.vy), T.LAUNCH_SPEED * POWERS.bodyslam.params.speedScale, 0.5), `${Math.hypot(ball.vx, ball.vy)}`); { // Gravity pulls the slam ball down more gently than a regular ball. const mk = (heavy) => { const s = mkState([]); if (heavy) applyPower(s, 'bodyslam'); launchBall(s, 0); const vy0 = s.balls[0].vy; stepSim(s, 0.5); return s.balls[0].vy - vy0; }; const gain = mk(true) / mk(false); check('slam ball falls at scaled gravity', near(gain, POWERS.bodyslam.params.gravityScale, 0.02), `${gain}`); } let vyBefore = null; let vyAfter = null; for (let i = 0; i < 2000 && vyAfter == null; i++) { vyBefore = st.balls[0]?.vy; const evs = stepSim(st, 1 / 240); if (evs.some((e) => e.type === 'pegHit')) vyAfter = st.balls[0]?.vy; } check('slam ball keeps ≈95% speed off pegs', vyAfter != null && near(Math.abs(vyAfter / vyBefore), POWERS.bodyslam.params.restitution, 0.06), `${Math.abs(vyAfter / vyBefore)}`); } { // Laser Sweep: lights exactly the green peg's row. const st = mkState([ { x: 600, y: 500 }, // green { x: 200, y: 505 }, { x: 1000, y: 495 }, // same row { x: 600, y: 300 }, // off row ], { powerId: 'lasergrid' }); st.pegs[0].color = 'green'; inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const laser = log.find((e) => e.type === 'laserRow'); check('laser lights exactly its row', !!laser && laser.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, laser && JSON.stringify(laser.pegIds)); } { // Beam Up: a draining ball returns from the top once, then drains normally. const st = mkState([]); inject(st, { x: 300, y: 700, vx: 0, vy: 400 }); applyPower(st, 'beamup', { ball: st.balls[0] }); check('beam up arms the ball', st.balls[0].spooky === 1); let beamed = false; let lost = false; for (let i = 0; i < 4000 && !lost; i++) { const evs = stepSim(st, 1 / 60); for (const e of evs) { if (e.type === 'beamUp') { beamed = true; check('beamed ball re-enters at the top, same x', near(st.balls[0].x, 300, 1) && st.balls[0].y < 50, `x ${st.balls[0]?.x} y ${st.balls[0]?.y}`); } if (e.type === 'ballLost') lost = true; } } check('beam up fires once then the ball drains', beamed && lost && st.balls.length === 0); } { // Meteor Strike: lights exactly the green peg's column. const st = mkState([ { x: 600, y: 500 }, // green { x: 640, y: 250 }, { x: 570, y: 700 }, // same column { x: 900, y: 500 }, // off column ], { powerId: 'meteor' }); st.pegs[0].color = 'green'; inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const met = log.find((e) => e.type === 'meteorColumn'); check('meteor lights exactly its column', !!met && met.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, met && JSON.stringify(met.pegIds)); } { // Overclock: doubles peg scoring for the rest of the shot only. An orange // far off the path keeps the round alive; force the target peg blue (the // round's purple assignment would otherwise land on it). const st = mkState([{ x: 600, y: 500 }, { x: 100, y: 780, orangeEligible: true }], { orangeCount: 1 }); st.pegs[0].color = 'blue'; st.purpleId = null; applyPower(st, 'overclock'); check('overclock active', st.overclockShot === true); inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 10); const hit = log.find((e) => e.type === 'pegHit'); check('overclocked blue peg scores 20', hit && hit.points === SCORING.PEG_BASE.blue * 2, JSON.stringify(hit)); launchBall(st, 0); check('overclock expires at next launch', st.overclockShot === false); } { // Extreme Ball: faster launch, lossless wall bounces. const st = mkState([]); applyPower(st, 'extremeball'); launchBall(st, 0.4); const b = st.balls[0]; const speed = Math.hypot(b.vx, b.vy); check('extreme ball launches at scaled speed', near(speed, T.LAUNCH_SPEED * POWERS.extremeball.params.speedScale, 1), `${speed}`); const st2 = mkState([]); inject(st2, { x: 100, y: 300, vx: -900, vy: 0, extreme: true, r: T.BALL_R }); let vxAfter = null; for (let i = 0; i < 200 && vxAfter == null; i++) { if (stepSim(st2, 1 / 240).some((e) => e.type === 'wallHit')) vxAfter = st2.balls[0].vx; } check('extreme ball loses nothing to walls', vxAfter != null && near(vxAfter, 900, 1), `${vxAfter}`); } { // Cannonball: plows through exactly 3 fresh pegs. const pegs = [{ x: 600, y: 350 }, { x: 600, y: 430 }, { x: 600, y: 510 }, { x: 600, y: 590 }]; const st = mkState(pegs); inject(st, { x: 600, y: 280, vx: 0, vy: 400 }); applyPower(st, 'cannonball', { ball: st.balls[0] }); check('cannonball arms 3 pierces', st.balls[0].pierce === 3); run(st, 4); const litOrRemoved = st.pegs.filter((p) => p.lit || p.removed).length; check('cannonball pierces the first 3 then bounces', litOrRemoved >= 3 && st.pegs[0].lit !== undefined, `${litOrRemoved} pegs lit`); } { // Beaver Dam: wider opening catches wide, expires after 3 launches. const st = mkState([]); applyPower(st, 'beaverdam'); check('dam charges 3 shots', st.wideBucketShots === 3); check('dam widens the bucket', bucketWidth(st) === T.BUCKET_W * POWERS.beaverdam.params.widthScale); st.time = 0; const bx = bucketX(st); const wideX = bx + T.BUCKET_W / 2 + 20; // outside normal opening, inside dam st.phase = 'flight'; st.wideBucketShots = 3; st.balls.push({ x: wideX, y: T.BUCKET_Y - 30, vx: 0, vy: 300, stuckFor: 0, fireball: false, r: T.BALL_R, pierce: 0, spooky: 0 }); let caught = false; for (let i = 0; i < 60 && !caught; i++) { caught = stepSim(st, 1 / 240).some((e) => e.type === 'bucketCatch'); } check('dam catches outside the normal opening', caught); const st3 = mkState([]); applyPower(st3, 'beaverdam'); launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim'; launchBall(st3, 0); st3.balls.length = 0; st3.phase = 'aim'; launchBall(st3, 0); check('dam expires after 3 launches', st3.wideBucketShots === 0 && bucketWidth(st3) === T.BUCKET_W); } { // Shadow Slash: lights the diagonals, not the straights. const st = mkState([ { x: 600, y: 500 }, // green { x: 700, y: 600 }, { x: 480, y: 620 }, // on the two diagonals { x: 720, y: 500 }, // straight right — off ], { powerId: 'shadowslash' }); st.pegs[0].color = 'green'; inject(st, { x: 600, y: 450, vx: 0, vy: 200 }); const log = run(st, 2); const slash = log.find((e) => e.type === 'shadowSlash'); check('slash lights only the diagonals', !!slash && slash.pegIds.sort().join(',') === '1,2' && !st.pegs[3].lit, slash && JSON.stringify(slash.pegIds)); } { // Tailwind: floaty ball falls measurably slower. const stA = mkState([]); applyPower(stA, 'tailwind'); launchBall(stA, 0); const stB = mkState([]); launchBall(stB, 0); for (let i = 0; i < 30; i++) { stepSim(stA, 1 / 60); stepSim(stB, 1 / 60); } check('tailwind ball hangs higher', (stA.balls[0]?.y ?? 9999) < (stB.balls[0]?.y ?? 0), `floaty y ${stA.balls[0]?.y} vs normal y ${stB.balls[0]?.y}`); } { // Cosmic Bloom: exactly 2 blues become green, and they're real power pegs. const st = mkState([ { x: 200, y: 300 }, { x: 400, y: 300 }, { x: 600, y: 300 }, { x: 800, y: 300 }, { x: 1000, y: 300 }, ], { powerId: 'cosmicbloom' }); const evs = applyPower(st, 'cosmicbloom'); const conv = evs.find((e) => e.type === 'pegsConverted'); const greens = st.pegs.filter((p) => p.color === 'green'); check('bloom converts exactly 2 blues to green', !!conv && conv.pegIds.length === POWERS.cosmicbloom.params.blooms && greens.length >= 2, `${greens.length} greens`); } { // Rewind: a drained ball is refunded once. const st = mkState([{ x: 100, y: 500, orangeEligible: true }], { orangeCount: 1 }); applyPower(st, 'rewind'); check('rewind charges', st.rewindCharges === 1); const before = st.ballsLeft; launchBall(st, 0); // misses the lone corner peg const log = run(st, 10); check('rewind refunds the drained ball', log.some((e) => e.type === 'rewind') && log.some((e) => e.type === 'freeBall' && e.reason === 'rewind')); check('ballsLeft restored, charge spent', st.ballsLeft === before && st.rewindCharges === 0, `ballsLeft ${st.ballsLeft} vs ${before}`); } { // Every power id survives applyPower on a bare state. let ok = true; for (const id of Object.keys(POWERS)) { try { const st = mkState([{ x: 600, y: 500 }]); applyPower(st, id, { peg: st.pegs[0] }); } catch (err) { ok = false; console.error(` applyPower(${id}) threw: ${err.message}`); } } check('all POWERS ids apply cleanly', ok); check('all POWERS entries have name/desc/trigger', Object.values(POWERS).every((p) => p.name && p.desc && p.trigger)); } // ── 4. Determinism ─────────────────────────────────────────────────────────── console.log('― determinism'); { const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-002.json'), 'utf8')); const logOf = (dt) => { const st = createRound(lvl, { seed: 4242 }); const log = [...launchBall(st, 0.42)]; for (let t = 0; t < 30 && st.phase === 'flight'; t += dt) log.push(...stepSim(st, dt)); return { log: log.filter((e) => e.type === 'pegHit').map((e) => `${e.pegId}:${e.points}`).join('|'), score: st.score }; }; const a = logOf(1 / 60); const b = logOf(1 / 60); check('seeded replay identical', a.log === b.log && a.score === b.score); const c = logOf(1 / 120); check('frame-rate independent (1/60 vs 1/120)', a.log === c.log && a.score === c.score, `\n a: ${a.log}\n c: ${c.log}`); } { // Preview is a dry run: it must not disturb the live state. const lvl = JSON.parse(readFileSync(join(DATA_DIR, 'level-003.json'), 'utf8')); const st = createRound(lvl, { seed: 31337 }); const snap = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase }); const prev = simulatePreview(st, 0.2, { maxPegHits: 3 }); const snap2 = JSON.stringify({ pegs: st.pegs, score: st.score, balls: st.balls, rng: st.rng.state, phase: st.phase }); check('preview leaves live state untouched', snap === snap2); check('preview produces a path', prev.points.length > 5); // Preview trajectory agrees with the real shot up to the first peg contact. const st2 = cloneState(st); const log = [...launchBall(st2, 0.2)]; for (let t = 0; t < 20 && st2.phase === 'flight' && !log.some((e) => e.type === 'pegHit'); t += 1 / 120) { log.push(...stepSim(st2, 1 / 120)); } const firstReal = log.find((e) => e.type === 'pegHit'); const stP = cloneState(st); const logP = [...launchBall(stP, 0.2)]; for (let t = 0; t < 20 && stP.phase === 'flight' && !logP.some((e) => e.type === 'pegHit'); t += 1 / 120) { logP.push(...stepSim(stP, 1 / 120)); } const firstPrev = logP.find((e) => e.type === 'pegHit'); check('preview and live shot hit the same first peg', firstReal && firstPrev && firstReal.pegId === firstPrev.pegId); } { const r1 = mulberry32(555); const r2 = mulberry32(555); const seq1 = [r1.next(), r1.next(), r1.next()]; const seq2 = [r2.next(), r2.next(), r2.next()]; check('mulberry32 deterministic', seq1.join() === seq2.join()); const r3 = mulberry32(0); r3.state = r1.state; check('rng state clone resumes identically', r3.next() === r2.next()); } check('aim clamps to ±MAX_AIM_DEG', clampAim(3) === (T.MAX_AIM_DEG * Math.PI) / 180 && clampAim(-3) === -(T.MAX_AIM_DEG * Math.PI) / 180); { const v = aimToVelocity(0); check('straight-down launch', near(v.vx, 0, 0.001) && near(v.vy, T.LAUNCH_SPEED, 0.001)); } // ── 5. Data lint ───────────────────────────────────────────────────────────── console.log('― data lint'); { const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8')); const opponents = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'opponents.json'), 'utf8')); const roster = new Set((opponents.opponents ?? opponents).map((o) => o.id)); check('manifest has levels', Array.isArray(manifest.levels) && manifest.levels.length >= 5); check('manifest levels sequential from 1', manifest.levels.every((l, i) => l.level === i + 1)); // Friend blocks: 5 consecutive levels per master, in the expected order, // one power per block. The intro screen keys off masterId changing between // consecutive entries, so block integrity matters. const BLOCK_ORDER = [ 'ethel', 'kona', 'zanthor', 'fireball', 'victor', 'smasher', 'maurice', 'schooner', 'terry', 'gerome', 'blackwind', 'steve', 'nicole', 'dv-8-2303', 'kage', 'aiko', 'nadia', ]; check('85 levels in 17 friend blocks', manifest.levels.length === BLOCK_ORDER.length * 5); let blocksOk = true; for (let b = 0; b < BLOCK_ORDER.length; b++) { const block = manifest.levels.slice(b * 5, b * 5 + 5); if (!block.every((l) => l.masterId === BLOCK_ORDER[b])) blocksOk = false; if (new Set(block.map((l) => l.powerId)).size !== 1) blocksOk = false; } check('blocks of 5 share master+power in order', blocksOk); const introLevels = manifest.levels .filter((l, i) => i === 0 || manifest.levels[i - 1].masterId !== l.masterId) .map((l) => l.level); const expectedIntros = BLOCK_ORDER.map((_, b) => b * 5 + 1).join(','); check('masterId-change rule marks intros at each block start', introLevels.join(',') === expectedIntros, introLevels.join(',')); check('level names unique', new Set(manifest.levels.map((l) => l.name)).size === manifest.levels.length); check('manifest masters exist in opponents.json', manifest.levels.every((l) => roster.has(l.masterId)), manifest.levels.filter((l) => !roster.has(l.masterId)).map((l) => l.masterId).join(',')); check('manifest powers exist in POWERS', manifest.levels.every((l) => POWERS[l.powerId])); check('manifest names/files present', manifest.levels.every((l) => l.name && /^level-\d{3}\.json$/.test(l.file))); for (const entry of manifest.levels) { const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8')); const tag = entry.file; check(`${tag}: board is ${T.BOARD_W}×${T.BOARD_H}`, lvl.board.width === T.BOARD_W && lvl.board.height === T.BOARD_H); const bricks = tessellateCurves(lvl.curves ?? []); check(`${tag}: enough orange-eligible pegs`, lvl.pegs.filter((p) => p.orangeEligible).length + bricks.filter((b) => b.orangeEligible).length >= lvl.orangeCount); check(`${tag}: enough pegs for orange+green+purple`, lvl.pegs.length + bricks.length >= lvl.orangeCount + lvl.greenCount + 1); if (lvl.curves?.length) { check(`${tag}: curve schema valid`, lvl.curves.every((c) => (c.anchors?.length ?? 0) >= 2 && (c.bends?.length ?? -1) === c.anchors.length - 1)); const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2); check(`${tag}: bricks in bounds and clear of launcher/bucket lanes`, bricks.every((b) => b.x >= boundR && b.x <= T.BOARD_W - boundR && b.y >= 140 && b.y <= 820), bricks.filter((b) => !(b.y >= 140 && b.y <= 820)).map((b) => `${Math.round(b.x)},${Math.round(b.y)}`).join(' ')); const clash = lvl.pegs.some((p) => bricks.some((b) => { const min = (p.r ?? T.PEG_R) + T.BRICK_WID / 2; return (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min; })); check(`${tag}: bricks clear of circle pegs`, !clash); } check(`${tag}: pegs in bounds and clear of launcher/bucket lanes`, lvl.pegs.every((p) => p.x >= p.r && p.x <= T.BOARD_W - p.r && p.y >= 140 && p.y <= 820)); let overlap = false; for (let i = 0; i < lvl.pegs.length && !overlap; i++) { for (let j = i + 1; j < lvl.pegs.length; j++) { const a = lvl.pegs[i]; const b = lvl.pegs[j]; const min = a.r + b.r + 6; if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < min * min) { overlap = true; break; } } } check(`${tag}: no overlapping pegs`, !overlap); // Every level must be playable: a straight-ish shot hits at least one peg. const st = createRound(lvl, { seed: 1 }); let anyHit = false; for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) { if (scoreShot(st, ang) > 0) { anyHit = true; break; } } check(`${tag}: pegs are reachable`, anyHit); } } // ── Soak: full rounds at varied aims stay well-formed ──────────────────────── console.log('― soak'); { const manifest = JSON.parse(readFileSync(join(DATA_DIR, 'levels.json'), 'utf8')); let wins = 0; let games = 0; let invariantOk = true; for (const entry of manifest.levels) { const lvl = JSON.parse(readFileSync(join(DATA_DIR, entry.file), 'utf8')); for (let g = 0; g < 3; g++) { games++; const st = createRound({ ...lvl, powerId: entry.powerId }, { seed: g * 977 + entry.level }); const rng = mulberry32(g * 31 + 7); let guard = 0; // Generous guard: 10+ balls × up to MAX_FLIGHT_S each at 60 steps/s. while (st.phase !== 'won' && st.phase !== 'lost' && guard++ < 60 * 60 * 15) { if (st.phase === 'aim') { // Aim at a random remaining unlit peg, then (like a player reading // the aim guide) try a few nearby angles and take the best-scoring. const targets = st.pegs.filter((p) => !p.removed && (p.color === 'orange' || rng.next() < 0.3)); const tgt = targets[Math.floor(rng.next() * targets.length)] ?? { x: 600, y: 600 }; const base = Math.atan2(tgt.x - T.LAUNCH_X, tgt.y - T.LAUNCH_Y); let angle = base; let bestScore = -1; for (const off of [-0.22, -0.1, 0, 0.1, 0.22]) { const s = scoreShot(st, base + off, { maxTime: 25 }); if (s > bestScore) { bestScore = s; angle = base + off; } } launchBall(st, angle); } stepSim(st, 1 / 60); const cleared = st.pegs.filter((p) => p.color === 'orange' && (p.removed || p.lit)).length; if (cleared !== st.orangeCleared) invariantOk = false; if (st.score < 0 || st.ballsLeft < 0) invariantOk = false; } if (st.phase === 'won') wins++; if (st.phase !== 'won' && st.phase !== 'lost') invariantOk = false; } } check('soak: every game terminates cleanly', invariantOk); check('soak: naive aim-at-orange wins sometimes', wins > 0, `${wins}/${games}`); console.log(` soak result: ${wins}/${games} naive-aim wins`); } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`\n${passes} passed, ${failures} failed`); process.exit(failures ? 1 : 0);