/** * sail.selftest.js — assert suite for the sail sim. [Lane B] * * Exports SAIL_TESTS as plain [name, fn] pairs so ONE set of asserts runs in * two harnesses: Lane A's selftest.html (via js/tests/b.test.js) and node * (`node web/world/js/sail.selftest.js`) for fast iteration without a browser. * Drives time with fixed-dt loops only — never rAF, never a clock. * * The headline assert is `hypar sheds load vs flat`: it is the game's thesis * stated as a test. If it ever goes red, the sail has stopped being a sail. */ import { SailRig } from './sail.js'; import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js'; const SIM_DT = FIXED_DT; // ---------- deterministic stub wind ---------- // contracts.js ships createStubWind(), and the integration test below uses it. // This local one exists only because the thesis needs the wind DIRECTION swept, // which the shared stub does not expose. Lane C's weather.js replaces both. function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) { const rand = rng(seed); const gusts = []; for (let t = 3; t < stormLen; t += 5 + rand() * 7) { gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) }); } const len = Math.hypot(dir.x, dir.y, dir.z) || 1; const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len; const out = { x: 0, y: 0, z: 0 }; return { speedAt(t) { if (calm) return 0; let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6); for (const g of gusts) { const gt = t - g.start; if (gt < 0 || gt >= 5) continue; if (gt < 1.5) continue; // telegraph: seen, not felt else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp else if (gt < 4.0) speed += g.pow; // hold else speed += g.pow * (5.0 - gt); // fade } return speed; }, sample(pos, t) { const s = this.speedAt(t); out.x = dx * s; out.y = dy * s; out.z = dz * s; return out; }, gustTelegraph: () => null, }; } const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null }); // ---------- test rigs ---------- // Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}. // Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted // hypar). Any load difference is therefore purely geometry, nothing else. const FOOT = [ { x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 }, ]; export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle /** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */ export const makeAnchors = (heights) => FOOT.map((f, i) => { const pos = { x: f.x, y: heights[i], z: f.z }; return { id: `a${i}`, type: 'post', pos, sway: () => pos }; }); const ALL_IDS = ['a0', 'a1', 'a2', 'a3']; const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity }; function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) { return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity }) .attach(ALL_IDS, [hw, hw, hw, hw], tension); } /** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */ function runStorm(r, wind, secs, onStep) { const steps = Math.round(secs / SIM_DT); let peak = 0; for (let i = 0; i < steps; i++) { r.step(SIM_DT, wind, i * SIM_DT); const m = r.maxLoad(); if (m > peak) peak = m; if (onStep) onStep(r, i); } return peak; } const TESTS = []; const test = (name, fn) => TESTS.push([name, fn]); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const kN = (n) => `${(n / 1000).toFixed(2)} kN`; // ---------- the suite ---------- test('sim stays finite through a full storm', () => { const r = rig(HEIGHTS_HYPAR); runStorm(r, makeStubWind({ stormLen: 90 }), 90); for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity'); for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN'); return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`; }); test('sail sags under gravity when calm', () => { const r = rig(HEIGHTS_FLAT); runStorm(r, makeStubWind({ calm: true }), 6); const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3; const midY = r.pos[mid + 1]; const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4; assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`); return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`; }); // Newton's third law. This is what pins FABRIC_K to real newtons: if the corner // reactions don't sum to the actual aerodynamic + weight force on the fabric, // the load meter is lying and every kN rating on it is meaningless. test('statics: corner reactions balance the applied force', () => { const w = constantWind({ x: 0, y: 0, z: 18 }); const r = rig(HEIGHTS_FLAT); runStorm(r, w, 12); // settle // A membrane in steady wind never fully stops moving, so compare the // TIME-AVERAGED reaction against the time-averaged applied force. That is the // momentum balance that must hold; instant by instant it need not. let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0; for (let i = 0; i < Math.round(4 / SIM_DT); i++) { const t = 12 + i * SIM_DT; r.step(SIM_DT, w, t); const f = r.netAppliedForce(w, t); ax += f.x; ay += f.y; az += f.z; for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; } n++; } ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n; const appliedMag = Math.hypot(ax, ay, az); const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag; assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)} — ${(err * 100).toFixed(0)}% out of balance`); return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`; }); // THE THESIS. A twisted sail resists bellying into one coherent pocket, so its // worst moment is gentler than a flat sail's worst moment. // // Scored on WORST CASE over wind direction, not per-direction. Lane C's storms // veer, so the player never gets to choose the wind, and worst-case is what the // hardware actually has to survive. Per-direction would be a false assert: a // flat sail sitting edge-on to the wind genuinely does have low drag, and from // that one angle it beats the hypar. Demanding otherwise would mean tuning the // sim into a lie. test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => { const DIRS = [ { name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 }, { name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 }, { name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 }, { name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 }, ]; const sweep = (heights) => { let worst = 0, at = ''; for (const d of DIRS) { const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } }); const p = runStorm(rig(heights), storm, 45); if (p > worst) { worst = p; at = d.name; } } return { worst, at }; }; const flat = sweep(HEIGHTS_FLAT); const hypar = sweep(HEIGHTS_HYPAR); assert( hypar.worst < flat.worst * 0.8, `hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})` ); return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`; }); test('cascade: losing a corner spikes its neighbours', () => { const w = constantWind({ x: 0, y: 0, z: 22 }); const r = rig(HEIGHTS_HYPAR); runStorm(r, w, 6); // settle const before = Math.max(r.corners[1].load, r.corners[3].load); r.corners[0].broken = true; r._repin(r.t); runStorm(r, w, 2.5); // let the load redistribute const after = Math.max(r.corners[1].load, r.corners[3].load); assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`); return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`; }); test('determinism: identical inputs give byte-equal load traces', () => { const trace = () => { const r = rig(HEIGHTS_HYPAR); const w = makeStubWind({ seed: 3, stormLen: 30 }); const out = []; runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); }); return out; }; const a = trace(), b = trace(); assert(a.length === b.length, 'traces differ in length'); for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`); return `${a.length} load samples identical`; }); test('determinism: variable frame dt matches fixed dt', () => { // Lane A's render loop delivers ragged dt. The internal accumulator has to // absorb that, or nothing the selftest proves applies to the real game. const w1 = makeStubWind({ seed: 5, stormLen: 20 }); const fixed = rig(HEIGHTS_HYPAR); for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT); const w2 = makeStubWind({ seed: 5, stormLen: 20 }); const ragged = rig(HEIGHTS_HYPAR); const rand = rng(99); let acc = 0; while (acc < 20) { const dt = 0.004 + rand() * 0.02; // 4-24 ms frames ragged.step(dt, w2, acc); acc += dt; } for (let k = 0; k < 4; k++) { const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load); assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`); } return 'ragged frame times converge on the fixed-dt trace'; }); test('tension dial changes load (drum tight shock-loads)', () => { const w = constantWind({ x: 0, y: 0, z: 20 }); const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8); const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8); assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`); return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`; }); test('porous shade cloth carries less load than solid membrane', () => { const w = constantWind({ x: 0, y: 0, z: 20 }); const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8); const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8); assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`); return `solid ${kN(solid)} vs porous ${kN(porous)}`; }); test('coverage: sail shades the ground under it, not beside it', () => { const r = rig(HEIGHTS_FLAT); runStorm(r, makeStubWind({ calm: true }), 4); // world.gardenBed rects are CENTRE + size, so this bed straddles the origin. const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }); const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 }); assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`); assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`); return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`; }); test('coverage tracks a low sun off to the side', () => { const r = rig(HEIGHTS_FLAT); runStorm(r, makeStubWind({ calm: true }), 4); const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 }); const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 }); assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`); return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`; }); // PLAN3D §7 definition of done, in miniature. test('cheap flat rig cascades; twisted mixed rig survives', () => { const storm = () => makeStubWind({ seed: 11, stormLen: 90 }); const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 }); runStorm(cheap, storm(), 90); const cheapBroken = cheap.corners.filter((c) => c.broken).length; const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 }); runStorm(good, storm(), 90); const goodBroken = good.corners.filter((c) => c.broken).length; assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`); assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`); return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`; }); // PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This // drives a REAL overload failure rather than setting broken by hand, because // hand-setting it was exactly what hid the bug where _checkFailure marked a // corner broken but never gave its node its mass back — so a blown corner // stayed welded in mid-air and the sail never flogged. test('a blown corner is freed and flies (flogging is emergent)', () => { const w = makeStubWind({ seed: 11, stormLen: 90 }); const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go const broke = []; r.events.on('break', (e) => broke.push(e)); // step until the first corner lets go let i = 0; for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT); assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm'); const k = r.corners.indexOf(broke[0].corner); const node = r.cornerIdx[k], ci = node * 3; const anchor = r.corners[k].anchor.pos; const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]]; for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT); const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]); const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z); assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging'); assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`); assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`); return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`; }); test('break and repair emit on the events Emitter', () => { const w = constantWind({ x: 0, y: 0, z: 20 }); const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE }); const seen = []; r.events.on('break', (e) => seen.push(e)); r.events.on('repair', (e) => seen.push(e)); runStorm(r, w, 4); r.corners[0].broken = true; r._repin(r.t); runStorm(r, w, 1); assert(r.corners[0].load === 0, 'broken corner should carry no load'); assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success'); runStorm(r, w, 3); assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`); assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}'); return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`; }); // --- SPRINT2 decision 4: the seam Lane D already calls --------------------- test('decision 4: repair(i) re-rigs a blown corner with the spare', () => { const w = constantWind({ x: 0, y: 0, z: 20 }); const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] }); runStorm(r, w, 4); r.corners[0].broken = true; r._repin(r.t); // exactly Lane D's interact.js call: no hardware argument, return ignored r.repair(0); assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner'); assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`); assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again'); runStorm(r, w, 3); assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`); return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`; }); test('decision 4: repair(i) on an intact corner is a no-op', () => { const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] }); runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2); const hw = r.corners[1].hw; r.repair(1); // D gates on corner.broken, but the rig must not trust that assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware'); return 'intact corner untouched'; }); test('decision 4: trim(i, delta) tightens one corner only', () => { const r = rig(HEIGHTS_HYPAR); r.trim(0, +0.1); assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`); assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour'); for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`); return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`; }); test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => { const w = makeStubWind({ seed: 11, stormLen: 90 }); const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); const anchor = r.corners[0].anchor.pos; const p0 = r.cornerPos(0); assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6, 'an intact corner should report its anchor position'); assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch'); // blow it, then confirm the prompt would follow the flying corner r.corners[0].broken = true; r._repin(r.t); runStorm(r, w, 6); const p1 = r.cornerPos(0); const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z); assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`); assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null, 'cornerPos on an unrigged rig should be null, not a throw'); return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`; }); // --- SPRINT2 decision 5: debris ------------------------------------------- const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over }); test('decision 5: a crate hitting the sail conserves momentum', () => { const r = rig(HEIGHTS_FLAT); runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing // aimed at the belly, not a corner: a pinned corner would (correctly) dump // momentum into the house and there'd be nothing to conserve const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2); const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 }); const clothP = () => { let x = 0, y = 0, z = 0; for (let n = 0; n < r.invMass.length; n++) { if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house const i = n * 3; x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass; y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass; z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass; } return { x, y, z }; }; const total = () => { const c = clothP(); return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass }; }; const before = total(); r._applyDebris([p], SIM_DT); const after = total(); const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z); const scale = Math.hypot(before.x, before.y, before.z); assert(scale > 1, 'test crate carries no momentum to conserve'); assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`); assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`); return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`; }); test('decision 5: a crate through the sail shoves the cloth and emits', () => { const r = rig(HEIGHTS_FLAT); runStorm(r, makeStubWind({ calm: true }), 4); const hits = []; r.events.on('debrisHit', (e) => hits.push(e)); const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2); const before = r.pos[mid * 3 + 1]; const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 }); const v0 = p.vy; // Peak, not final: the crate crosses the cloth in about three frames and the // membrane springs back well inside the run, so sampling the end measures the // recovery rather than the punch. const wind = makeStubWind({ calm: true }); let peak = before; for (let i = 0; i < 30; i++) { r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] }); p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT; peak = Math.max(peak, r.pos[mid * 3 + 1]); } assert(hits.length > 0, 'crate passed through the cloth without a single contact'); assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`); assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`); return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`; }); test('decision 5: no debris and empty debris are both fine', () => { const w = makeStubWind({ seed: 2, stormLen: 20 }); const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR); for (let i = 0; i < 600; i++) { a.step(SIM_DT, w, i * SIM_DT); // Lane A's 3-arg call still works b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] }); } for (let k = 0; k < 4; k++) { assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9, 'an empty debris list changed the sim'); } return 'empty and absent debris both no-op'; }); test('runs against the shared contracts.js stub wind', () => { // Proves the rig eats the sanctioned Wind implementation, not just my local // stub — so nothing surprises us when Lane C's weather.js drops in. const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[1] }); const wind = createStubWind({ seed: 1, stormLen: 90 }); const peak = runStorm(r, wind, 90); for (const v of r.pos) assert(Number.isFinite(v), 'went NaN on the shared stub wind'); assert(peak > 0, 'shared stub wind produced no load at all'); return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`; }); export const SAIL_TESTS = TESTS; export function runSailSelftest() { const results = TESTS.map(([name, fn]) => { try { return { name, pass: true, detail: fn() || '' }; } catch (e) { return { name, pass: false, detail: e.message }; } }); return { pass: results.every((r) => r.pass), results }; } export function report(out) { const lines = out.results.map( (r) => `${r.pass ? 'PASS' : 'FAIL'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}` ); return `${lines.join('\n')}\n\n${out.pass ? 'ALL GREEN' : 'FAILURES'} — ${out.results.filter((r) => r.pass).length}/${out.results.length}`; } // Run only when invoked directly; importing this module must not run the suite. if (typeof process !== 'undefined' && process.versions?.node && import.meta.filename === process.argv[1]) { const out = runSailSelftest(); console.log(report(out)); process.exit(out.pass ? 0 : 1); } export { makeStubWind };