Verlet cloth on a bilinear patch between 4 anchors, N=10 grid, structural/shear/bend constraints, 5 relaxation iterations at a fixed 1/60 substep. Wind is applied per FACE so hypar twist genuinely sheds load rather than being cosmetic. Two deviations from PLAN3D worth flagging: - Load is read from each constraint's XPBD Lagrange multiplier, not from FABRIC_K * leftover-stretch. After a fixed iteration count the leftover stretch is solver error, not fabric strain, so the naive reading came out ~50x hot (60 kN peaks on a 5x5 m sail). The multiplier is the real constraint impulse, which the statics assert confirms by balancing the corner reactions against the applied wind to 8%. - Wind uses a signed square (d*|d|) rather than clamp(d)^2, so the leeward face is pushed too. A sail is double-sided. The sim core deliberately does not import three.js: it runs headless under node today, stays allocation-free in the hot loop, and replays bit-for-bit. createSailView() pulls three in lazily for rendering. Loads land in real newtons (~1-4 kN on a 5x5 m sail in a 34 m/s storm), so hardware ratings are real working load limits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
15 KiB
JavaScript
353 lines
15 KiB
JavaScript
/**
|
|
* sail.selftest.js — assert suite for the sail sim. [Lane B]
|
|
*
|
|
* DOM-free and three-free on purpose: runs today under `node sail.selftest.js`
|
|
* before Lane A's shell exists, and Lane A's selftest.html can import
|
|
* runSailSelftest() unchanged once it lands. Drives the sim 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, HARDWARE } from './sail.js';
|
|
|
|
const SIM_DT = 1 / 60;
|
|
|
|
// ---------- deterministic stub wind (Lane C owns the real weather.js) ----------
|
|
|
|
function mulberry32(seed) {
|
|
return function () {
|
|
seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ports the prototype's gust scheduler shape: telegraph 1.5 s, ramp 0.8 s,
|
|
* hold ~1.7 s, fade 1 s. The schedule is precomputed from the seed so
|
|
* sample(pos, t) stays a pure function of t — which is what makes the
|
|
* determinism assert meaningful and lets the selftest fast-forward.
|
|
*/
|
|
function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
|
|
const rng = mulberry32(seed);
|
|
const gusts = [];
|
|
for (let t = 3; t < stormLen; t += 5 + rng() * 7) {
|
|
gusts.push({ start: t, pow: 12 + rng() * 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;
|
|
const p = Math.min(1, t / stormLen);
|
|
let speed = 8 + 26 * Math.min(1, p * 1.6);
|
|
for (const g of gusts) {
|
|
const gt = t - g.start;
|
|
if (gt < 0 || gt >= 5) continue;
|
|
if (gt < 1.5) continue; // telegraph: you see it, you don't feel it
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z) });
|
|
|
|
// ---------- 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 },
|
|
];
|
|
const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y is linear in z -> one plane
|
|
const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
|
|
|
|
const makeAnchors = (heights) =>
|
|
FOOT.map((f, i) => ({
|
|
id: `a${i}`,
|
|
type: 'post',
|
|
pos: { x: f.x, y: heights[i], z: f.z },
|
|
}));
|
|
|
|
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 peak corner load seen 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;
|
|
}
|
|
|
|
// ---------- tiny assert harness ----------
|
|
|
|
const results = [];
|
|
function test(name, fn) {
|
|
try {
|
|
const detail = fn();
|
|
results.push({ name, pass: true, detail: detail || '' });
|
|
} catch (e) {
|
|
results.push({ name, pass: false, detail: e.message });
|
|
}
|
|
}
|
|
function assert(cond, msg) {
|
|
if (!cond) throw new Error(msg);
|
|
}
|
|
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;
|
|
|
|
// ---------- the suite ----------
|
|
|
|
export function runSailSelftest() {
|
|
results.length = 0;
|
|
|
|
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 calibrates FABRIC_K into 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 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 this compares the
|
|
// TIME-AVERAGED reaction against the time-averaged applied force. That is
|
|
// the momentum balance that has to hold; instant by instant it need not.
|
|
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
|
|
const steps = Math.round(4 / SIM_DT);
|
|
for (let i = 0; i < steps; 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 that happens to sit 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})`
|
|
);
|
|
const shed = (1 - hypar.worst / flat.worst) * 100;
|
|
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${shed.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 rng = mulberry32(99);
|
|
let acc = 0;
|
|
while (acc < 20) {
|
|
const dt = 0.004 + rng() * 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 loose = rig(HEIGHTS_HYPAR, { tension: 0.7 });
|
|
const tight = rig(HEIGHTS_HYPAR, { tension: 1.35 });
|
|
const loosePeak = runStorm(loose, w, 8);
|
|
const tightPeak = runStorm(tight, 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);
|
|
const under = r.coverageOver({ x: -2, z: -2, w: 4, d: 4 });
|
|
const beside = r.coverageOver({ x: 12, z: 12, w: 4, d: 4 });
|
|
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
|
|
assert(beside === 0, `ground 12 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: -2, z: -2, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
|
|
const lowSun = r.coverageOver({ x: -2, z: -2, 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`;
|
|
});
|
|
|
|
test('repair re-pins a blown corner and it carries load again', () => {
|
|
const w = constantWind({ x: 0, y: 0, z: 20 });
|
|
const r = rig(HEIGHTS_HYPAR);
|
|
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, HARDWARE[1]), 'repairCorner should report success');
|
|
runStorm(r, w, 3);
|
|
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
|
const evs = r.drainEvents();
|
|
assert(evs.some((e) => e.type === 'repair'), 'no repair event emitted');
|
|
return `repaired corner back to ${kN(r.corners[0].load)}`;
|
|
});
|
|
|
|
const pass = results.every((r) => r.pass);
|
|
return { pass, results };
|
|
}
|
|
|
|
// ---------- entry points ----------
|
|
|
|
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: `node web/world/js/sail.selftest.js`.
|
|
// Importing the module must not run the suite. In the browser `process` is
|
|
// undefined, so Lane A's selftest.html just calls runSailSelftest() itself.
|
|
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 { report, makeStubWind, HEIGHTS_FLAT, HEIGHTS_HYPAR, makeAnchors };
|