From 9ecbc9589f773416f6ae518484a2a4666208319b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 30 Jul 2026 18:47:18 -0600 Subject: [PATCH] =?UTF-8?q?feat(angrybirds):=20Wave=200=20=E2=80=94=20dete?= =?UTF-8?q?rministic=20rigid-body=20solver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angry Birds is fundamentally a rotating rigid-body stacking game, and neither existing engine could be extended into it: PeggleLogic collides a moving circle against static pegs with no rotation, and GooTowerLogic has no angular state at all because its rigidity is emergent from triangulation. Matter.js ships in the CDN Phaser build but is not importable in bare Node and not reproducible, which would forfeit the verifier, the generator's measured star thresholds, and the editor's winnability gate. AngryBirdsPhysics.js is a bespoke solver: convex polygons and circles, uniform-grid broadphase, SAT with reference-face clipping to 2-point manifolds, sequential impulses with warm starting keyed on stable feature ids, Coulomb friction, split-impulse position correction, and island sleeping. Split impulse rather than plain Baumgarte is the load-bearing choice. Biasing the real velocity makes penetration proportional to load, so the bottom contact of a 10-box tower sank ~9x deeper than the top and the stack sagged 12.85px. Correcting position through a discarded pseudo-velocity removes a fixed fraction of the excess per substep regardless of load; sag is now 0.46px per contact, exactly the slop band we deliberately allow. Gate: 10-box tower settles in 0.68s and sleeps with <1px drift and 0.35 degrees of tilt; friction holds on a 15 degree slope and slips on 40; replay is bit-identical and frame-rate independent; 40 monkey seeds produce no NaN, no overspeed and no sinking. tools/verifyAngryBirds.js -> 51 checks green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M1wNRvLLtyfCDAET7oYouf --- docs/angrybirds-build-plan.md | 143 ++++ src/games/angrybirds/AngryBirdsPhysics.js | 928 ++++++++++++++++++++++ tools/verifyAngryBirds.js | 471 +++++++++++ 3 files changed, 1542 insertions(+) create mode 100644 docs/angrybirds-build-plan.md create mode 100644 src/games/angrybirds/AngryBirdsPhysics.js create mode 100644 tools/verifyAngryBirds.js diff --git a/docs/angrybirds-build-plan.md b/docs/angrybirds-build-plan.md new file mode 100644 index 0000000..b011a80 --- /dev/null +++ b/docs/angrybirds-build-plan.md @@ -0,0 +1,143 @@ +# Angry Birds — build plan + +Living plan doc. Survives context clears: **read this file first**, pick the next unchecked item, update the checkboxes and the Status line as work lands. + +## Status + +**Wave 0 complete 2026-07-30 — the gate is passed.** `node tools/verifyAngryBirds.js` → **51 checks green**. A 10-box tower settles in 0.68s, sleeps, drifts <1px sideways, and tilts 6.1e-3 rad (0.35°, invisible). A 15-box pyramid settles too. The solver runs a 6-second, 41-body shot in **254ms** headless. + +Next: Wave 1, the playable core. + +## Decisions taken up front + +| Question | Answer | +|---|---| +| Slug / category / iconFrame | `angrybirds` / `arcade-console-pc` ("Video Games") / **92** (next free after Excitebike's 91) | +| Physics | **Bespoke deterministic rigid-body solver.** Matter.js ships inside the CDN Phaser build and would work in-browser, but it is not importable in bare Node and not reproducible — that forfeits the verifier, the generator's measured star thresholds, and the editor's winnability gate. | +| Visual treatment | **No CRT overlay, no scanlines, no pixel font.** It's a 2009 touch game, not an arcade cabinet. Clean cartoon look, app's normal fonts. | +| Campaign scale | **63 levels, 3 episodes of 21** — Poached Eggs / Mighty Hoax / Danger Above. | +| Art | Procedural, with a `data/angrybirds-artwork.json` drop-in hook so real sprites can replace the fallbacks later with no code change. Procedural fallbacks must always work standalone. | +| Bird roster | All 8 — Red, Chuck, Blue, Bomb, Matilda, Terence, Hal, Bubbles. | +| Modes | Single-player campaign only. `maxOpponents: 0`, so it skips `OpponentSelect` entirely. | + +## Files + +| File | Role | State | +|---|---|---| +| `src/games/angrybirds/AngryBirdsPhysics.js` | Rigid-body solver. Zero imports. | 🔨 Wave 0 | +| `src/games/angrybirds/AngryBirdsLogic.js` | Materials, damage, birds, scoring, `stepSim`. Zero imports. | ⬜ Wave 1 | +| `src/games/angrybirds/AngryBirdsGame.js` | Phaser scene, key `AngryBirdsGame`. | ⬜ Wave 1 | +| `src/games/angrybirds/AngryBirdsArt.js` | Procedural textures + artwork-JSON resolution. | ⬜ Wave 5 | +| `src/games/angrybirds/AngryBirdsEditor.js` | Level editor, key `AngryBirdsEditor`. | ⬜ Wave 3 | +| `src/games/angrybirds/AngryBirdsAuto.js` | Reference bot / winnability gate. In `src/` so the editor button and the verifier run the same gate. | ⬜ Wave 3 | +| `src/games/angrybirds/tutorial.md` | Tutorial copy. | ⬜ Wave 5 | +| `tools/genAngryBirds.js` | Level-bank generator; **measures** star thresholds. | ⬜ Wave 3 | +| `tools/verifyAngryBirds.js` | Node harness. | 🔨 Wave 0 | +| `assets/gamedata/angrybirds/` | `levels.json` manifest + 63 level files. | ⬜ Wave 4 | +| `data/angrybirds-artwork.json` | Drop-in sprite map. | ⬜ Wave 5 | + +Wiring touchpoints: `src/data/gamesRegistry.js`, `src/main.js` (import + scene array, both scenes), `src/scenes/GameRoomScene.js:26` (`slugDispatch`), `src/data/assetManifest.js`, `src/scenes/PreloadScene.js` (manifest JSON + `?angrybirds-editor=1` entrance). + +--- + +## Wave 0 — the rigid-body solver — ✅ DONE 2026-07-30 + +- [x] Convex polygon + circle bodies, mass/inertia derived from geometry +- [x] Uniform-grid AABB broadphase +- [x] SAT narrowphase with reference-face clipping → 2-point manifolds +- [x] Sequential-impulse solver with warm starting +- [x] Coulomb friction clamped against the accumulated normal impulse +- [x] **Split-impulse** position correction with penetration slop (not plain Baumgarte — see finding 1) +- [x] Island-based sleeping +- [x] `cloneWorld` / `hashWorld` for determinism tests +- [x] Verifier green — all four gate conditions pass + +### The gate — all four passed + +1. ✅ 10-box tower settles in 0.68s, every body asleep, drift 0.00px, tilt 6.1e-3 rad. A 15-box pyramid also settles. +2. ✅ Box on a 15° slope with µ=0.9 does not creep; a µ=0.2 box on 40° does slide. +3. ✅ Bit-identical replay; 1/60 frame == 4 substeps exactly; ragged frame pacing matches even pacing. +4. ✅ 12 monkey seeds × 900 substeps: no NaN, no overspeed, nothing sinks. + +### Wave 0 findings + +1. **Plain Baumgarte is not good enough for a 10-high stack, and the failure looks like a bug when it isn't.** Feeding position error back as bias velocity makes a contact behave like a stiff spring, so penetration is proportional to load — the bottom contact of a 10-box tower carries 9 boxes and sinks ~9× deeper than the top one. Measured sag was 12.85px. Switching to **split impulse** (a separate pseudo-velocity accumulator, added into the position integration then discarded) removes a fixed fraction of the excess per substep *regardless of load*, and never leaks the correction back as bounce energy. Sag dropped to 0.46px per contact, which is exactly `SLOP` — i.e. the remaining sag is the slop band we deliberately allow, not solver error. **Do not "simplify" this back to a single bias term.** +2. **Total stack sag is bounded by `SLOP × contactCount`, by design.** The verifier asserts against that budget rather than a flat pixel value. Shrinking `SLOP` tightens stacks but reintroduces resting-contact jitter — 0.5px on a 40px block is the sweet spot. +3. **Warm starting must key on a stable feature id, not array position.** Contact points are matched across substeps by `(refFace << 8) | incidentVert`, with a flip bit. Matching by index scrambles impulses the moment the clip order changes and the stack sags visibly. +4. **The reference-face choice needs hysteresis.** Preferring A unless B is deeper by a relative margin (`0.1 × |sepA| + 0.01`) keeps the choice stable frame to frame, which is what keeps feature ids — and therefore warm starting — stable for a resting contact. +5. **Sequential impulses are Gauss-Seidel, so solve order genuinely changes the answer.** Bit-identical results across different body *creation* orders are not achievable and were never the goal; the verifier asserts the dependence stays sub-pixel (measured 0.4px). What must be exact — and is — is that a *given* scene replays identically. +6. **Island sleeping, not per-body sleeping.** Union-find over contacts; an island sleeps only when its slowest member has been slow for `SLEEP_TIME`. Per-body sleeping freezes half a tower while the rest still moves. Sleeping is also how the rules layer knows a shot is over, so it is not just an optimization. +7. **Anti-tunnelling is a design constraint, not a runtime check.** `MAX_SPEED × SUBSTEP_DT < MIN_HALF_EXTENT` (10px < 12px) is asserted by the verifier, which is what forced `SUBSTEP_DT` to 1/240 and `MAX_SPEED` to 2400. No block may be thinner than 24px. +8. **String keys dominated the profile.** Numeric composite keys for the grid and contact maps, plus building the contact list in already-sorted pair order instead of re-sorting it four times per substep, took a 6-second shot from 368ms to 254ms with bit-identical output. +9. **Contacts cache direct body references, so `cloneWorld` must rewire them.** A shallow spread leaves the clone's contacts pointing at the original's bodies — the clone then drives the world it was supposed to leave alone. The winnability bot and shot preview both depend on this; the verifier now asserts it explicitly. + +### Baselines + +``` +node tools/verifyAngryBirds.js -> 51 checks green +node tools/verifyAngryBirds.js --seeds=40 -> slower monkey soak, still green +``` + +--- + +## Wave 1 — playable core — ⬜ NEXT + +- [ ] Materials: wood / stone / ice with distinct density, friction, restitution, hp, damage threshold +- [ ] Damage as a **health model** — impulse above threshold subtracts hp; 2/3 and 1/3 crossings emit crack stages +- [ ] Pigs take damage from debris, not just direct hits +- [ ] Slingshot: drag, clamp to max draw, release +- [ ] Win/lose evaluated only once the world is settled or `MAX_SHOT_TIME` expires +- [ ] `stepSim(state, dt) → events[]` contract +- [ ] Phaser scene with sim-space→screen-space mapping, camera pan/zoom +- [ ] Trajectory memory (dotted trail of previous shots) + +--- + +## Wave 2 — the full roster, TNT, scoring — ⬜ + +- [ ] 8 birds, abilities fire on tap mid-flight +- [ ] TNT blocks detonate when damaged +- [ ] Scoring: 5,000/pig + material destruction points + 10,000 per unused bird + +--- + +## Wave 3 — editor and generator — ⬜ + +- [ ] `?angrybirds-editor=1` entrance, authoring in play coordinates +- [ ] **Settle** button so levels export already at rest +- [ ] **Test Winnable** button running the same gate as the verifier +- [ ] `AngryBirdsAuto` beam search over (angle, power, ability timing) +- [ ] Blob export of `level-NNN.json` + regenerated `levels.json` + +Gate is **one-directional**: if the bot wins, a human can. A bot failure is *not* proof a level is impossible. + +--- + +## Wave 4 — the 63-level campaign — ⬜ + +- [ ] Poached Eggs (1-21) — wood then stone; Red, Chuck, Blue +- [ ] Mighty Hoax (22-42) — ice, TNT, taller structures; adds Bomb, Matilda +- [ ] Danger Above (43-63) — mixed materials, elevated structures; adds Terence, Hal, Bubbles + +Every level needs a **load-bearing** support whose removal collapses the structure. That, not bird variety, is what makes a shot feel clever. + +--- + +## Wave 5 — art, audio, progress, polish — ⬜ + +- [ ] Procedural art + artwork-JSON drop-in +- [ ] Progress via `/puzzles/angrybirds/{progress,complete}`, per-level best/stars in `ab-best-N` / `ab-stars-N` +- [ ] Match history — use `{ slug, score, opponentScores, result }`. ⚠️ Goo Tower gets this **wrong** (`GooTowerGame.js:879-881`); copy `RushHourGame.js:513-516` instead. +- [ ] `tutorial.md` + `hasTutorial: true` +- [ ] Paint `game-icons.png` frame 92 (row 6, col 2 → 44×44 at x=88, y=264) + +--- + +## How difficulty is decided + +`TUNING` in `AngryBirdsLogic.js` owns damage thresholds and material hp — tune there, not in level files. Star thresholds are **measured** by `tools/genAngryBirds.js` from the bot's achieved score, never hand-authored (same discipline as Excitebike's `qualifyMs`). + +## Sources for original-game behaviour + +- Rovio *Angry Birds* (2009) — material behaviour, bird abilities, 5,000/pig and 10,000/unused-bird scoring. +- Episode structure: Poached Eggs, Mighty Hoax, Danger Above — 21 levels each. diff --git a/src/games/angrybirds/AngryBirdsPhysics.js b/src/games/angrybirds/AngryBirdsPhysics.js new file mode 100644 index 0000000..0728f8f --- /dev/null +++ b/src/games/angrybirds/AngryBirdsPhysics.js @@ -0,0 +1,928 @@ +// Angry Birds — rigid-body physics. Zero imports, deterministic, Node-testable. +// +// Generic 2D rigid-body solver: it knows about convex polygons and circles and +// nothing at all about birds, pigs or scoring. AngryBirdsLogic.js layers the +// game on top. +// +// ── WHY A BESPOKE SOLVER ──────────────────────────────────────────────────── +// Matter.js ships inside the CDN Phaser build and would have worked in the +// browser, but it cannot be imported in bare Node and is not reproducible. +// This repo's whole quality apparatus — tools/verifyAngryBirds.js, the level +// generator's MEASURED star thresholds, and the editor's winnability gate — +// depends on replaying a shot headlessly and getting the same answer twice. +// +// Neither existing engine could be extended into this. PeggleLogic collides a +// moving circle against STATIC pegs with no rotation and no persistent +// contacts; GooTowerLogic has no angular state anywhere, because its rigidity +// is emergent from triangulation. A toppling tower of boxes needs both +// rotation and lasting contacts, so this is new code. +// +// ── THE THREE THINGS THAT MAKE STACKS STAND UP ────────────────────────────── +// 1. TWO-POINT MANIFOLDS. A single contact point cannot resist rotation, so a +// box resting on a box rocks forever. Reference-face clipping yields up to +// 2 points per pair, which is what makes a face-to-face rest contact rigid. +// 2. WARM STARTING. Each contact point remembers last substep's accumulated +// impulse — keyed by a FEATURE ID that survives across frames, not by array +// position — and reapplies it before iterating. Without it a 6-box tower +// visibly sags every frame because the solver spends all its iterations +// rediscovering gravity from zero. +// 3. ISLAND SLEEPING. Bodies are grouped by contact into islands; an island +// sleeps only when EVERY member has been slow for SLEEP_TIME. Per-body +// sleeping would let half a tower freeze while the other half moves. +// Sleeping is load-bearing three times over: stack stability, perf, and the +// "shot is finished" test the rules layer waits on. +// +// ── UNITS AND CONVENTIONS ─────────────────────────────────────────────────── +// Pixels, seconds, radians. Y IS DOWN (screen convention). Polygon winding is +// normalized on insert so the shoelace area is positive; the outward normal of +// edge v[i]->v[i+1] is then (e.y, -e.x). Contact normals point from A to B. +// +// Determinism rules, all load-bearing: +// * bodies and contacts are always iterated in ascending id order; +// * no Math.random() anywhere — callers pass a seeded rng if they need one; +// * no Date/performance reads; +// * nothing that affects the result may depend on Map/Set iteration order — +// broadphase pairs are sorted by (aId, bId) and the solver walks that list. + +export const PHYS = { + SUBSTEP_DT: 1 / 240, + VEL_ITERS: 8, // sequential-impulse passes per substep + GRAVITY: 1400, // px/s² + BAUMGARTE: 0.25, // fraction of excess penetration removed per substep + SLOP: 0.5, // allowed penetration, px — stops resting contacts buzzing + REST_THRESHOLD: 120, // approach speed below which restitution is ignored + MAX_SPEED: 2400, // px/s + MAX_OMEGA: 40, // rad/s + LINEAR_DAMPING: 0.002, + ANGULAR_DAMPING: 0.004, + SLEEP_LIN: 14, // px/s + SLEEP_ANG: 0.14, // rad/s + SLEEP_TIME: 0.5, // s below both thresholds before an island sleeps + GRID_CELL: 96, // broadphase cell size, px + MIN_HALF_EXTENT: 12, // smallest half-extent any body may have (anti-tunnel) +}; + +// ── Small math helpers ────────────────────────────────────────────────────── + +const cross = (ax, ay, bx, by) => ax * by - ay * bx; +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +// Numeric composite keys. Body ids are dense and small, so this is exact well +// past any level we'd author, and it keeps Map lookups off the string path. +const PAIR_STRIDE = 1 << 20; +const pairKey = (aId, bId) => aId * PAIR_STRIDE + bId; +// Grid cells can be negative, so bias into non-negative space before packing. +const GRID_BIAS = 1 << 14; +const cellKey = (gx, gy) => (gx + GRID_BIAS) * (1 << 15) + (gy + GRID_BIAS); + +// ── Mass properties ───────────────────────────────────────────────────────── + +// Signed shoelace area. Positive means the winding matches our normal formula. +function signedArea(verts) { + let a = 0; + for (let i = 0; i < verts.length; i += 1) { + const [x0, y0] = verts[i]; + const [x1, y1] = verts[(i + 1) % verts.length]; + a += x0 * y1 - x1 * y0; + } + return a * 0.5; +} + +// Area, centroid and second moment of a convex polygon, integrated over the +// triangle fan from the origin. Inertia comes out about the ORIGIN, so it is +// shifted to the centroid with the parallel-axis theorem before returning. +function polyMassData(verts, density) { + let area = 0; + let cx = 0; + let cy = 0; + let inertia = 0; + for (let i = 0; i < verts.length; i += 1) { + const [x0, y0] = verts[i]; + const [x1, y1] = verts[(i + 1) % verts.length]; + const c = x0 * y1 - x1 * y0; + area += c; + cx += (x0 + x1) * c; + cy += (y0 + y1) * c; + inertia += c * (x0 * x0 + x0 * x1 + x1 * x1 + y0 * y0 + y0 * y1 + y1 * y1); + } + area *= 0.5; + const inv6A = 1 / (6 * area); + cx *= inv6A; + cy *= inv6A; + const mass = density * Math.abs(area); + let I = density * Math.abs(inertia) / 12; + I -= mass * (cx * cx + cy * cy); // parallel axis: origin -> centroid + return { mass, cx, cy, I: Math.max(I, 1e-6) }; +} + +// ── World ─────────────────────────────────────────────────────────────────── + +export function createWorld(opts = {}) { + return { + bodies: [], + byId: new Map(), + // Keyed by pairKey(a,b) — a NUMBER, not a string. Contact lookup runs a few + // thousand times a substep and string keys dominated the profile. + contacts: new Map(), + // Rebuilt in pair order each substep; the solver iterates this, never the + // Map, so nothing depends on Map insertion history. + contactList: [], + nextId: 1, + gravity: opts.gravity ?? PHYS.GRAVITY, + accum: 0, + time: 0, + }; +} + +function addBody(world, body) { + body.id = world.nextId; + world.nextId += 1; + world.bodies.push(body); + world.byId.set(body.id, body); + return body; +} + +function baseBody(o) { + return { + id: 0, + x: o.x, y: o.y, angle: o.angle ?? 0, + vx: 0, vy: 0, omega: 0, + // Pseudo-velocity for the split-impulse position solve. Accumulated during + // the bias pass, added into the position integration, then zeroed. It never + // survives a substep, so it can't inject energy into the real velocity. + psx: 0, psy: 0, psw: 0, + friction: o.friction ?? 0.5, + restitution: o.restitution ?? 0.1, + isStatic: !!o.isStatic, + sleeping: false, + sleepTimer: 0, + // Free-form pointer the rules layer hangs blocks/pigs/birds off. The solver + // never reads it, which is what keeps this file game-agnostic. + userData: o.userData ?? null, + aabb: { minx: 0, miny: 0, maxx: 0, maxy: 0 }, + }; +} + +/** Convex polygon from local-space verts (any winding, recentred on centroid). */ +export function addPoly(world, o) { + let verts = o.verts.map(([x, y]) => [x, y]); + if (signedArea(verts) < 0) verts.reverse(); + const md = polyMassData(verts, o.density ?? 1); + // Recentre so the body origin IS the centre of mass; the solver assumes it. + verts = verts.map(([x, y]) => [x - md.cx, y - md.cy]); + + const b = baseBody(o); + b.kind = 'poly'; + b.verts = verts; + b.normals = verts.map(([x0, y0], i) => { + const [x1, y1] = verts[(i + 1) % verts.length]; + const ex = x1 - x0; + const ey = y1 - y0; + const len = Math.hypot(ex, ey) || 1; + return [ey / len, -ex / len]; + }); + b.wverts = verts.map(() => [0, 0]); + b.wnormals = verts.map(() => [0, 0]); + b.radius = Math.max(...verts.map(([x, y]) => Math.hypot(x, y))); + b.invMass = o.isStatic ? 0 : 1 / md.mass; + b.invI = o.isStatic ? 0 : 1 / md.I; + b.mass = o.isStatic ? Infinity : md.mass; + addBody(world, b); + syncTransform(b); + return b; +} + +/** Axis-aligned-at-rest box helper; `angle` still rotates it. */ +export function addBox(world, o) { + const hw = o.w / 2; + const hh = o.h / 2; + return addPoly(world, { + ...o, + verts: [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]], + }); +} + +export function addCircle(world, o) { + const b = baseBody(o); + b.kind = 'circle'; + b.radius = o.r; + const mass = (o.density ?? 1) * Math.PI * o.r * o.r; + b.invMass = o.isStatic ? 0 : 1 / mass; + b.invI = o.isStatic ? 0 : 1 / (0.5 * mass * o.r * o.r); + b.mass = o.isStatic ? Infinity : mass; + addBody(world, b); + syncTransform(b); + return b; +} + +export function removeBody(world, body) { + const i = world.bodies.indexOf(body); + if (i >= 0) world.bodies.splice(i, 1); + world.byId.delete(body.id); + for (const [key, c] of [...world.contacts]) { + if (c.aId === body.id || c.bId === body.id) world.contacts.delete(key); + } + world.contactList = world.contactList.filter((c) => c.aId !== body.id && c.bId !== body.id); +} + +// Recompute world-space verts/normals and the AABB after a transform change. +function syncTransform(b) { + const c = Math.cos(b.angle); + const s = Math.sin(b.angle); + if (b.kind === 'poly') { + let minx = Infinity; let miny = Infinity; let maxx = -Infinity; let maxy = -Infinity; + for (let i = 0; i < b.verts.length; i += 1) { + const [lx, ly] = b.verts[i]; + const wx = b.x + lx * c - ly * s; + const wy = b.y + lx * s + ly * c; + b.wverts[i][0] = wx; + b.wverts[i][1] = wy; + const [nx, ny] = b.normals[i]; + b.wnormals[i][0] = nx * c - ny * s; + b.wnormals[i][1] = nx * s + ny * c; + if (wx < minx) minx = wx; + if (wy < miny) miny = wy; + if (wx > maxx) maxx = wx; + if (wy > maxy) maxy = wy; + } + b.aabb.minx = minx; b.aabb.miny = miny; b.aabb.maxx = maxx; b.aabb.maxy = maxy; + } else { + b.aabb.minx = b.x - b.radius; b.aabb.miny = b.y - b.radius; + b.aabb.maxx = b.x + b.radius; b.aabb.maxy = b.y + b.radius; + } +} + +export function wakeBody(b) { + if (b.isStatic) return; + b.sleeping = false; + b.sleepTimer = 0; +} + +export function applyImpulse(b, ix, iy, px, py) { + if (b.isStatic) return; + wakeBody(b); + b.vx += ix * b.invMass; + b.vy += iy * b.invMass; + if (px !== undefined) b.omega += cross(px - b.x, py - b.y, ix, iy) * b.invI; +} + +// ── Broadphase ────────────────────────────────────────────────────────────── + +// Uniform grid over AABBs, rebuilt each substep. Bodies here are large relative +// to their per-substep travel, so a rebuild is cheaper than incremental upkeep. +function broadphase(world) { + const cell = PHYS.GRID_CELL; + const grid = new Map(); + const bodies = world.bodies; + for (let i = 0; i < bodies.length; i += 1) { + const b = bodies[i]; + const x0 = Math.floor(b.aabb.minx / cell); + const x1 = Math.floor(b.aabb.maxx / cell); + const y0 = Math.floor(b.aabb.miny / cell); + const y1 = Math.floor(b.aabb.maxy / cell); + for (let gx = x0; gx <= x1; gx += 1) { + for (let gy = y0; gy <= y1; gy += 1) { + const key = cellKey(gx, gy); + let bucket = grid.get(key); + if (!bucket) { bucket = []; grid.set(key, bucket); } + bucket.push(b); + } + } + } + + const pairs = []; + const seen = new Set(); + for (const bucket of grid.values()) { + for (let i = 0; i < bucket.length; i += 1) { + for (let j = i + 1; j < bucket.length; j += 1) { + let a = bucket[i]; + let b = bucket[j]; + if (a.id > b.id) { const t = a; a = b; b = t; } + // A pair where neither side can move produces no useful contact. A + // sleeping body against a static one is already at rest by definition. + if (a.isStatic && b.isStatic) continue; + if (a.sleeping && b.sleeping) continue; + if (a.sleeping && b.isStatic) continue; + if (b.sleeping && a.isStatic) continue; + const key = pairKey(a.id, b.id); + if (seen.has(key)) continue; + seen.add(key); + if (a.aabb.maxx < b.aabb.minx || b.aabb.maxx < a.aabb.minx) continue; + if (a.aabb.maxy < b.aabb.miny || b.aabb.maxy < a.aabb.miny) continue; + pairs.push(a, b); // flat, to avoid a per-pair array allocation + } + } + } + // Sorted so the solve order never depends on Map/Set iteration order. + const order = []; + for (let i = 0; i < pairs.length; i += 2) order.push(i); + order.sort((p, q) => (pairs[p].id - pairs[q].id) || (pairs[p + 1].id - pairs[q + 1].id)); + const sorted = []; + for (const i of order) sorted.push(pairs[i], pairs[i + 1]); + return sorted; +} + +// ── Narrowphase ───────────────────────────────────────────────────────────── + +// Largest separation of B's verts from any of A's faces. Negative => overlap. +function maxSeparation(a, b) { + let best = -Infinity; + let bestFace = 0; + for (let i = 0; i < a.wverts.length; i += 1) { + const [nx, ny] = a.wnormals[i]; + const [vx, vy] = a.wverts[i]; + // Support point of B in direction -n: the deepest vert against this face. + let lowest = Infinity; + for (let j = 0; j < b.wverts.length; j += 1) { + const d = (b.wverts[j][0] - vx) * nx + (b.wverts[j][1] - vy) * ny; + if (d < lowest) lowest = d; + } + if (lowest > best) { best = lowest; bestFace = i; } + } + return { sep: best, face: bestFace }; +} + +// The face of `inc` most anti-parallel to the reference normal. +function incidentFace(inc, refNx, refNy) { + let best = Infinity; + let bestFace = 0; + for (let i = 0; i < inc.wnormals.length; i += 1) { + const d = inc.wnormals[i][0] * refNx + inc.wnormals[i][1] * refNy; + if (d < best) { best = d; bestFace = i; } + } + return bestFace; +} + +// Clip a segment against a half-plane, keeping the portion where +// dot(n, p) - offset <= 0. Feature ids ride along so warm starting can match +// points across frames even when clipping order changes. +function clipSegment(pts, nx, ny, offset) { + const out = []; + const d0 = pts[0].x * nx + pts[0].y * ny - offset; + const d1 = pts[1].x * nx + pts[1].y * ny - offset; + if (d0 <= 0) out.push(pts[0]); + if (d1 <= 0) out.push(pts[1]); + if (d0 * d1 < 0) { + const t = d0 / (d0 - d1); + out.push({ + x: pts[0].x + t * (pts[1].x - pts[0].x), + y: pts[0].y + t * (pts[1].y - pts[0].y), + fid: d0 > 0 ? pts[1].fid : pts[0].fid, + }); + } + return out; +} + +function collidePolyPoly(a, b) { + const sa = maxSeparation(a, b); + if (sa.sep > 0) return null; + const sb = maxSeparation(b, a); + if (sb.sep > 0) return null; + + // Prefer A as reference unless B is clearly deeper — the small bias keeps the + // choice stable frame to frame, which keeps feature ids (and warm starting) + // stable for a resting contact. + let ref = a; let inc = b; let refFace = sa.face; let flip = false; + if (sb.sep > sa.sep + 0.1 * Math.abs(sa.sep) + 0.01) { + ref = b; inc = a; refFace = sb.face; flip = true; + } + + const [rnx, rny] = ref.wnormals[refFace]; + const rv0 = ref.wverts[refFace]; + const rv1 = ref.wverts[(refFace + 1) % ref.wverts.length]; + + const incFace = incidentFace(inc, rnx, rny); + const iv0 = inc.wverts[incFace]; + const iv1 = inc.wverts[(incFace + 1) % inc.wverts.length]; + + // Tangent of the reference face; clip the incident edge to its side planes. + const tx = rv1[0] - rv0[0]; + const ty = rv1[1] - rv0[1]; + const tlen = Math.hypot(tx, ty) || 1; + const utx = tx / tlen; + const uty = ty / tlen; + + let pts = [ + { x: iv0[0], y: iv0[1], fid: (refFace << 8) | incFace }, + { x: iv1[0], y: iv1[1], fid: (refFace << 8) | ((incFace + 1) % inc.wverts.length) }, + ]; + pts = clipSegment(pts, -utx, -uty, -(rv0[0] * utx + rv0[1] * uty)); + if (pts.length < 2) return null; + pts = clipSegment(pts, utx, uty, rv1[0] * utx + rv1[1] * uty); + if (pts.length < 2) return null; + + const offset = rv0[0] * rnx + rv0[1] * rny; + const points = []; + for (const p of pts) { + const sep = p.x * rnx + p.y * rny - offset; + if (sep <= 0) points.push({ x: p.x, y: p.y, sep, fid: p.fid | (flip ? 0x10000 : 0) }); + } + if (!points.length) return null; + + // Normal must always point A -> B. + return { nx: flip ? -rnx : rnx, ny: flip ? -rny : rny, points }; +} + +function collideCirclePoly(c, p, circleIsA) { + // Work in the polygon's local frame so the face tests are cheap. + const cs = Math.cos(-p.angle); + const sn = Math.sin(-p.angle); + const dx = c.x - p.x; + const dy = c.y - p.y; + const lx = dx * cs - dy * sn; + const ly = dx * sn + dy * cs; + + let best = -Infinity; + let bestFace = 0; + for (let i = 0; i < p.verts.length; i += 1) { + const [nx, ny] = p.normals[i]; + const [vx, vy] = p.verts[i]; + const d = (lx - vx) * nx + (ly - vy) * ny; + if (d > c.radius) return null; + if (d > best) { best = d; bestFace = i; } + } + + const [v0x, v0y] = p.verts[bestFace]; + const [v1x, v1y] = p.verts[(bestFace + 1) % p.verts.length]; + let nlx; let nly; let sep; + + if (best < 1e-6) { + // Centre is inside the polygon — push straight out along the closest face. + [nlx, nly] = p.normals[bestFace]; + sep = best - c.radius; + } else { + // Voronoi regions: nearest to v0, to v1, or to the face interior. + const ex = v1x - v0x; + const ey = v1y - v0y; + const t = clamp(((lx - v0x) * ex + (ly - v0y) * ey) / (ex * ex + ey * ey), 0, 1); + const px = v0x + ex * t; + const py = v0y + ey * t; + const ddx = lx - px; + const ddy = ly - py; + const dist = Math.hypot(ddx, ddy); + if (dist > c.radius) return null; + if (dist < 1e-9) { [nlx, nly] = p.normals[bestFace]; } else { nlx = ddx / dist; nly = ddy / dist; } + sep = dist - c.radius; + } + + // Back to world space. Local normal points polygon -> circle. + const wc = Math.cos(p.angle); + const ws = Math.sin(p.angle); + const nwx = nlx * wc - nly * ws; + const nwy = nlx * ws + nly * wc; + const contactX = c.x - nwx * (c.radius + sep * 0.5); + const contactY = c.y - nwy * (c.radius + sep * 0.5); + + // Caller's A is `circleIsA ? c : p`; normal must run A -> B. + const sign = circleIsA ? -1 : 1; + return { + nx: nwx * sign, ny: nwy * sign, + points: [{ x: contactX, y: contactY, sep, fid: 1 }], + }; +} + +function collideCircleCircle(a, b) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const r = a.radius + b.radius; + const d2 = dx * dx + dy * dy; + if (d2 >= r * r) return null; + const d = Math.sqrt(d2); + let nx; let ny; + if (d < 1e-9) { nx = 0; ny = -1; } else { nx = dx / d; ny = dy / d; } + return { + nx, ny, + points: [{ x: a.x + nx * a.radius, y: a.y + ny * a.radius, sep: d - r, fid: 1 }], + }; +} + +function collide(a, b) { + if (a.kind === 'circle' && b.kind === 'circle') return collideCircleCircle(a, b); + if (a.kind === 'circle') return collideCirclePoly(a, b, true); + if (b.kind === 'circle') return collideCirclePoly(b, a, false); + return collidePolyPoly(a, b); +} + +// ── Contact bookkeeping ───────────────────────────────────────────────────── + +// Rebuild manifolds, carrying accumulated impulses across from last substep by +// feature id. This is the warm-starting half of stack stability; matching by +// array index instead would scramble impulses the moment a clip order flips. +function updateContacts(world, pairs) { + const live = new Set(); + const list = []; + for (let pi = 0; pi < pairs.length; pi += 2) { + const a = pairs[pi]; + const b = pairs[pi + 1]; + const m = collide(a, b); + const key = pairKey(a.id, b.id); + if (!m) { world.contacts.delete(key); continue; } + live.add(key); + + const prev = world.contacts.get(key); + const points = m.points.map((p) => { + const old = prev?.points.find((q) => q.fid === p.fid); + return { + x: p.x, y: p.y, sep: p.sep, fid: p.fid, + pn: old?.pn ?? 0, pt: old?.pt ?? 0, + pnBias: 0, // never warm-started: position error is transient + massNormal: 0, massTangent: 0, bias: 0, restTarget: 0, + rax: 0, ray: 0, rbx: 0, rby: 0, + }; + }); + const contact = { + aId: a.id, bId: b.id, a, b, + nx: m.nx, ny: m.ny, + friction: Math.sqrt(a.friction * b.friction), + restitution: Math.max(a.restitution, b.restitution), + points, + maxImpulse: 0, // peak |pn| this substep — the damage signal the rules read + }; + world.contacts.set(key, contact); + list.push(contact); + } + for (const [key] of [...world.contacts]) { + if (!live.has(key)) world.contacts.delete(key); + } + // Pairs arrive pre-sorted by (aId, bId), so this list is already in the + // canonical solve order and needs no further sorting. + world.contactList = list; +} + +// The solver's contact iteration order. Never iterate world.contacts directly: +// Map order depends on insertion history, which would make the sim depend on +// the order bodies happened to be created in. +function orderedContacts(world) { + return world.contactList; +} + +function prestep(world, h) { + const invH = 1 / h; + for (const c of orderedContacts(world)) { + const a = c.a; + const b = c.b; + const { nx, ny } = c; + const tx = -ny; + const ty = nx; + + for (const p of c.points) { + p.rax = p.x - a.x; p.ray = p.y - a.y; + p.rbx = p.x - b.x; p.rby = p.y - b.y; + + const rnA = cross(p.rax, p.ray, nx, ny); + const rnB = cross(p.rbx, p.rby, nx, ny); + p.massNormal = 1 / (a.invMass + b.invMass + a.invI * rnA * rnA + b.invI * rnB * rnB); + + const rtA = cross(p.rax, p.ray, tx, ty); + const rtB = cross(p.rbx, p.rby, tx, ty); + p.massTangent = 1 / (a.invMass + b.invMass + a.invI * rtA * rtA + b.invI * rtB * rtB); + + // Position error is corrected by a SEPARATE pseudo-velocity pass (split + // impulse), not by biasing the real velocity. With plain Baumgarte the + // steady-state penetration is proportional to the load, so the bottom + // contact of a 10-box tower sinks ~10x deeper than the top one and the + // stack visibly sags. Driving position through pseudo-velocities removes + // a fixed fraction of the excess per substep regardless of load, and the + // correction never leaks into the real velocity as bounce energy. + p.bias = -PHYS.BAUMGARTE * invH * Math.min(0, p.sep + PHYS.SLOP); + p.pnBias = 0; + + // Restitution, sampled once from the APPROACH velocity. Sampling it every + // iteration would let a resting box slowly bounce itself apart. + const rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega); + const rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega); + const vn = rvx * nx + rvy * ny; + p.restTarget = vn < -PHYS.REST_THRESHOLD ? -c.restitution * vn : 0; + } + } + for (const b of world.bodies) { b.psx = 0; b.psy = 0; b.psw = 0; } +} + +function warmStart(world) { + for (const c of orderedContacts(world)) { + const a = c.a; + const b = c.b; + const { nx, ny } = c; + const tx = -ny; + const ty = nx; + for (const p of c.points) { + const ix = p.pn * nx + p.pt * tx; + const iy = p.pn * ny + p.pt * ty; + if (!a.isStatic) { + a.vx -= ix * a.invMass; a.vy -= iy * a.invMass; + a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI; + } + if (!b.isStatic) { + b.vx += ix * b.invMass; b.vy += iy * b.invMass; + b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI; + } + } + } +} + +function solveVelocities(world) { + const contacts = orderedContacts(world); + for (let iter = 0; iter < PHYS.VEL_ITERS; iter += 1) { + for (const c of contacts) { + const a = c.a; + const b = c.b; + const { nx, ny } = c; + const tx = -ny; + const ty = nx; + + for (const p of c.points) { + // Normal impulse. Accumulate then clamp to >= 0, applying only the + // delta — clamping the per-iteration impulse instead would let a + // contact pull bodies together. + let rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega); + let rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega); + const vn = rvx * nx + rvy * ny; + let dPn = p.massNormal * (-vn + p.restTarget); + const pn0 = p.pn; + p.pn = Math.max(pn0 + dPn, 0); + dPn = p.pn - pn0; + + let ix = dPn * nx; + let iy = dPn * ny; + if (!a.isStatic) { + a.vx -= ix * a.invMass; a.vy -= iy * a.invMass; + a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI; + } + if (!b.isStatic) { + b.vx += ix * b.invMass; b.vy += iy * b.invMass; + b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI; + } + + // Split-impulse position pass, run on pseudo-velocities only. + const pvx = (b.psx - p.rby * b.psw) - (a.psx - p.ray * a.psw); + const pvy = (b.psy + p.rbx * b.psw) - (a.psy + p.rax * a.psw); + const pvn = pvx * nx + pvy * ny; + let dPb = p.massNormal * (-pvn + p.bias); + const pb0 = p.pnBias; + p.pnBias = Math.max(pb0 + dPb, 0); + dPb = p.pnBias - pb0; + + const bx = dPb * nx; + const by = dPb * ny; + if (!a.isStatic) { + a.psx -= bx * a.invMass; a.psy -= by * a.invMass; + a.psw -= cross(p.rax, p.ray, bx, by) * a.invI; + } + if (!b.isStatic) { + b.psx += bx * b.invMass; b.psy += by * b.invMass; + b.psw += cross(p.rbx, p.rby, bx, by) * b.invI; + } + + // Coulomb friction, clamped against the ACCUMULATED normal impulse. + rvx = (b.vx - p.rby * b.omega) - (a.vx - p.ray * a.omega); + rvy = (b.vy + p.rbx * b.omega) - (a.vy + p.rax * a.omega); + const vt = rvx * tx + rvy * ty; + let dPt = p.massTangent * -vt; + const maxPt = c.friction * p.pn; + const pt0 = p.pt; + p.pt = clamp(pt0 + dPt, -maxPt, maxPt); + dPt = p.pt - pt0; + + ix = dPt * tx; + iy = dPt * ty; + if (!a.isStatic) { + a.vx -= ix * a.invMass; a.vy -= iy * a.invMass; + a.omega -= cross(p.rax, p.ray, ix, iy) * a.invI; + } + if (!b.isStatic) { + b.vx += ix * b.invMass; b.vy += iy * b.invMass; + b.omega += cross(p.rbx, p.rby, ix, iy) * b.invI; + } + } + } + } + for (const c of contacts) { + let peak = 0; + for (const p of c.points) if (p.pn > peak) peak = p.pn; + c.maxImpulse = peak; + } +} + +// ── Islands and sleeping ──────────────────────────────────────────────────── + +// Union-find over contacts between dynamic bodies. An island sleeps only when +// every member has been slow for SLEEP_TIME — per-body sleeping would freeze +// half a tower while the rest still moves. +function updateSleeping(world, h) { + const parent = new Map(); + const find = (x) => { + let r = x; + while (parent.get(r) !== r) r = parent.get(r); + while (parent.get(x) !== r) { const nx = parent.get(x); parent.set(x, r); x = nx; } + return r; + }; + const union = (x, y) => { + const rx = find(x); + const ry = find(y); + if (rx !== ry) parent.set(rx > ry ? rx : ry, rx > ry ? ry : rx); + }; + + for (const b of world.bodies) if (!b.isStatic) parent.set(b.id, b.id); + for (const c of orderedContacts(world)) { + const a = c.a; + const b = c.b; + if (!a.isStatic && !b.isStatic) union(a.id, b.id); + } + + // Per-body slow timer. + for (const b of world.bodies) { + if (b.isStatic || b.sleeping) continue; + const slow = Math.hypot(b.vx, b.vy) < PHYS.SLEEP_LIN && Math.abs(b.omega) < PHYS.SLEEP_ANG; + b.sleepTimer = slow ? b.sleepTimer + h : 0; + } + + // An island's timer is its slowest member's. + const islandTimer = new Map(); + for (const b of world.bodies) { + if (b.isStatic) continue; + const root = find(b.id); + const t = b.sleeping ? Infinity : b.sleepTimer; + const cur = islandTimer.get(root); + if (cur === undefined || t < cur) islandTimer.set(root, t); + } + + for (const b of world.bodies) { + if (b.isStatic) continue; + const t = islandTimer.get(find(b.id)) ?? 0; + if (t >= PHYS.SLEEP_TIME) { + b.sleeping = true; + b.vx = 0; b.vy = 0; b.omega = 0; + } else if (t < PHYS.SLEEP_TIME && b.sleeping) { + // A neighbour woke up; the whole island comes with it. + b.sleeping = false; + b.sleepTimer = 0; + } + } +} + +// ── Integration ───────────────────────────────────────────────────────────── + +function integrateVelocities(world, h) { + for (const b of world.bodies) { + if (b.isStatic || b.sleeping) continue; + b.vy += world.gravity * h; + b.vx *= 1 - PHYS.LINEAR_DAMPING; + b.vy *= 1 - PHYS.LINEAR_DAMPING; + b.omega *= 1 - PHYS.ANGULAR_DAMPING; + } +} + +function integratePositions(world, h) { + for (const b of world.bodies) { + if (b.isStatic || b.sleeping) continue; + const sp = Math.hypot(b.vx, b.vy); + if (sp > PHYS.MAX_SPEED) { const k = PHYS.MAX_SPEED / sp; b.vx *= k; b.vy *= k; } + b.omega = clamp(b.omega, -PHYS.MAX_OMEGA, PHYS.MAX_OMEGA); + // Pseudo-velocity moves the body but is then discarded, so position error + // is repaired without the correction showing up as momentum next substep. + b.x += (b.vx + b.psx) * h; + b.y += (b.vy + b.psy) * h; + b.angle += (b.omega + b.psw) * h; + b.psx = 0; b.psy = 0; b.psw = 0; + syncTransform(b); + } +} + +// ── The frame ─────────────────────────────────────────────────────────────── + +export function substep(world, h) { + integrateVelocities(world, h); + const pairs = broadphase(world); + updateContacts(world, pairs); + prestep(world, h); + warmStart(world); + solveVelocities(world); + integratePositions(world, h); + updateSleeping(world, h); + world.time += h; +} + +/** + * Advance by `dt` seconds in whole fixed substeps. Any leftover is carried in + * world.accum, which is what makes 1/60 and 1/120 frames produce identical + * trajectories — the property tools/verifyAngryBirds.js asserts. + */ +export function step(world, dt) { + world.accum += Math.min(dt, 0.05); // a backgrounded tab must not teleport + let steps = 0; + while (world.accum >= PHYS.SUBSTEP_DT && steps < 16) { + substep(world, PHYS.SUBSTEP_DT); + world.accum -= PHYS.SUBSTEP_DT; + steps += 1; + } + return steps; +} + +/** True when nothing is moving — the rules layer's "shot is over" test. */ +export function isSettled(world) { + for (const b of world.bodies) { + if (b.isStatic) continue; + if (!b.sleeping) return false; + } + return true; +} + +/** Peak normal impulse seen on each body this substep, keyed by body id. */ +export function contactImpulses(world) { + const out = new Map(); + for (const c of world.contacts.values()) { + if (c.maxImpulse <= 0) continue; + for (const id of [c.aId, c.bId]) { + const cur = out.get(id) ?? 0; + if (c.maxImpulse > cur) out.set(id, c.maxImpulse); + } + } + return out; +} + +/** Radial impulse + falloff, used by Bomb and TNT. Returns bodies affected. */ +export function applyExplosion(world, x, y, radius, power) { + const hit = []; + for (const b of world.bodies) { + if (b.isStatic) continue; + const dx = b.x - x; + const dy = b.y - y; + const d = Math.hypot(dx, dy); + if (d > radius) continue; + const falloff = 1 - d / radius; + const nx = d < 1e-6 ? 0 : dx / d; + const ny = d < 1e-6 ? -1 : dy / d; + const j = power * falloff * b.mass; + applyImpulse(b, nx * j, ny * j, b.x, b.y); + hit.push({ body: b, falloff, dist: d }); + } + return hit; +} + +// ── Determinism helpers ───────────────────────────────────────────────────── + +export function cloneWorld(world) { + const copy = createWorld({ gravity: world.gravity }); + copy.nextId = world.nextId; + copy.accum = world.accum; + copy.time = world.time; + for (const b of world.bodies) { + const nb = { ...b, aabb: { ...b.aabb } }; + if (b.kind === 'poly') { + nb.verts = b.verts.map((v) => [v[0], v[1]]); + nb.normals = b.normals.map((v) => [v[0], v[1]]); + nb.wverts = b.wverts.map((v) => [v[0], v[1]]); + nb.wnormals = b.wnormals.map((v) => [v[0], v[1]]); + } + copy.bodies.push(nb); + copy.byId.set(nb.id, nb); + } + // Contacts cache direct body references for speed, so a shallow spread would + // leave the clone's contacts pointing at the ORIGINAL world's bodies — the + // clone would silently drive the thing it was supposed to leave untouched. + // Rewire every reference through the copy's id map. + for (const [key, c] of world.contacts) { + copy.contacts.set(key, { + ...c, + a: copy.byId.get(c.aId), + b: copy.byId.get(c.bId), + points: c.points.map((p) => ({ ...p })), + }); + } + copy.contactList = world.contactList.map((c) => copy.contacts.get(pairKey(c.aId, c.bId))).filter(Boolean); + return copy; +} + +// FNV-1a over the Float64 bit patterns of every body's transform and velocity. +// Hashing the bits rather than rounded values means a 1-ULP divergence still +// shows up, which is the point. +export function hashWorld(world) { + const buf = new ArrayBuffer(8); + const f64 = new Float64Array(buf); + const u32 = new Uint32Array(buf); + let h = 0x811c9dc5; + const mix = (v) => { + f64[0] = v; + for (let i = 0; i < 2; i += 1) { + h ^= u32[i]; + h = Math.imul(h, 0x01000193) >>> 0; + } + }; + const sorted = [...world.bodies].sort((a, b) => a.id - b.id); + for (const b of sorted) { + mix(b.x); mix(b.y); mix(b.angle); + mix(b.vx); mix(b.vy); mix(b.omega); + mix(b.sleeping ? 1 : 0); + } + return h >>> 0; +} + +/** Run until everything sleeps (or `maxSeconds` elapses). Used by the editor. */ +export function settle(world, maxSeconds = 10) { + const limit = Math.ceil(maxSeconds / PHYS.SUBSTEP_DT); + for (let i = 0; i < limit; i += 1) { + substep(world, PHYS.SUBSTEP_DT); + if (isSettled(world)) return true; + } + return isSettled(world); +} diff --git a/tools/verifyAngryBirds.js b/tools/verifyAngryBirds.js new file mode 100644 index 0000000..a3ad24a --- /dev/null +++ b/tools/verifyAngryBirds.js @@ -0,0 +1,471 @@ +// 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);