// Headless verification for Angry Birds. // node tools/verifyAngryBirds.js // Exits non-zero on any failure. // // 1. Physics invariants — mass properties, resting contacts, stack stability, // friction, restitution, anti-tunnel bound, explosion falloff. // 2. Determinism — seeded replay, frame-rate independence, clone independence. // 3. Robustness — random-impulse monkey test for NaN / overspeed / sinking. // // Rendering, slingshot feel, camera and the editor's Blob export are // browser-only and must be smoke-tested manually. import { PHYS, createWorld, addBox, addPoly, addCircle, removeBody, step, substep, settle, isSettled, applyImpulse, applyExplosion, cloneWorld, hashWorld, contactImpulses, } from '../src/games/angrybirds/AngryBirdsPhysics.js'; 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; function section(title) { console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 60 - title.length))}`); } // Deterministic RNG for the monkey test (never Math.random — see the header // contract in AngryBirdsPhysics.js). function mulberry32(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Ground plane spanning the test area. */ function withGround(world, y = 800) { addBox(world, { x: 600, y: y + 50, w: 4000, h: 100, isStatic: true, friction: 0.7 }); return world; } // ── 1. Mass properties ────────────────────────────────────────────────────── section('1. Mass properties'); { const w = createWorld(); const b = addBox(w, { x: 0, y: 0, w: 40, h: 20, density: 2 }); const expectMass = 2 * 40 * 20; check('box mass = density x w x h', near(b.mass, expectMass, 1e-6), `${b.mass} vs ${expectMass}`); const expectI = (expectMass * (40 * 40 + 20 * 20)) / 12; check('box inertia = m(w^2+h^2)/12', near(1 / b.invI, expectI, expectI * 1e-6), `${1 / b.invI} vs ${expectI}`); const c = addCircle(w, { x: 0, y: 0, r: 10, density: 3 }); const cMass = 3 * Math.PI * 100; check('circle mass = density x pi r^2', near(c.mass, cMass, 1e-6), `${c.mass} vs ${cMass}`); const s = addBox(w, { x: 0, y: 0, w: 10, h: 10, isStatic: true }); check('static body has zero inverse mass', s.invMass === 0 && s.invI === 0); } { // Winding must be normalized: a CW polygon must produce the same body as CCW. const w = createWorld(); const ccw = addPoly(w, { x: 0, y: 0, verts: [[-10, -10], [10, -10], [10, 10], [-10, 10]], density: 1 }); const cw = addPoly(w, { x: 0, y: 0, verts: [[-10, 10], [10, 10], [10, -10], [-10, -10]], density: 1 }); check('polygon winding normalized', near(ccw.mass, cw.mass, 1e-9) && near(ccw.invI, cw.invI, 1e-12), `${ccw.mass}/${cw.mass}`); // Outward normals must point away from the centroid. const outward = ccw.normals.every(([nx, ny], i) => { const [vx, vy] = ccw.verts[i]; return vx * nx + vy * ny > 0; }); check('polygon normals point outward', outward); } { // Verts are recentred on the centroid, so an off-centre polygon still // rotates about its true centre of mass. const w = createWorld(); const b = addPoly(w, { x: 0, y: 0, verts: [[0, 0], [40, 0], [40, 20], [0, 20]], density: 1 }); const cx = b.verts.reduce((s, v) => s + v[0], 0) / b.verts.length; const cy = b.verts.reduce((s, v) => s + v[1], 0) / b.verts.length; check('polygon recentred on centroid', near(cx, 0, 1e-9) && near(cy, 0, 1e-9), `${cx},${cy}`); } // ── 2. Anti-tunnelling bound ──────────────────────────────────────────────── section('2. Anti-tunnelling'); { const travel = PHYS.MAX_SPEED * PHYS.SUBSTEP_DT; check('MAX_SPEED x SUBSTEP_DT < MIN_HALF_EXTENT', travel < PHYS.MIN_HALF_EXTENT, `${travel.toFixed(2)} !< ${PHYS.MIN_HALF_EXTENT}`); } { // A fast circle fired at a thin static wall must not pass through it. const w = createWorld(); addBox(w, { x: 400, y: 300, w: 20, h: 400, isStatic: true }); const ball = addCircle(w, { x: 100, y: 300, r: 12, density: 1 }); ball.vx = PHYS.MAX_SPEED; w.gravity = 0; for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT); check('fast body does not tunnel through a wall', ball.x < 400, `x=${ball.x.toFixed(1)}`); } // ── 3. Resting contacts and stack stability (THE WAVE 0 GATE) ─────────────── section('3. Resting contacts and stacks'); { const w = withGround(createWorld()); const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 }); for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT); // Ground top is y=800; a 60-tall box rests with its centre at 770. check('single box rests on ground', near(b.y, 770, PHYS.SLOP + 0.5), `y=${b.y.toFixed(3)}`); check('resting box does not sink', b.y < 772, `y=${b.y.toFixed(3)}`); check('resting box falls asleep', b.sleeping, `timer=${b.sleepTimer.toFixed(2)}`); } { // THE GATE: a 10-box tower must settle, sleep, and not drift. const w = withGround(createWorld()); const boxes = []; // Ground top is y=800, boxes are 40 tall, so box i rests centred at 780-40i. // Spawning them exactly at rest isolates solver sag from free-fall settling. for (let i = 0; i < 10; i += 1) { boxes.push(addBox(w, { x: 600, y: 780 - i * 40, w: 60, h: 40, density: 1, friction: 0.6 })); } const startX = boxes.map((b) => b.x); let sleptAt = -1; for (let i = 0; i < 720; i += 1) { // 3 simulated seconds at 1/240 substep(w, PHYS.SUBSTEP_DT); if (sleptAt < 0 && isSettled(w)) sleptAt = i; } check('10-box tower settles within 3s', sleptAt >= 0, `never settled`); check('10-box tower is fully asleep', boxes.every((b) => b.sleeping), `${boxes.filter((b) => !b.sleeping).length} awake`); const drift = Math.max(...boxes.map((b, i) => Math.abs(b.x - startX[i]))); check('tower horizontal drift < 1px', drift < 1, `max drift ${drift.toFixed(3)}px`); // Total sag is bounded by SLOP per contact — the solver deliberately stops // correcting once penetration is inside the slop band, so 10 stacked // contacts can each give up to SLOP. Anything beyond that is real sag. const sag = boxes[9].y - (780 - 9 * 40); const sagBudget = PHYS.SLOP * 10; check('tower sag within the slop budget', Math.abs(sag) < sagBudget, `sag ${sag.toFixed(3)}px vs budget ${sagBudget}px`); const tilt = Math.max(...boxes.map((b) => Math.abs(b.angle))); check('tower stays upright', tilt < 0.02, `max |angle| ${tilt.toFixed(4)} rad`); } { // A pyramid is the harder stacking case: contacts are shared sideways. const w = withGround(createWorld()); const bodies = []; for (let row = 0; row < 5; row += 1) { const n = 5 - row; for (let i = 0; i < n; i += 1) { bodies.push(addBox(w, { x: 600 - (n - 1) * 35 + i * 70, y: 770 - row * 40, w: 64, h: 40, density: 1, friction: 0.6, })); } } for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT); check('pyramid settles and sleeps', bodies.every((b) => b.sleeping), `${bodies.filter((b) => !b.sleeping).length} awake`); const maxTilt = Math.max(...bodies.map((b) => Math.abs(b.angle))); check('pyramid stays upright', maxTilt < 0.05, `max |angle| ${maxTilt.toFixed(4)}`); } // ── 4. Friction ───────────────────────────────────────────────────────────── section('4. Friction'); { // 15 degrees, mu = 0.8 -> tan(15) = 0.27 < 0.8, so the box must not creep. const w = createWorld(); const slope = 15 * Math.PI / 180; addBox(w, { x: 600, y: 800, w: 2000, h: 60, angle: slope, isStatic: true, friction: 0.9 }); const b = addBox(w, { x: 600, y: 745, w: 60, h: 40, angle: slope, density: 1, friction: 0.9 }); const x0 = b.x; for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT); check('box on 15deg slope does not creep', Math.abs(b.x - x0) < 2, `moved ${(b.x - x0).toFixed(2)}px`); check('box on slope sleeps', b.sleeping); } { // 40 degrees, mu = 0.2 -> tan(40) = 0.84 > 0.2, so it must slide. const w = createWorld(); const slope = 40 * Math.PI / 180; addBox(w, { x: 600, y: 800, w: 3000, h: 60, angle: slope, isStatic: true, friction: 0.2 }); const b = addBox(w, { x: 400, y: 800 - 200 * Math.tan(slope) - 52, w: 60, h: 40, angle: slope, density: 1, friction: 0.2 }); const x0 = b.x; for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT); check('low-friction box slides down a steep slope', b.x - x0 > 20, `moved ${(b.x - x0).toFixed(2)}px`); } // ── 5. Restitution ────────────────────────────────────────────────────────── section('5. Restitution'); { const w = withGround(createWorld()); const ball = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0.8 }); let peakUp = 0; for (let i = 0; i < 400; i += 1) { substep(w, PHYS.SUBSTEP_DT); if (ball.vy < peakUp) peakUp = ball.vy; } check('bouncy ball rebounds upward', peakUp < -100, `peak vy ${peakUp.toFixed(1)}`); } { const w = withGround(createWorld()); const dead = addCircle(w, { x: 600, y: 400, r: 20, density: 1, restitution: 0 }); for (let i = 0; i < 900; i += 1) substep(w, PHYS.SUBSTEP_DT); check('zero-restitution ball comes to rest', dead.sleeping && near(dead.y, 780, 1.5), `y=${dead.y.toFixed(2)} sleeping=${dead.sleeping}`); } // ── 6. Shape-pair coverage ────────────────────────────────────────────────── section('6. Shape pairs'); { const w = createWorld(); w.gravity = 0; const a = addCircle(w, { x: 100, y: 300, r: 20, density: 1 }); const b = addCircle(w, { x: 200, y: 300, r: 20, density: 1 }); a.vx = 200; for (let i = 0; i < 200; i += 1) substep(w, PHYS.SUBSTEP_DT); check('circle-circle transfers momentum', b.vx > 50 && a.vx < 200, `a=${a.vx.toFixed(1)} b=${b.vx.toFixed(1)}`); } { const w = createWorld(); w.gravity = 0; const c = addCircle(w, { x: 100, y: 300, r: 20, density: 1 }); const p = addBox(w, { x: 300, y: 300, w: 60, h: 60, density: 1 }); c.vx = 300; for (let i = 0; i < 240; i += 1) substep(w, PHYS.SUBSTEP_DT); check('circle-poly transfers momentum', p.vx > 20, `box vx=${p.vx.toFixed(1)}`); check('circle-poly does not overlap after impact', Math.hypot(c.x - p.x, c.y - p.y) > 40, `dist ${Math.hypot(c.x - p.x, c.y - p.y).toFixed(1)}`); } { // A circle dropped into a closed V must wedge, not squeeze through the seam. // Two slabs tilted toward each other, overlapping at the bottom so there is // no gap for the ball to slip through. const w = createWorld(); addBox(w, { x: 480, y: 780, w: 400, h: 40, angle: -0.6, isStatic: true, friction: 0.6 }); addBox(w, { x: 720, y: 780, w: 400, h: 40, angle: 0.6, isStatic: true, friction: 0.6 }); const ball = addCircle(w, { x: 600, y: 300, r: 25, density: 1 }); for (let i = 0; i < 1800; i += 1) substep(w, PHYS.SUBSTEP_DT); check('circle wedges in a V without escaping', ball.y < 820 && ball.sleeping, `y=${ball.y.toFixed(1)} sleeping=${ball.sleeping}`); } // ── 7. Sleeping and waking ────────────────────────────────────────────────── section('7. Sleeping'); { const w = withGround(createWorld()); const stack = []; for (let i = 0; i < 4; i += 1) stack.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 })); for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT); check('stack asleep before impact', stack.every((b) => b.sleeping)); // A projectile must wake the whole island, not just the box it touches. const shot = addCircle(w, { x: 200, y: 700, r: 16, density: 4 }); shot.vx = 1200; let allAwake = false; for (let i = 0; i < 240; i += 1) { substep(w, PHYS.SUBSTEP_DT); if (stack.every((b) => !b.sleeping)) { allAwake = true; break; } } check('impact wakes the whole island', allAwake, `${stack.filter((b) => b.sleeping).length} still asleep`); } { const w = withGround(createWorld()); const b = addBox(w, { x: 600, y: 700, w: 60, h: 60, density: 1 }); settle(w, 10); check('settle() reaches rest', isSettled(w) && b.sleeping); const yRest = b.y; for (let i = 0; i < 600; i += 1) substep(w, PHYS.SUBSTEP_DT); check('sleeping body does not drift', near(b.y, yRest, 1e-9), `${b.y} vs ${yRest}`); } // ── 8. Explosions ─────────────────────────────────────────────────────────── section('8. Explosions'); { const w = withGround(createWorld()); const near1 = addBox(w, { x: 620, y: 700, w: 40, h: 40, density: 1 }); const far1 = addBox(w, { x: 900, y: 700, w: 40, h: 40, density: 1 }); const outside = addBox(w, { x: 1400, y: 700, w: 40, h: 40, density: 1 }); const hit = applyExplosion(w, 600, 700, 400, 600); check('explosion hits bodies inside the radius', hit.length === 2, `hit ${hit.length}`); check('explosion falls off with distance', Math.hypot(near1.vx, near1.vy) > Math.hypot(far1.vx, far1.vy), `${Math.hypot(near1.vx, near1.vy).toFixed(1)} vs ${Math.hypot(far1.vx, far1.vy).toFixed(1)}`); check('explosion spares bodies outside the radius', outside.vx === 0 && outside.vy === 0); check('explosion pushes away from the centre', near1.vx > 0 && far1.vx > 0); check('explosion wakes sleeping bodies', !near1.sleeping); } // ── 9. Determinism ────────────────────────────────────────────────────────── section('9. Determinism'); function scene() { const w = withGround(createWorld()); for (let i = 0; i < 6; i += 1) addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 }); addBox(w, { x: 660, y: 730, w: 30, h: 120, density: 0.8 }); const ball = addCircle(w, { x: 200, y: 600, r: 16, density: 4 }); ball.vx = 900; ball.vy = -120; return w; } { const a = scene(); const b = scene(); for (let i = 0; i < 300; i += 1) { substep(a, PHYS.SUBSTEP_DT); substep(b, PHYS.SUBSTEP_DT); } check('identical scenes replay bit-identically', hashWorld(a) === hashWorld(b), `${hashWorld(a)} vs ${hashWorld(b)}`); } { // Frame-rate independence: one 1/60 frame must equal exactly N substeps. const perFrame = Math.round((1 / 60) / PHYS.SUBSTEP_DT); const c = scene(); const d = scene(); for (let i = 0; i < 300; i += 1) { step(c, 1 / 60); for (let k = 0; k < perFrame; k += 1) substep(d, PHYS.SUBSTEP_DT); } check(`1/60 step == ${perFrame}x substep`, hashWorld(c) === hashWorld(d), `${hashWorld(c)} vs ${hashWorld(d)}`); } { // Ragged frame pacing: the accumulator only ever runs WHOLE substeps, so N // substeps reached through jittery frame times must be bit-identical to N // substeps reached evenly. (Total elapsed time can differ by up to one // substep's worth of carry — that leftover is the accumulator's whole job — // so the comparison is on substeps run, not on wall time.) const e = scene(); const f = scene(); const rng = mulberry32(99); let ran = 0; for (let i = 0; i < 400; i += 1) ran += step(e, 0.004 + rng() * 0.02); for (let i = 0; i < ran; i += 1) substep(f, PHYS.SUBSTEP_DT); check('ragged frame pacing matches even pacing', hashWorld(e) === hashWorld(f), `${ran} substeps: ${hashWorld(e)} vs ${hashWorld(f)}`); } { const src = scene(); for (let i = 0; i < 60; i += 1) substep(src, PHYS.SUBSTEP_DT); const before = hashWorld(src); const copy = cloneWorld(src); check('clone starts hash-identical', before === hashWorld(copy)); // Stepping the clone must not disturb the original by any route — including // through the body references cached on contacts. simulatePreview and the // winnability bot both depend on this being airtight. for (let i = 0; i < 120; i += 1) substep(copy, PHYS.SUBSTEP_DT); check('stepping a clone leaves the original untouched', hashWorld(src) === before, `${hashWorld(src)} vs ${before}`); check('clone diverges from a stationary original', hashWorld(copy) !== before); for (let i = 0; i < 120; i += 1) substep(src, PHYS.SUBSTEP_DT); check('clone and original converge when stepped equally', hashWorld(src) === hashWorld(copy), `${hashWorld(src)} vs ${hashWorld(copy)}`); } { // Sequential impulses are Gauss-Seidel, so solve order genuinely affects the // answer — bit-identical results across creation orders are NOT achievable // and not required. What matters is that the dependence stays sub-pixel, so // an author reordering blocks in the editor can't change whether a level // works. Exact reproducibility of a GIVEN scene is covered above. const build = (reverse) => { const w = withGround(createWorld()); const spec = []; for (let i = 0; i < 5; i += 1) spec.push({ x: 600, y: 770 - i * 40 }); const order = reverse ? [...spec].reverse() : spec; for (const s of order) addBox(w, { ...s, w: 60, h: 40, density: 1 }); return w; }; const fwd = build(false); const rev = build(true); for (let i = 0; i < 480; i += 1) { substep(fwd, PHYS.SUBSTEP_DT); substep(rev, PHYS.SUBSTEP_DT); } const fy = [...fwd.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b); const ry = [...rev.bodies].filter((b) => !b.isStatic).map((b) => b.y).sort((a, b) => a - b); const worst = Math.max(...fy.map((v, i) => Math.abs(v - ry[i]))); check('creation order shifts results by under 1px', worst < 1, `worst ${worst.toFixed(3)}px`); } // ── 10. Robustness monkey test ────────────────────────────────────────────── section('10. Robustness'); { const seeds = Number((process.argv.find((a) => a.startsWith('--seeds=')) ?? '--seeds=12').split('=')[1]); let bad = 0; let sank = 0; let overspeed = 0; for (let s = 0; s < seeds; s += 1) { const rng = mulberry32(1000 + s); const w = withGround(createWorld()); const bodies = []; for (let i = 0; i < 14; i += 1) { const b = rng() < 0.3 ? addCircle(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, r: 10 + rng() * 18, density: 0.5 + rng() }) : addBox(w, { x: 400 + rng() * 400, y: 300 + rng() * 400, w: 24 + rng() * 60, h: 24 + rng() * 60, angle: rng() * Math.PI, density: 0.5 + rng() }); bodies.push(b); } for (let i = 0; i < 900; i += 1) { if (i % 60 === 0) { const b = bodies[Math.floor(rng() * bodies.length)]; applyImpulse(b, (rng() - 0.5) * 4e5, (rng() - 0.5) * 4e5, b.x, b.y); } substep(w, PHYS.SUBSTEP_DT); for (const b of bodies) { if (!Number.isFinite(b.x) || !Number.isFinite(b.y) || !Number.isFinite(b.angle) || !Number.isFinite(b.vx) || !Number.isFinite(b.vy) || !Number.isFinite(b.omega)) bad += 1; if (Math.hypot(b.vx, b.vy) > PHYS.MAX_SPEED + 1e-6) overspeed += 1; if (b.y > 1000) sank += 1; } } } check(`no NaN across ${seeds} monkey seeds`, bad === 0, `${bad} non-finite samples`); check('nothing exceeds MAX_SPEED', overspeed === 0, `${overspeed} samples`); check('nothing sinks through the ground', sank === 0, `${sank} samples`); } { // Bodies must be removable mid-sim without leaving dangling contacts. const w = withGround(createWorld()); const boxes = []; for (let i = 0; i < 5; i += 1) boxes.push(addBox(w, { x: 600, y: 770 - i * 40, w: 60, h: 40, density: 1 })); for (let i = 0; i < 120; i += 1) substep(w, PHYS.SUBSTEP_DT); removeBody(w, boxes[2]); let threw = false; try { for (let i = 0; i < 480; i += 1) substep(w, PHYS.SUBSTEP_DT); } catch (e) { threw = true; } check('removing a mid-stack body is safe', !threw); check('stack recovers after a removal', boxes.filter((b, i) => i !== 2).every((b) => b.sleeping), `${boxes.filter((b, i) => i !== 2 && !b.sleeping).length} awake`); } { // contactImpulses is the damage signal the rules layer reads — a hard hit // must report a much larger impulse than a resting contact. const w = withGround(createWorld()); const target = addBox(w, { x: 600, y: 770, w: 60, h: 60, density: 1 }); settle(w, 5); substep(w, PHYS.SUBSTEP_DT); const resting = contactImpulses(w).get(target.id) ?? 0; const shot = addCircle(w, { x: 200, y: 740, r: 16, density: 6 }); shot.vx = 1800; let peak = 0; for (let i = 0; i < 240; i += 1) { substep(w, PHYS.SUBSTEP_DT); peak = Math.max(peak, contactImpulses(w).get(target.id) ?? 0); } check('impact impulse exceeds resting impulse', peak > resting * 3, `peak ${peak.toFixed(0)} vs resting ${resting.toFixed(0)}`); } // ── Summary ───────────────────────────────────────────────────────────────── console.log(`\n${passes} passed, ${failures} failed`); process.exit(failures ? 1 : 0);