From 3fd58110f4d70a0d6752994a887b16ec24e78dfa Mon Sep 17 00:00:00 2001 From: m3ultra Date: Tue, 28 Jul 2026 21:19:48 +1000 Subject: [PATCH] VSEPR geometry and a solvent: molecules become things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It needs more detail" was not a pixel problem. A molecule was a set of atoms at whatever angles the storm left them — three circles near each other, never a thing — and the room was a vacuum with objects in it. VSEPR. Bond springs only ever fixed bond LENGTH. Angular springs now splay the bonds around each atom as far apart as they can go, which is what produces the famous silhouettes: water bent at 104.5, CO2 a straight bar, methane a caltrop, a closed carbon ring a hexagon. Two bonds is where lone pairs matter, so O/N/S carry an explicit `bend`; three or more is just even angular spacing, which lands trigonal at 120 and the caltrop at 90 for free. The springs are deliberately weak (ANGLE_SPRING is ~15% of BOND_SPRING) so geometry loses to the storm, to a blast and to Molly's hands. A rigid molecule that ignores being hit reads as fake instantly. They also needed DAMPING, which was the real bug. Undamped, the mean angle measured correct while water actually swung through a 61 degree range — a broken hinge, not a molecule. Damping the tangential relative velocity (as bondSprings already does radially) cuts the spread from sd 16.5 to 1.3. Two measurement traps worth recording, since both nearly sent me the wrong way: a single-frame angle reading is meaningless in a Brownian storm and showed water at 58 degrees when its mean was 105; and starting a two-arm molecule at exactly 180 degrees is a degenerate equilibrium where the cross product vanishes and it cannot pick a side to fold toward, which looked exactly like a systematic bias. THE SOLVENT. 600 motes advected by the Coulomb field the sim already computes. Deliberately NOT particles: no charge, no valence, no collisions, nothing can touch them, so 600 cost 0.16ms where 116 real atoms cost ~5ms. They carry what the atoms cannot — you see the field move before anything charged does. Drawn under everything and never additive, because 600 additive motes would stack into a wash that eats the element hues and the radicals' white. 13 new tests in test/geometry.test.mjs; 81 passing. Co-Authored-By: Claude Opus 5 --- README.md | 12 +++- src/config.js | 18 ++++++ src/elements.js | 15 ++++- src/particle.js | 2 + src/physics.js | 77 +++++++++++++++++++++++ src/render.js | 3 + src/solvent.js | 107 ++++++++++++++++++++++++++++++++ src/world.js | 4 ++ test/geometry.test.mjs | 138 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 src/solvent.js create mode 100644 test/geometry.test.mjs diff --git a/README.md b/README.md index 667f9b0..b093ed5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ slip through a membrane. wrestling two together — radical pairs recombine barrierlessly, so it's fast. - **Two radicals that drift into each other quench on their own.** Herding is a legitimate tactic. +- **Molecules have real shapes.** Bonds splay as far apart as they can, so + water sits visibly bent at 104.5°, CO₂ is a straight bar, methane is a + caltrop and a closed carbon ring is a hexagon. The geometry is a tendency, + not a rigid body — a blast deforms it and it relaxes back. +- **The room is a medium, not a vacuum.** 600 solvent motes drift on the same + Coulomb field the atoms feel. They can't be touched and nothing interacts + with them; they're there so you can *see* the field move before anything + charged does. ## Structure @@ -73,7 +81,8 @@ streak that lapses after three quiet seconds. No label, no praise, no record. ``` src/config.js ALL feel constants. Tune the game here. -src/physics.js storm, charge, the activation barrier, zones, membrane +src/physics.js storm, charge, the activation barrier, VSEPR, zones, membrane +src/solvent.js 600 non-interactive motes advected by the field src/bonds.js the wrestle — grab, squeeze, SNAP src/bolts.js laserhands src/radicals.js the enemy @@ -91,6 +100,7 @@ node test/sim.test.mjs core physics, radicals, molly, regression node test/arena.test.mjs arena waves, win/lose, difficulty ramp node test/overload.test.mjs fuses, cascade, juice budget, perf node test/firebreak.test.mjs saturation, homolysis, the score, arena both-ends +node test/geometry.test.mjs VSEPR shapes, angular damping, the solvent ``` The sim is seedable (`seedRandom`), so runs are reproducible. Tests drive the diff --git a/src/config.js b/src/config.js index f1564a7..c783241 100644 --- a/src/config.js +++ b/src/config.js @@ -66,6 +66,16 @@ export const CFG = { BOND_SPRING: 560, BOND_DAMP: 16, BOND_BREAK_DIST: 74, + // VSEPR angular springs — bond ANGLE, where bond length is BOND_SPRING. + // Deliberately weak (~15% of BOND_SPRING): geometry must lose to the storm, + // to a blast and to Molly's hands. A rigid molecule that ignores being hit + // reads as fake instantly; a molecule that relaxes back into shape reads + // as chemistry. + ANGLE_SPRING: 85, + // Damps the TANGENTIAL relative velocity. Without it the spring is undamped + // and the storm pumps it — the mean angle comes out right while the molecule + // visibly flaps, which is worse than no geometry at all. + ANGLE_DAMP: 26, // ---- snap payoff ---- SNAP_TRAUMA: 0.62, @@ -173,6 +183,14 @@ export const CFG = { MAX_ROT: 2 * Math.PI / 180, TRAUMA_DECAY: 1.5, + // ---- the solvent ---- + // Non-interactive motes advected by the field. Not particles: no charge, + // no valence, no collisions, nothing can touch them. That is what makes + // 600 affordable when 116 real atoms already cost ~5ms. + SOLVENT_N: 600, + SOLVENT_REACH: 260, + SOLVENT_PUSH: 5200, // 1/r, not 1/r^2 — see solvent.js + // ---- palette ---- COL_POS: '#FF2D9B', COL_NEG: '#2DB8FF', diff --git a/src/elements.js b/src/elements.js index 5427860..3cb2261 100644 --- a/src/elements.js +++ b/src/elements.js @@ -8,14 +8,23 @@ // THE LAW: element owns the FILL. charge owns the EDGE and the LIGHT. // ============================================================ +// `bend` = the ideal angle between two bonds on this atom, in DEGREES. +// Only consulted when the atom has exactly two bonds; with three or more the +// geometry is just even angular spacing (3 -> 120 trigonal, 4 -> 90 caltrop), +// which lands benzene and methane on the right silhouettes for free. +// +// Two bonds is where the lone pairs matter and where the shapes are famous: +// water is visibly BENT at 104.5 and CO2 is a straight bar, and those two +// reading differently at a glance is the whole point. Carbon has no lone +// pairs, so it defaults to linear. export const ELEMENTS = { H: { col: '#EAF4FF', slots: 1, r: 7, mass: 0.4, name: 'hydrogen' }, C: { col: '#9B8CFF', slots: 4, r: 13, mass: 1.2, name: 'carbon' }, - N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen' }, - O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen' }, + N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen', bend: 107 }, + O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen', bend: 104.5 }, Na: { col: '#FF9500', slots: 1, r: 14, mass: 1.8, name: 'sodium' }, Cl: { col: '#D8FF3B', slots: 1, r: 13, mass: 2.0, name: 'chlorine' }, - S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur' }, + S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur', bend: 92 }, Mg: { col: '#D9DEE6', slots: 2, r: 13, mass: 1.6, name: 'magnesium' }, Fe: { col: '#9FB4C4', slots: 3, r: 15, mass: 3.0, name: 'iron' }, Cu: { col: '#23D9C0', slots: 2, r: 14, mass: 2.8, name: 'copper' }, diff --git a/src/particle.js b/src/particle.js index 312ae93..e08929c 100644 --- a/src/particle.js +++ b/src/particle.js @@ -30,6 +30,8 @@ export class Particle { this.mass = o.mass ?? (E ? E.mass : 1); this.spin = random() * Math.PI * 2; this.flash = 0; + // ideal angle between two bonds on this atom, radians. see elements.js + this.bend = E && E.bend ? (E.bend * Math.PI) / 180 : 0; } get freeSlots() { return Math.max(0, this.slots - this.bonds.length); } diff --git a/src/physics.js b/src/physics.js index 59ffdfa..ec8f23e 100644 --- a/src/physics.js +++ b/src/physics.js @@ -194,6 +194,82 @@ function bondSprings(world, dt) { } } +// ---- 5b. VSEPR: molecules have REAL SHAPES --------------------------- +// Bond springs alone only fix bond LENGTH, so a molecule was a set of atoms +// at whatever angles the storm happened to leave them — three circles near +// each other, never a thing. Valence-shell repulsion says the bonds around +// an atom splay as far apart as they can, and that is what gives the famous +// silhouettes: water bent at 104.5, CO2 a straight bar, methane a caltrop, +// benzene a hexagon. +// +// Weak on purpose (ANGLE_SPRING is a fraction of BOND_SPRING). These springs +// have to lose to the storm, to a blast and to Molly's hands — geometry is a +// tendency the molecule relaxes into, not a rigid body. Crank it up and +// molecules stop reacting to being hit, which reads as fake immediately. +function idealAngle(c, n) { + // two bonds is where lone pairs matter and the shapes are recognisable + if (n === 2 && c.bend) return c.bend; + // otherwise splay evenly: 3 -> 120 (trigonal/benzene), 4 -> 90 (caltrop) + return (Math.PI * 2) / n; +} + +function angleSprings(world, dt) { + const k = CFG.ANGLE_SPRING; + for (const c of world.particles) { + const n = c.bonds.length; + if (n < 2) continue; + const ideal = idealAngle(c, n); + + // order the neighbours by bearing so "adjacent" means adjacent. Applying + // the ideal to EVERY pair is wrong past three bonds: with four arms the + // adjacent pairs want 90 but the opposite pairs sit at 180, and forcing + // 90 on those folds the caltrop in on itself. + const nb = c.bonds + .map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) })) + .sort((p, q) => p.a - q.a); + + // with two bonds the "adjacent pairs" are the same pair twice + const pairs = n === 2 ? 1 : n; + for (let i = 0; i < pairs; i++) { + const A = nb[i].b, B = nb[(i + 1) % n].b; + const ax = A.x - c.x, ay = A.y - c.y; + const bx = B.x - c.x, by = B.y - c.y; + const la = Math.hypot(ax, ay) || 1e-4, lb = Math.hypot(bx, by) || 1e-4; + + const cross = ax * by - ay * bx; + const dot = ax * bx + ay * by; + const theta = Math.abs(Math.atan2(cross, dot)); // 0..PI + const err = ideal - theta; // >0 => splay further + if (Math.abs(err) < 0.01) continue; + + // push each neighbour along its own tangent, in the direction that + // opens the angle; the centre takes the reaction so momentum holds. + const s = cross >= 0 ? 1 : -1; + const f = k * err; + + // unit tangents — the direction each neighbour travels to open the angle + const tax = (ay / la) * s, tay = (-ax / la) * s; + const tbx = (-by / lb) * s, tby = (bx / lb) * s; + + // ANGULAR DAMPING. Without this the spring is undamped and the Brownian + // storm pumps it: the mean angle is correct but water FLAPS between 63 + // and 132 degrees, which reads as a broken hinge rather than a bent + // molecule. bondSprings damps its radial velocity for exactly this + // reason; the angular spring needs the tangential equivalent. + const dva = (A.vx - c.vx) * tax + (A.vy - c.vy) * tay; + const dvb = (B.vx - c.vx) * tbx + (B.vy - c.vy) * tby; + const damp = CFG.ANGLE_DAMP; + + const fax = tax * f - tax * dva * damp, fay = tay * f - tay * dva * damp; + const fbx = tbx * f - tbx * dvb * damp, fby = tby * f - tby * dvb * damp; + + A.addForce(fax, fay, dt); + B.addForce(fbx, fby, dt); + c.addForce(-(fax + fbx), -(fay + fby), dt); + } + } +} + // ---- 6. collisions --------------------------------------------------- function collide(world) { const ps = world.particles; @@ -263,6 +339,7 @@ export function step(world, dt) { world.bondsSystem.update(world, dt); // hands + barrier + snap membranes(world, dt); bondSprings(world, dt); + angleSprings(world, dt); collide(world); integrate(world, dt); } diff --git a/src/render.js b/src/render.js index 9453a76..1a66422 100644 --- a/src/render.js +++ b/src/render.js @@ -435,6 +435,9 @@ export function render(ctx, world, camera) { // ---- the ground is a MEDIUM, not a void: the field grid ---- drawField(ctx, world); + // ---- and so is the air. Under everything, so it reads as depth. ---- + world.solvent.draw(ctx); + // ---- zone washes ---- for (const z of world.zones) { ctx.fillStyle = z.type === 'sour' ? CFG.TINT_SOUR diff --git a/src/solvent.js b/src/solvent.js new file mode 100644 index 0000000..944b015 --- /dev/null +++ b/src/solvent.js @@ -0,0 +1,107 @@ +import { CFG } from './config.js'; +import { rand, random, rgba } from './util.js'; + +// ============================================================ +// THE SOLVENT. +// +// The room used to be a vacuum with objects in it. Real matter at this +// scale is not sparse — it is crowded, and everything that happens +// happens THROUGH a medium. The field grid made the ground a medium; +// this makes the air one. +// +// These motes are deliberately NOT particles. They have no charge, no +// valence, no collisions, no bonds, and nothing can interact with them — +// they are advected by the Coulomb field the sim already computes, and +// that is all. That is what makes 600 of them affordable when 116 real +// atoms already cost ~5ms: no O(n^2) anything, one pass, one path, one +// fill per layer. +// +// They earn their place by carrying information the atoms cannot: you can +// SEE the field move before anything charged does, a detonation shockwave +// becomes visible as it crosses the room, and a dense pocket of matter is +// legible as a region where the medium is being dragged around. +// ============================================================ + +const MAX_SPEED = 130; + +export class Solvent { + constructor() { this.motes = []; } + + reset(bounds, n = CFG.SOLVENT_N) { + this.motes.length = 0; + for (let i = 0; i < n; i++) { + this.motes.push({ + x: bounds.x + random() * bounds.w, + y: bounds.y + random() * bounds.h, + vx: 0, vy: 0, + // three depth bands: far motes are smaller, dimmer and lag the + // field, which reads as parallax without a second camera + z: rand(0.35, 1), + tw: random() * Math.PI * 2, + }); + } + } + + update(world, dt) { + const B = world.bounds; + // gather charged bodies ONCE — this is the only thing that costs, and + // it is linear in atoms, not in motes + const src = []; + for (const p of world.particles) if (p.charge) src.push(p); + if (world.molly.charge) src.push(world.molly); + + const R2 = CFG.SOLVENT_REACH * CFG.SOLVENT_REACH; + const damp = Math.pow(0.04, dt); + + for (const m of this.motes) { + let ax = 0, ay = 0; + for (const p of src) { + const dx = m.x - p.x, dy = m.y - p.y; + const d2 = dx * dx + dy * dy; + if (d2 > R2 || d2 < 4) continue; + const d = Math.sqrt(d2); + // 1/r falloff, not 1/r^2 — the motes are here to SHOW the field's + // shape at a distance, and an inverse-square drops off so fast that + // only the motes already touching an atom would ever move. + const f = (CFG.SOLVENT_PUSH * p.charge) / d; + ax += (dx / d) * f; + ay += (dy / d) * f; + } + m.vx = (m.vx + ax * dt) * damp; + m.vy = (m.vy + ay * dt) * damp; + + const sp = Math.hypot(m.vx, m.vy); + if (sp > MAX_SPEED) { const k = MAX_SPEED / sp; m.vx *= k; m.vy *= k; } + + m.x += m.vx * dt * m.z; + m.y += m.vy * dt * m.z; + m.tw += dt * 1.7; + + // wrap, so the medium is continuous and never depletes at the edges + if (m.x < B.x) m.x += B.w; else if (m.x > B.x + B.w) m.x -= B.w; + if (m.y < B.y) m.y += B.h; else if (m.y > B.y + B.h) m.y -= B.h; + } + } + + // Drawn UNDER everything, dim, in two batched passes. Never additive: + // 600 additive motes stack into a grey wash that eats the element hues + // and the radicals' white, which is the one thing the colour law will + // not tolerate. + draw(ctx) { + for (const [zmin, alpha, size] of [[0, 0.16, 0.9], [0.7, 0.30, 1.4]]) { + ctx.beginPath(); + let any = false; + for (const m of this.motes) { + if (m.z < zmin || (zmin === 0 && m.z >= 0.7)) continue; + // a slow twinkle so the medium never looks like dead static + const r = size * (0.75 + Math.sin(m.tw) * 0.25); + ctx.moveTo(m.x + r, m.y); + ctx.arc(m.x, m.y, r, 0, Math.PI * 2); + any = true; + } + if (!any) continue; + ctx.fillStyle = rgba('#8FA8C8', alpha); + ctx.fill(); + } + } +} diff --git a/src/world.js b/src/world.js index 60390aa..39bd116 100644 --- a/src/world.js +++ b/src/world.js @@ -6,6 +6,7 @@ import { Bolts } from './bolts.js'; import { Juice } from './juice.js'; import { Radicals } from './radicals.js'; import { Overload } from './overload.js'; +import { Solvent } from './solvent.js'; import { Particle, unbond } from './particle.js'; import { FX } from './fx.js'; import { Audio } from './audio.js'; @@ -23,6 +24,7 @@ export class World { this.bolts = new Bolts(); this.radicals = new Radicals(); this.overload = new Overload(); + this.solvent = new Solvent(); this.exit = null; this.exitOpen = false; this.onSnap = null; @@ -55,6 +57,7 @@ export class World { this.bolts.reset(); this.radicals.reset(); this.overload.reset(); + this.solvent.reset(this.bounds); for (const p of this.particles) { p.load = 0; p.fuse = 0; } this.molly.revive(); this.kills = 0; @@ -189,6 +192,7 @@ export class World { this.bolts.update(this, dt); this.radicals.update(this, dt); this.overload.update(this, dt); + this.solvent.update(this, dt); // ---- radicals eat Molly ---- const m = this.molly; diff --git a/test/geometry.test.mjs b/test/geometry.test.mjs new file mode 100644 index 0000000..68cc0a5 --- /dev/null +++ b/test/geometry.test.mjs @@ -0,0 +1,138 @@ +import { makeWorld, sim, tick, check, section, report, CFG } from './harness.mjs'; +const { bond } = await import('../src/particle.js'); + +// ============================================================ +// VSEPR — molecules have real shapes. +// +// Bond springs only fix bond LENGTH, so before this a molecule was atoms at +// whatever angles the storm left them: three circles near each other, never +// a thing. These tests guard the silhouettes that make it read as chemistry +// — water bent, CO2 a bar, methane a caltrop, benzene a hexagon. +// ============================================================ + +const deg = (c, a, b) => { + const ax = a.x - c.x, ay = a.y - c.y, bx = b.x - c.x, by = b.y - c.y; + return Math.abs(Math.atan2(ax * by - ay * bx, ax * bx + ay * by)) * 180 / Math.PI; +}; + +// Build a centre with n arms, started OFF the ideal so we prove the springs +// do the work — but never at exactly 180 for a 2-arm case, which is a +// degenerate equilibrium (the cross product vanishes and it cannot pick a +// side to fold toward). +function molecule(centreEl, armEls, seed = 42) { + const w = makeWorld(seed); + w.load({ id: 'lab', molly: { x: -9999, y: -9999 }, particles: [] }); + const c = w.spawn({ x: 640, y: 360, el: centreEl }); + const arms = armEls.map((el, i) => { + const a = 0.6 + (i / armEls.length) * Math.PI * 1.4; // deliberately wrong + return w.spawn({ x: 640 + Math.cos(a) * 30, y: 360 + Math.sin(a) * 30, el }); + }); + arms.forEach((a) => bond(c, a)); + return { w, c, arms }; +} + +// mean + spread of the ADJACENT bond angles over a stretch of live sim +function measure(w, c, arms, seconds = 3) { + sim(w, 4); + const xs = []; + const n = Math.round(seconds / CFG.DT); + for (let i = 0; i < n; i++) { + tick(w); + const bs = arms.map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) })) + .sort((p, q) => p.a - q.a); + const pairs = arms.length === 2 ? 1 : arms.length; + for (let j = 0; j < pairs; j++) xs.push(deg(c, bs[j].b, bs[(j + 1) % arms.length].b)); + } + const mean = xs.reduce((a, b) => a + b, 0) / xs.length; + const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length); + return { mean, sd }; +} + +section('VSEPR — the famous shapes'); +{ + const cases = [ + { name: 'water is BENT', c: 'O', arms: ['H', 'H'], want: 104.5, tol: 8 }, + { name: 'CO2 is a straight bar', c: 'C', arms: ['O', 'O'], want: 180, tol: 12 }, + { name: 'ammonia', c: 'N', arms: ['H', 'H'], want: 107, tol: 8 }, + { name: 'H2S is tighter than water', c: 'S', arms: ['H', 'H'], want: 92, tol: 10 }, + { name: 'methane is a caltrop', c: 'C', arms: ['H', 'H', 'H', 'H'], want: 90, tol: 6 }, + { name: 'trigonal splays to 120', c: 'C', arms: ['H', 'H', 'H'], want: 120, tol: 6 }, + ]; + for (const t of cases) { + const { w, c, arms } = molecule(t.c, t.arms); + const { mean } = measure(w, c, arms); + check(t.name, Math.abs(mean - t.want) < t.tol, + `${mean.toFixed(1)}deg (want ${t.want} +-${t.tol})`); + } +} + +section('VSEPR — water and CO2 must not look the same'); +{ + // the whole point: two bonds on carbon reads as a BAR, two on oxygen as a + // BEND. If these ever converge the geometry is decorative. + const a = molecule('O', ['H', 'H']); + const b = molecule('C', ['O', 'O']); + const wa = measure(a.w, a.c, a.arms).mean; + const wb = measure(b.w, b.c, b.arms).mean; + check('bent and linear are far apart', wb - wa > 50, + `water ${wa.toFixed(0)}deg vs CO2 ${wb.toFixed(0)}deg`); +} + +section('VSEPR — geometry HOLDS, it does not flap'); +{ + // Undamped, the mean angle came out right while water swung through a 61 + // degree range — a broken hinge, not a molecule. ANGLE_DAMP fixed that and + // this is the regression guard. + const { w, c, arms } = molecule('O', ['H', 'H']); + const { sd } = measure(w, c, arms, 5); + check('water holds a steady bend under the storm', sd < 8, `sd=${sd.toFixed(1)}deg`); +} + +section('VSEPR — geometry stays SOFT'); +{ + // It must lose to a blast, or molecules read as rigid props. A detonation + // beside a molecule should visibly deform it. + const { w, c, arms } = molecule('C', ['H', 'H', 'H', 'H']); + sim(w, 5); + const before = deg(c, arms[0], arms[1]); + const bomb = w.spawn({ x: c.x + 34, y: c.y, el: 'C' }); + w.overload.detonate(w, bomb); + tick(w); + const after = deg(c, arms[0], arms[1]); + check('a blast deforms the molecule', Math.abs(after - before) > 1, + `${before.toFixed(0)}deg -> ${after.toFixed(0)}deg`); +} + +section('SOLVENT — a medium, not more particles'); +{ + const w = makeWorld(7); + w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] }); + check('motes are spawned', w.solvent.motes.length === CFG.SOLVENT_N, + `${w.solvent.motes.length}`); + check('motes are NOT particles (nothing can interact with them)', + w.particles.length === 0, `${w.particles.length} particles`); + + // a charged atom must visibly move the medium around it + const p = w.spawn({ x: 640, y: 360, el: 'O' }); + p.charge = 1; + const near = w.solvent.motes + .map((m) => ({ m, d: Math.hypot(m.x - 640, m.y - 360) })) + .filter((o) => o.d < 120 && o.d > 20) + .sort((a, b) => a.d - b.d)[0]; + if (near) { + const d0 = near.d; + sim(w, 1.5); + const d1 = Math.hypot(near.m.x - p.x, near.m.y - p.y); + check('a charge pushes the medium away from it', d1 > d0, + `${d0.toFixed(0)}px -> ${d1.toFixed(0)}px`); + } else check('a charge pushes the medium away from it', false, 'no mote in range'); + + // and the medium must never leak out of the room + sim(w, 4); + const B = w.bounds; + const escaped = w.solvent.motes.filter( + (m) => m.x < B.x - 1 || m.x > B.x + B.w + 1 || m.y < B.y - 1 || m.y > B.y + B.h + 1).length; + check('motes wrap and never leak out of bounds', escaped === 0, `${escaped} escaped`); +} + +process.exit(report() ? 0 : 1);