import { describe, it, expect } from 'vitest'; import { _test } from '../src/lib/pinball.js'; const { closestOnSeg, clamp, TABLE, PHYS } = _test; describe('clamp', () => { it('bounds a value', () => { expect(clamp(5, 0, 10)).toBe(5); expect(clamp(-3, 0, 10)).toBe(0); expect(clamp(99, 0, 10)).toBe(10); }); }); describe('closestOnSeg', () => { it('projects onto the segment interior', () => { const [x, y, t] = closestOnSeg(5, 5, 0, 0, 10, 0); expect([x, y]).toEqual([5, 0]); expect(t).toBeCloseTo(0.5); }); it('clamps to the endpoints', () => { expect(closestOnSeg(-4, 2, 0, 0, 10, 0).slice(0, 2)).toEqual([0, 0]); expect(closestOnSeg(20, -1, 0, 0, 10, 0).slice(0, 2)).toEqual([10, 0]); }); it('handles a degenerate (zero-length) segment', () => { expect(closestOnSeg(3, 4, 1, 1, 1, 1).slice(0, 2)).toEqual([1, 1]); }); }); // "Gravity only goes down" (pinballmakers.com/wiki/Design#Pinball_Basics): // any two table features that don't touch by design must stay at least a // ball's width apart everywhere, or a ball can wedge between them with no // way through. This walks the real TABLE geometry (including hand-edited // layouts exported from the #/pinball-editor dev tool) and fails loudly if // it finds that class of bug. describe('table geometry has no gravity traps', () => { const BALL_D = PHYS.ballR * 2; // The dangerous range is a gap the ball can partially enter but not pass // through — roughly 3px (below that, the ball can't get its centre near // the gap at all; it just bounces off whichever wall it reaches first) // up to one ball-diameter. Gaps at/above BALL_D are honest passages — // including a deliberately narrow one — not traps. const DANGER_MIN = 3; const EPS = 0.05; function samplePts(a, b, step = 2) { const len = Math.hypot(b[0] - a[0], b[1] - a[1]); const n = Math.max(1, Math.round(len / step)); return Array.from({ length: n + 1 }, (_, i) => { const t = i / n; return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; }); } const eqPt = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]) < EPS; // Union-find: two wall segments are the same physical feature (allowed // to be close, even touching) if a chain of shared endpoints connects // them — e.g. the tessellated boundary curve, or a slingshot's 3 edges. const walls = TABLE.walls; const parent = walls.map((_, i) => i); const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; for (let i = 0; i < walls.length; i++) { for (let j = i + 1; j < walls.length; j++) { const [w1, w2] = [walls[i], walls[j]]; if (eqPt(w1.a, w2.a) || eqPt(w1.a, w2.b) || eqPt(w1.b, w2.a) || eqPt(w1.b, w2.b)) { const [ra, rb] = [find(i), find(j)]; if (ra !== rb) parent[ra] = rb; } } } const sampled = walls.map((w) => samplePts(w.a, w.b)); it('keeps unrelated wall segments at least a ball-width apart', () => { const offenders = []; for (let i = 0; i < walls.length; i++) { for (let j = i + 1; j < walls.length; j++) { if (find(i) === find(j)) continue; // same feature — expected to touch let minD = Infinity; for (const p of sampled[i]) for (const q of sampled[j]) minD = Math.min(minD, Math.hypot(p[0] - q[0], p[1] - q[1])); if (minD >= DANGER_MIN && minD < BALL_D) offenders.push( `#${i} (${walls[i].role}) <-> #${j} (${walls[j].role}): ${minD.toFixed(1)}px`, ); } } expect(offenders).toEqual([]); }); it('keeps bumpers/posts clear of every wall', () => { const circles = [...TABLE.bumpers, ...TABLE.posts]; const offenders = []; for (const c of circles) { for (const w of walls) { const [cx, cy] = closestOnSeg(c.x, c.y, w.a[0], w.a[1], w.b[0], w.b[1]); const gap = Math.hypot(c.x - cx, c.y - cy) - c.r; if (gap >= DANGER_MIN && gap < BALL_D) offenders.push(`(${c.x},${c.y}) r${c.r} <-> ${w.role}: ${gap.toFixed(1)}px`); } } expect(offenders).toEqual([]); }); it('keeps bumpers/posts clear of each other', () => { const circles = [...TABLE.bumpers, ...TABLE.posts]; const offenders = []; for (let i = 0; i < circles.length; i++) { for (let j = i + 1; j < circles.length; j++) { const [a, b] = [circles[i], circles[j]]; const gap = Math.hypot(a.x - b.x, a.y - b.y) - a.r - b.r; if (gap >= DANGER_MIN && gap < BALL_D) offenders.push(`(${a.x},${a.y}) <-> (${b.x},${b.y}): ${gap.toFixed(1)}px`); } } expect(offenders).toEqual([]); }); });