Selftest on merged main: 169 pass / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
692 lines
33 KiB
JavaScript
692 lines
33 KiB
JavaScript
/**
|
|
* 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';
|
|
import { createWindField } from './weather.core.js';
|
|
|
|
const SIM_DT = FIXED_DT;
|
|
|
|
// ---------- real storm wind (SPRINT2 B-4) ----------
|
|
// The §7 gate used to run on the local stub, which is uniform, horizontal and
|
|
// tuned by nobody. These load the storms design actually ships and drive the
|
|
// cloth with them. weather.core.js is pure and import-free, so the same code
|
|
// path works in node and in Lane A's selftest.html; only reading the JSON off
|
|
// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
|
|
// is a file:// URL under node, which fetch won't open).
|
|
|
|
async function loadStormDef(name) {
|
|
const url = new URL(`../data/storms/${name}.json`, import.meta.url);
|
|
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
const { readFile } = await import('node:fs/promises');
|
|
return JSON.parse(await readFile(url, 'utf8'));
|
|
}
|
|
return (await fetch(url)).json();
|
|
}
|
|
|
|
const STORM_02 = await loadStormDef('storm_02_wildnight');
|
|
|
|
/** A Wind over a real storm def. Same field the game flies. */
|
|
function realWind(def = STORM_02, opts = {}) {
|
|
const field = createWindField(def, opts);
|
|
const out = { x: 0, y: 0, z: 0 };
|
|
return {
|
|
sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
|
|
speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
|
|
gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
|
|
};
|
|
}
|
|
|
|
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
|
const YARD = [
|
|
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
|
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
|
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
|
].map(([id, type, x, y, z]) => {
|
|
const pos = { x, y, z };
|
|
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
|
// a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
|
|
// the game and in a.test.
|
|
return { id, type, pos, sway: () => pos };
|
|
});
|
|
|
|
const yardRig = (ids, hw, tension) =>
|
|
new SailRig({ anchors: YARD, gridN: 10 })
|
|
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
|
|
|
// ---------- 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.
|
|
* `theta` spins the footprint about the yard's Y axis — which is how you sweep
|
|
* wind direction against a real storm, whose direction curve you don't get to
|
|
* choose. Rotating the rig under the wind and rotating the wind over the rig are
|
|
* the same experiment; only one of them is available with authored storm JSON.
|
|
*/
|
|
export const makeAnchors = (heights, theta = 0) =>
|
|
FOOT.map((f, i) => {
|
|
const c = Math.cos(theta), s = Math.sin(theta);
|
|
const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
|
|
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';
|
|
});
|
|
|
|
// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------
|
|
|
|
// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
|
|
// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
|
|
// version of this proved it against my own stub wind, which is uniform,
|
|
// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
|
|
// not that the game works. This is the real storm JSON, the real yard, and the
|
|
// same two rig shapes Lane C measured decision 3 against.
|
|
test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
|
const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
|
|
const broke = [];
|
|
rig.events.on('break', (e) => broke.push(e));
|
|
const w = realWind();
|
|
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
|
|
const lost = rig.corners.filter((c) => c.broken).length;
|
|
assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
|
|
return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
|
|
});
|
|
|
|
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
|
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
|
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
|
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
|
const w = realWind();
|
|
let peak = 0;
|
|
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
|
rig.step(SIM_DT, w, i * SIM_DT);
|
|
peak = Math.max(peak, rig.maxLoad());
|
|
}
|
|
const lost = rig.corners.filter((c) => c.broken).length;
|
|
assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
|
|
return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
|
|
});
|
|
|
|
test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
|
|
// The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
|
|
// MUST be survivable". The twisted rig above already survives outright, so
|
|
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
|
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
|
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
|
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
|
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
|
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
|
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
|
// lightest) is what a player does by accident and it survives the storm
|
|
// having proved nothing; putting it on t2 is the real bet.
|
|
const rig = yardRig(
|
|
['h1', 't2', 'p1', 't1'],
|
|
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
|
0.85,
|
|
);
|
|
const w = realWind();
|
|
let repairs = 0;
|
|
rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
|
|
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
|
rig.step(SIM_DT, w, i * SIM_DT);
|
|
if (repairs === 0) {
|
|
const k = rig.corners.findIndex((c) => c.broken);
|
|
if (k >= 0) { rig.repair(k); repairs++; }
|
|
}
|
|
}
|
|
const lost = rig.corners.filter((c) => c.broken).length;
|
|
if (repairs === 0) {
|
|
// A vacuous pass is worse than a skip: "nothing broke" would let this go
|
|
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
|
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
|
// with downdraft 0.3, never without). Lights up by itself on merge.
|
|
assert(
|
|
!STORM_02.gusts?.downdraft,
|
|
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
|
);
|
|
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
|
}
|
|
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
|
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
|
});
|
|
|
|
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
|
|
|
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
|
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
|
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
|
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
|
// decision 3 asks Lane B for.
|
|
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
|
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
|
if (!downdraft) {
|
|
// Feature-detected rather than hard-failed: this assert is only meaningful
|
|
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
|
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
|
}
|
|
if (downdraft < 0.5) {
|
|
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
|
|
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
|
|
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
|
|
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
|
|
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
|
|
// gust power — which loads a flat roof steadily without spiking the gust
|
|
// peak that breaks the twisted rig. That is a weather.core change (joint
|
|
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
|
|
// this assert self-skips rather than shipping a red main or a lying bar.
|
|
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
|
|
}
|
|
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
|
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
|
// instead would only reshuffle gust TIMING — the direction curve is authored
|
|
// in the JSON and doesn't move — so it would look like a sweep and measure
|
|
// nothing about direction.)
|
|
const sweep = (heights) => {
|
|
let worst = 0;
|
|
for (let k = 0; k < 8; k++) {
|
|
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
|
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
|
// full duration: storm_02's own note says the peak lands just AFTER the
|
|
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
|
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
|
}
|
|
return worst;
|
|
};
|
|
const pitched = sweep(HEIGHTS_FLAT);
|
|
const horizontal = sweep(FLAT_H);
|
|
const ratio = horizontal / pitched;
|
|
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
|
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
|
});
|
|
|
|
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 };
|