dex/test/pinball.test.js
chris 6b090b9a0a Pinball: hand-tuned table via a new drag-to-edit dev tool
Added #/pinball-editor — a temporary drag-to-edit layout board (points,
bumpers/posts, flipper pivot+tip, mirror lock, add/delete nodes,
rubber-band multi-select, live JSON export/autosave). Used it to
redesign the table by hand instead of guessing coordinates, then
rebuilt TABLE in pinball.js from the exported layout:

- Real outlane tubes on both sides (inboard of the outer wall) instead
  of a sealed funnel — a ball can miss the flipper and drain, same as
  a real table, verified to have no wedge points.
- Flippers same length, mirrored pivots and rest angle; slingshots and
  outlane tubes are exact mirrors of each other.
- Everything (outlanes, slings, bumpers, posts, flippers) recentred on
  the whole table width — including the shooter lane — rather than
  just the playfield-minus-lane, so it isn't visually lopsided.
- Bumper/post sizes back to a consistent set after some experimentation
  drifted them apart (15/16/15/12/12 and 7/7/8).
- Physics: heavier gravity (was floaty), stronger slingshot kick (the
  rubber wasn't doing anything), eased back down slightly after the
  first pass felt too fast, flipper length is now per-flipper.
- The "gravity only goes down" trap-safety test (added last session)
  now runs against this hand-edited geometry too and passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 23:42:09 -04:00

125 lines
4.6 KiB
JavaScript

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([]);
});
});