HardYards/web/world/js/sail.selftest.js
type-two 9700b40a30 Wire ratingHint into the failure threshold: the ratings are real (A's ruling)
sail.js _checkFailure now fails a corner on load > hw.rating * anchor.ratingHint
(read live from the anchor — dress() mutates in place). Consequence asserted,
not the formula: on site_02's carport line (uniform rated shackles, funnel on,
12 s calm settle) a cb corner blows before any q corner — at DEFAULT tension
(t~29 s into the storm, honest posts hold) and at max. In-suite control pins
the attribution: the same rig with every hint forced to 1 holds 4/4, so the
break is the hint, not the load. Mutation-checked: unwiring the line fails both.

rigging.js weak-link (D's #7): summary.weakest reduces on rating * ratingHint
via _effRating, not bare hw.rating — uniform hardware now flags the genuinely
worst steel instead of the first click. Mutation-checked.

site_audit prices the anchor, not just the steel: tierFor(peak, hint) needs
h.rating * hint >= peak, both front-ends carry ratingHint through (browser off
world.anchors, node dump extended with the live-dumped hints), rows print the
hint. sweep.selftest asserts hints reprice without touching peaks and land
over-ceiling corners in unholdable. Mutation-checked.

Selftest 319/0/0 (315 + 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:19:01 +10:00

1042 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, RAIN_TIME_COMPRESSION } 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,
// ponding reads this — the whole point of C exporting it in real units
rainAt: (t) => field.rainAt(t),
rainMmPerHour: (t) => field.rainMmPerHour(t),
};
}
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
const YARD = [
['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
].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 };
});
/**
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto
* a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the
* most twisted quad the band offers. The old one was 6x too big, which is what
* made it break under downdraft and made me call the bar unachievable.
*/
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
const yardRig = (ids, hw, tension) => {
const r = new SailRig({ anchors: YARD, gridN: 10 });
r.watchDivergence = true;
return r.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'];
// A right-sized LEVEL sail — a 5x5 m carport roof, all corners at one height.
// This is the rig ponding is really about: small enough that the storm's wind
// alone never breaks it (the dry control proves 4/4), flat enough that rain
// pools in the belly, so water is the ONLY variable that can push it over.
// Measured: dry 4/4, wet loses a corner at t~85 s holding ~440 kg. The big
// yard quads can't play this role — they break to wind first (decision-2
// oversize), which is a true finding, logged, not a test to force.
const LEVEL_CARPORT = [3.2, 3.2, 3.2, 3.2];
const carportAnchors = (S = 2.5) =>
[[-S, -S], [S, -S], [S, S], [-S, S]].map(([x, z], i) => {
const pos = { x, y: LEVEL_CARPORT[i], z };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const carportRig = (hw) => {
const r = new SailRig({ anchors: carportAnchors(), gridN: 10 });
r.watchDivergence = true;
return r.attach(ALL_IDS, Array(4).fill(hw), 1.0);
};
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
const r = new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity });
r.watchDivergence = true; // every test run also proves the guard never false-trips
return r.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(TWISTED_QUAD, [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 on the decision-11 quad: rated t1 ($30) + shackle p1
// ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
//
// The carabiner goes on p2 because that is where the load actually IS —
// measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
// Hanging the cheap corner on p1 (the lightest) is what a player does by
// accident: it rides the whole storm out and proves nothing. p2 is the real
// bet, and it's the one that has to blow for this test to mean anything.
const rig = yardRig(
TWISTED_QUAD, // ['t1','p1','p2','p3']
[HARDWARE[2], HARDWARE[1], HARDWARE[0], 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.
// Decision 11 landed the downdraft for real, so there is no longer an excuse
// for nothing breaking: a vacuous pass here would mean the §7 repair leg —
// the sprint's whole definition of done — is checking nothing.
assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
}
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 ------------------
// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
// 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 the wind descend (decision 8: a fraction of TOTAL speed, present
// whenever it's windy).
//
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
// on p far more than on the downdraft:
//
// pitch f=0.12 f=0.45 the bar is 60%
// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable,
// asymptote 109%, would need f=0.86
// 4.8° 34.9% 71.5% <- the yard: clears comfortably
//
// A steeply-pitched reference catches the downdraft nearly as well as a
// horizontal one does (its normal is still 96% vertical), so it can never be
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
// can't rig proved something true about nothing.
const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js
test('decision 3: flat-horizontal is no longer a free lunch', () => {
const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
// Footprint sized and pitched like a real quad from the dressed yard, spun
// through 8 headings under the real storm. (Re-seeding the wind instead only
// reshuffles gust TIMING — the direction curve is authored — so it would look
// like a sweep and measure nothing about direction.)
const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
const HORIZ = [3.2, 3.2, 3.2, 3.2];
const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
const at = (hs, th) => foot.map(([x, z], i) => {
const c = Math.cos(th), s = Math.sin(th);
const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const sweep = (hs) => {
let worst = 0;
for (let k = 0; k < 8; k++) {
const r = new SailRig({ anchors: at(hs, (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 short sweep measures the wrong half of the storm
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
}
return worst;
};
const pitched = sweep(PITCHED);
const horizontal = sweep(HORIZ);
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)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
});
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`;
});
// --- SPRINT4 decision 10 / SPRINT5: ponding -------------------------------
const STORM_01 = await loadStormDef('storm_01_gentle');
test('ponding: a flat rig pools water and a twisted one sheds it', () => {
// Real yard quads, not the synthetic level saddle: HEIGHTS_HYPAR is a
// symmetric two-up-two-down at one footprint, which sags into a central belly
// under a night of rain and ponds like a flat sail — an artifact of the test
// rig, not the sim. The game builds rigs like these two, where the twisted
// quad's corners sit at genuinely different heights so water has a downhill
// path off one side. Measured 50x apart (1.7 vs 13 kg/m²).
const flat = yardRig(['h1', 'h3', 'p2', 'p1'], UNBREAKABLE, 1.0); // 2.6/2.6/4.0/4.0 — a roof
const twisted = yardRig(TWISTED_QUAD, UNBREAKABLE, 0.85); // §7's own survivor
runStorm(flat, realWind(), STORM_02.duration);
runStorm(twisted, realWind(), STORM_02.duration);
const fm = flat.pondMass(), tm = twisted.pondMass();
const fpm = fm / flat.area, tpm = tm / twisted.area; // per m², since areas differ
assert(fpm > 8, `flat rig only held ${fpm.toFixed(1)} kg/m² after a night of rain — it isn't ponding`);
assert(tpm < fpm * 0.25, `twisted rig held ${tpm.toFixed(1)} kg/m² vs the flat rig's ${fpm.toFixed(1)} — it should shed`);
return `flat ${fpm.toFixed(1)} kg/m² vs twisted ${tpm.toFixed(1)} kg/m² (${(tpm / fpm * 100).toFixed(0)}%)`;
});
// THE POINT OF THE WHOLE WATER ARC. Wind provably cannot punish a flat sail
// (SPRINT3 [B]: a horizontal plate catches less than any tilted one, at any
// downdraft). Water can, it's DESIGN.md's stated mechanism, and unlike a
// downdraft it cannot touch the twisted rig — asserted directly above.
// A CONTROLLED experiment isolating water as the killer. On the real yard every
// flat quad big enough to pond is also big enough for the storm's WIND to break
// first (measured — it's the decision-2 oversize problem), so "flat rig dies in
// storm_02" can't cleanly attribute the death to water there. Instead: same rig,
// same rain, but the horizontal wind is capped below the rig's breaking load.
// Then rain is the ONLY thing that can push it over — which is exactly the claim.
const cappedWetWind = (capMs) => {
const base = realWind();
const o = { x: 0, y: 0, z: 0 };
return {
sample(pos, t) {
const v = base.sample(pos, t);
const h = Math.hypot(v.x, v.z);
if (h > capMs) { const s = capMs / h; o.x = v.x * s; o.y = v.y; o.z = v.z * s; return o; }
o.x = v.x; o.y = v.y; o.z = v.z; return o;
},
rainAt: (t) => base.rainAt(t),
rainMmPerHour: (t) => base.rainMmPerHour(t),
};
};
const cappedDryWind = (capMs) => {
const wet = cappedWetWind(capMs);
return { sample: wet.sample, rainAt: () => 0, rainMmPerHour: () => 0 };
};
test('ponding: rain alone kills a flat rig the capped wind cannot', () => {
const CAP = 16; // m/s — the dry control proves this rig holds 4/4 against it
const r = carportRig(HARDWARE[1]); // shackle: holds the capped wind, not a night of water
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, cappedWetWind(CAP), STORM_02.duration);
assert(broke.length > 0, `flat rated rig survived — peak pond was only ${r.pondMass().toFixed(0)} kg, rain isn't loading it`);
return `${broke.length} corner(s) blew to water under a ${CAP} m/s wind cap, first at t=${broke[0].t.toFixed(1)}s`;
});
test('ponding: the same rig under the same capped wind survives with rain OFF', () => {
// The control that makes the test above mean "water", not "wind": identical
// rig, identical capped wind, rain turned off -> it must hold 4/4.
const CAP = 16;
const r = carportRig(HARDWARE[1]);
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, cappedDryWind(CAP), STORM_02.duration);
assert(broke.length === 0, `the rig lost ${broke.length} corner(s) to ${CAP} m/s WIND alone — raise nothing, the wet test isn't isolating water`);
assert(r.pondMass() === 0, 'no rain should mean no pond');
return `dry, ${CAP} m/s cap: 4/4 held — so the kill above is the water`;
});
test('ponding: storm_01 gentle cannot hurt anyone', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, realWind(STORM_01), STORM_01.duration);
assert(broke.length === 0, `a gentle day blew ${broke.length} corner(s) — storm_01 is the tutorial`);
return `4/4 held, ${r.pondMass().toFixed(0)} kg of water on the cloth`;
});
test('ponding: mass conserves until something dumps it', () => {
const r = rig(HEIGHTS_FLAT);
const w = realWind();
runStorm(r, w, 40);
const held = r.pondMass();
assert(held > 50, `only ${held.toFixed(0)} kg to conserve — test is vacuous`);
// no rain from here: the pond may drain off the rim but must not appear
const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
runStorm(r, dry, 5);
assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
const dumped = r.dumpPond('test');
assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped');
assert(r.pondMass() === 0, 'dumpPond left water behind');
return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
});
function r_prev(_r, d) { return d; }
test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
runStorm(r, realWind(), STORM_02.duration);
assert(dumps.some((d) => d.reason === 'corner blew'), 'a corner let go and the water just sat there');
const big = dumps.find((d) => d.reason === 'corner blew');
return `corner blew and dropped ${big.kg.toFixed(0)} kg at t=${big.t.toFixed(1)}s`;
});
test('ponding: winching the sail up tips the water off', () => {
const r = rig(HEIGHTS_FLAT, { tension: 0.9 });
runStorm(r, realWind(), 40);
const held = r.pondMass();
assert(held > 50, 'nothing to tip off — test is vacuous');
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
r.setTension(1.1);
assert(dumps.some((d) => d.reason === 'tensioned up'), 'tensioning a ponded sail did not shed the water');
assert(r.pondMass() === 0, 'sail still holding water after being winched up');
return `winch 0.9 -> 1.1 shed ${held.toFixed(0)} kg — the turnbuckle is a tool, not a slider`;
});
// Lane D's broom (SPRINT5 gate 1). DESIGN.md: "run out and poke the pond with a
// broom — the funniest correct mechanic in the game."
test('ponding: drainPondAt is a broom, and the water has to go somewhere', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, realWind(), 45);
const before = r.pondMass();
assert(before > 100, `only ${before.toFixed(0)} kg to sweep — test is vacuous`);
const target = r.pondCentroid();
assert(target && target.node >= 0, 'pondCentroid found no pond to aim at');
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
let onYourHead = 0;
for (let i = 0; i < Math.round(1.5 / SIM_DT); i++) onYourHead += r.drainPondAt(target.node, SIM_DT);
assert(onYourHead > before * 0.4, `a 1.5 s poke only shifted ${onYourHead.toFixed(0)} of ${before.toFixed(0)} kg`);
assert(r.pondMass() < before * 0.6, 'the belly is still full after a full poke');
assert(dumps.length > 0, 'drainPondAt emitted nothing for Lane D to react to');
return `1.5 s poke dropped ${onYourHead.toFixed(0)} kg of ${before.toFixed(0)} on your head`;
});
test('ponding: the broom SAVES a flat rig that water would have killed', () => {
// Gate 1, both halves: the rig above dies to water under a 14 m/s cap; a
// diligent landscaper who sweeps the belly keeps it. Same rig, same capped
// wind, so the only thing that changed is the broom.
const CAP = 16;
const swept = carportRig(HARDWARE[1]);
const broke = [];
swept.events.on('break', (e) => broke.push(e));
const w = cappedWetWind(CAP);
const steps = Math.round(STORM_02.duration / SIM_DT);
for (let i = 0; i < steps; i++) {
swept.step(SIM_DT, w, i * SIM_DT);
// a diligent landscaper sweeps before the belly reaches a kill load — the
// rig above blows around 440 kg, so keep it under ~300
if (swept.pondMass() > 250) {
const c = swept.pondCentroid();
if (c) swept.drainPondAt(c.node, SIM_DT, 3);
}
}
assert(broke.length === 0, `swept rig still lost ${broke.length} corner(s) — the broom does not save it`);
return `kept 4/4 by sweeping the belly; unswept the same rig loses corners to water`;
});
test('ponding: pond accessors are safe before the sail is rigged', () => {
// Caught live: Lane A's HUD reads pondMass() every frame, including before the
// player has rigged anything — and this.water doesn't exist until attach().
const bare = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) });
assert(bare.pondMass() === 0, 'pondMass threw / was non-zero on an unrigged sail');
assert(bare.pondCentroid() === null, 'pondCentroid should be null on an unrigged sail');
assert(bare.drainPondAt(0, SIM_DT) === 0, 'drainPondAt should no-op on an unrigged sail');
assert(bare.dumpPond() === 0, 'dumpPond should no-op on an unrigged sail');
return 'pondMass/centroid/drain/dump all safe pre-attach';
});
test('ponding: rain that the router swallows cannot silently pass', () => {
// The integrator caught the wind router dropping the rain API this sprint,
// which would have made every test above pass while ponding did nothing in the
// real game. A wind with no rain methods must therefore be LOUD, not benign.
const r = rig(HEIGHTS_FLAT);
const noRainApi = { sample: realWind().sample, speedAt: () => 0, gustTelegraph: () => null };
runStorm(r, noRainApi, 30);
assert(r.pondMass() === 0, 'water appeared from a wind with no rain API');
return 'no rain API -> no pond (and Lane A asserts the router keeps it)';
});
// --- SPRINT12: THE RATINGS ARE REAL (A's ruling, THREADS sprint 11) ---------
// sail.js fails a corner on load > hw.rating * anchor.ratingHint. This test
// asserts the CONSEQUENCE, not the formula: on the corner block, at max
// tension, through the funnel, the carport blows before an honest post — the
// yard's whole thesis, which D measured to be UNENFORCED before the wiring
// (at any tension the sim had no reason to prefer cb over q; the lever that
// decided the trap was tension, not steel).
const STORM_03B = await loadStormDef('storm_03b_earlybuster');
/**
* site_02_corner_block, DRESSED — dumped live from world.anchors after dress()
* (browser, SPRINT12), same provenance as tools/site_audit's backyard dump.
* Node cannot dress (GLTFLoader + fetch), and the graybox positions are a
* different yard — see audit.mjs's header for the whole sermon. ratingHint is
* E's baked number, adopted by adoptAnchor; q1..q4 are site-JSON posts at
* world.js's DEFAULT_RATING_HINT = 1.
*/
const SITE2_DRESSED = [
['tr1', 'tree', 6.833, 2.992, 0.154, 1.0], ['tr1b', 'tree', 7.142, 3.764, 0.043, 0.88],
['q1', 'post', -3.953, 3.937, -2.824, 1.0], ['q2', 'post', 3.983, 4.001, -2.276, 1.0],
['q3', 'post', 2.249, 3.991, 4.498, 1.0], ['q4', 'post', -3.334, 4.022, 4.445, 1.0],
['cb1', 'carport', -8.41, 2.289, -3, 0.22], ['cb2', 'carport', -5.59, 2.289, -3, 0.22],
['cp1', 'carport_post', -8.41, 1.679, -0.39, 0.3], ['cp2', 'carport_post', -8.41, 1.679, -5.61, 0.3],
].map(([id, type, x, y, z, ratingHint]) => {
const pos = { x, y, z };
return { id, type, ratingHint, pos, sway: () => pos };
});
/** site_02's funnel, verbatim from the site JSON — the yard's personality. */
const SITE2_VENTURI = [{ x: -6, z: 0, axis: 2.1, gain: 1.5, radius: 5, sharp: 3 }];
/** realWind() with the SITE's venturi on it, the way main.js sets it at load. */
function site2Wind(def) {
const field = createWindField(def);
field.setVenturi(SITE2_VENTURI);
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(-6, 0, t, out); return Math.hypot(out.x, out.z); },
gustTelegraph: () => null,
rainAt: (t) => field.rainAt(t),
rainMmPerHour: (t) => field.rainMmPerHour(t),
};
}
/**
* D's exact carport line (cb1 cb2 q2 q3) on UNIFORM rated shackles, settled 12 s
* on the calm day (funnel on — the gap doesn't switch off for prep, same as the
* audit), then flown through the whole early buster. Uniform hardware ON
* PURPOSE: the only asymmetry left is the anchors themselves, so if a cb corner
* goes first it can only be ratingHint. Returns breaks with the rig's OWN clock
* on them — settle is t 0..12, the storm is t 12..102.
*/
const flyCarportLine = (anchors, tension) => {
const r = new SailRig({ anchors, gridN: 10, porosity: 0.30 });
r.watchDivergence = true;
r.attach(['cb1', 'cb2', 'q2', 'q3'], Array(4).fill(HARDWARE[2]), tension);
const breaks = [];
r.events.on('break', (e) => breaks.push({ id: e.anchorId, t: e.t }));
const calm = site2Wind(STORM_01), storm = site2Wind(STORM_03B);
for (let i = 0, n = Math.round(12 / SIM_DT); i < n; i++) r.step(SIM_DT, calm, (i * SIM_DT) % 3);
const SETTLE_END = r.t;
for (let i = 0; i < Math.round(STORM_03B.duration / SIM_DT); i++) r.step(SIM_DT, storm, i * SIM_DT);
return { breaks, SETTLE_END };
};
test('ruling: on site_02 the CARPORT blows before an honest post — at DEFAULT tension', () => {
// The half D's cold pass proved missing: pre-wiring, at tension 1.0 the
// honest post blew first and the carport cost nothing, so the lever that
// decided the trap was tension. Now the same rig, default tension, survives
// prep and loses the CARPORT mid-storm while both honest posts hold — the
// anchor decides, not the dial. (Measured: cb1 lets go around t≈41 with
// q2/q3 peaking ~1.8/2.5 kN, well under a bare 6.5 kN rated shackle.)
const { breaks, SETTLE_END } = flyCarportLine(SITE2_DRESSED, 1.0);
assert(breaks.length > 0,
'nothing blew at default tension: ratingHint is not reaching _checkFailure — the trap is still tension-gated');
const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
assert(firstCb >= 0, `the carport never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
assert(firstQ === -1 || firstCb < firstQ,
`an honest post (${breaks[firstQ]?.id}) blew before the carport — the trap is pointing the wrong way`);
assert(breaks[firstCb].t > SETTLE_END,
`the beam let go at t=${breaks[firstCb].t.toFixed(1)}s, INSIDE the ${SETTLE_END.toFixed(0)}s prep settle — ` +
'at default tension the trap should fire in the storm, not on the shop floor');
return `default tension: ${breaks[firstCb].id} blew at t=${(breaks[firstCb].t - SETTLE_END).toFixed(1)}s into the storm, honest posts held`;
});
test('ruling: at MAX tension the carport still goes first — and the hint is doing it', () => {
// The sprint's literal shape (max tension through the funnel), plus the
// control that makes both tests mean "the HINT", not "the load".
const { breaks } = flyCarportLine(SITE2_DRESSED, 1.4); // TENSION_MAX
const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
assert(firstCb >= 0, `max tension and the carport still never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
assert(firstQ === -1 || firstCb < firstQ,
`an honest post (${breaks[firstQ]?.id}) blew before the carport at max tension`);
// Identical yard, every hint forced to 1 — bare hardware ratings, the
// pre-wiring game — must survive the same night intact: rated shackles at
// 6.5 kN hold these loads (worst peak ~3.4 kN); only 6.5 × 0.22 = 1.43 kN
// lets go. This is the mutation control IN the suite: unwire sail.js and
// both tests above go red because the wired and unhinted runs collapse into
// this one. If THIS half ever breaks, the loads moved and the cb-first
// asserts stop isolating the wiring — re-measure before touching thresholds.
const unhinted = flyCarportLine(
SITE2_DRESSED.map((a) => ({ ...a, ratingHint: 1, sway: a.sway })), 1.4).breaks;
assert(unhinted.length === 0,
`hint-free control lost ${unhinted.map((b) => b.id).join(',')} — bare rated shackles no longer hold this line; ` +
'the cb-first asserts are no longer attributing the break to ratingHint');
return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
});
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 };