HardYards/web/world/js/sail.selftest.js
m3ultra 18099c8e6f Align sail lane to contracts.js; free blown corners so they flog
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.

Contract fixes:
  - anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
    I had it adding sway to pos, which would have flung every
    tree-anchored corner to double its coordinates).
  - events is an Emitter emitting {type, corner}, not a drained array.
  - coverageOver() rects are centre+size, matching world.gardenBed. It
    consumes world.sunDir directly: a hit along sunDir means shaded.
  - START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
    rather than being redeclared here.

Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.

Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.

HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:55:37 +10:00

371 lines
17 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';
const SIM_DT = FIXED_DT;
// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
// This local one exists only because the thesis needs the wind DIRECTION swept,
// which the shared stub does not expose. Lane C's weather.js replaces both.
function makeStubWind({ seed = 7, stormLen = 90, dir = { x: 0, y: 0, z: 1 }, calm = false } = {}) {
const rand = rng(seed);
const gusts = [];
for (let t = 3; t < stormLen; t += 5 + rand() * 7) {
gusts.push({ start: t, pow: 12 + rand() * 16 + 10 * (t / stormLen) });
}
const len = Math.hypot(dir.x, dir.y, dir.z) || 1;
const dx = dir.x / len, dy = dir.y / len, dz = dir.z / len;
const out = { x: 0, y: 0, z: 0 };
return {
speedAt(t) {
if (calm) return 0;
let speed = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
for (const g of gusts) {
const gt = t - g.start;
if (gt < 0 || gt >= 5) continue;
if (gt < 1.5) continue; // telegraph: seen, not felt
else if (gt < 2.3) speed += g.pow * (gt - 1.5) / 0.8; // ramp
else if (gt < 4.0) speed += g.pow; // hold
else speed += g.pow * (5.0 - gt); // fade
}
return speed;
},
sample(pos, t) {
const s = this.speedAt(t);
out.x = dx * s; out.y = dy * s; out.z = dz * s;
return out;
},
gustTelegraph: () => null,
};
}
const constantWind = (v) => ({ sample: () => v, speedAt: () => Math.hypot(v.x, v.y, v.z), gustTelegraph: () => null });
// ---------- test rigs ----------
// Same 5x5 m footprint, same multiset of corner heights {4.0, 4.0, 2.5, 2.5}.
// Only the ARRANGEMENT differs: coplanar (flat, pitched) vs permuted (twisted
// hypar). Any load difference is therefore purely geometry, nothing else.
const FOOT = [
{ x: -2.5, z: -2.5 }, { x: 2.5, z: -2.5 }, { x: 2.5, z: 2.5 }, { x: -2.5, z: 2.5 },
];
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
export const makeAnchors = (heights) =>
FOOT.map((f, i) => {
const pos = { x: f.x, y: heights[i], z: f.z };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
}
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
function runStorm(r, wind, secs, onStep) {
const steps = Math.round(secs / SIM_DT);
let peak = 0;
for (let i = 0; i < steps; i++) {
r.step(SIM_DT, wind, i * SIM_DT);
const m = r.maxLoad();
if (m > peak) peak = m;
if (onStep) onStep(r, i);
}
return peak;
}
const TESTS = [];
const test = (name, fn) => TESTS.push([name, fn]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const kN = (n) => `${(n / 1000).toFixed(2)} kN`;
// ---------- the suite ----------
test('sim stays finite through a full storm', () => {
const r = rig(HEIGHTS_HYPAR);
runStorm(r, makeStubWind({ stormLen: 90 }), 90);
for (const v of r.pos) assert(Number.isFinite(v), 'node position went NaN/Infinity');
for (const c of r.corners) assert(Number.isFinite(c.load), 'corner load went NaN');
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
});
test('sail sags under gravity when calm', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 6);
const N = r.N, mid = (Math.floor(N / 2) * N + Math.floor(N / 2)) * 3;
const midY = r.pos[mid + 1];
const cornerMeanY = HEIGHTS_FLAT.reduce((a, b) => a + b) / 4;
assert(midY < cornerMeanY, `belly (${midY.toFixed(2)}m) should hang below corner mean (${cornerMeanY}m)`);
return `belly sags ${(cornerMeanY - midY).toFixed(2)} m below corner plane`;
});
// Newton's third law. This is what pins FABRIC_K to real newtons: if the corner
// reactions don't sum to the actual aerodynamic + weight force on the fabric,
// the load meter is lying and every kN rating on it is meaningless.
test('statics: corner reactions balance the applied force', () => {
const w = constantWind({ x: 0, y: 0, z: 18 });
const r = rig(HEIGHTS_FLAT);
runStorm(r, w, 12); // settle
// A membrane in steady wind never fully stops moving, so compare the
// TIME-AVERAGED reaction against the time-averaged applied force. That is the
// momentum balance that must hold; instant by instant it need not.
let n = 0, ax = 0, ay = 0, az = 0, rx = 0, ry = 0, rz = 0;
for (let i = 0; i < Math.round(4 / SIM_DT); i++) {
const t = 12 + i * SIM_DT;
r.step(SIM_DT, w, t);
const f = r.netAppliedForce(w, t);
ax += f.x; ay += f.y; az += f.z;
for (const c of r.corners) { rx += c.loadVec.x; ry += c.loadVec.y; rz += c.loadVec.z; }
n++;
}
ax /= n; ay /= n; az /= n; rx /= n; ry /= n; rz /= n;
const appliedMag = Math.hypot(ax, ay, az);
const err = Math.hypot(rx - ax, ry - ay, rz - az) / appliedMag;
assert(err < 0.2, `reactions ${kN(Math.hypot(rx, ry, rz))} vs applied ${kN(appliedMag)}${(err * 100).toFixed(0)}% out of balance`);
return `applied ${kN(appliedMag)}, reactions ${kN(Math.hypot(rx, ry, rz))}, residual ${(err * 100).toFixed(1)}%`;
});
// THE THESIS. A twisted sail resists bellying into one coherent pocket, so its
// worst moment is gentler than a flat sail's worst moment.
//
// Scored on WORST CASE over wind direction, not per-direction. Lane C's storms
// veer, so the player never gets to choose the wind, and worst-case is what the
// hardware actually has to survive. Per-direction would be a false assert: a
// flat sail sitting edge-on to the wind genuinely does have low drag, and from
// that one angle it beats the hypar. Demanding otherwise would mean tuning the
// sim into a lie.
test('hypar sheds load vs flat, worst case over wind direction (the thesis)', () => {
const DIRS = [
{ name: 'N', x: 0, z: 1 }, { name: 'NE', x: 0.707, z: 0.707 },
{ name: 'E', x: 1, z: 0 }, { name: 'SE', x: 0.707, z: -0.707 },
{ name: 'S', x: 0, z: -1 }, { name: 'SW', x: -0.707, z: -0.707 },
{ name: 'W', x: -1, z: 0 }, { name: 'NW', x: -0.707, z: 0.707 },
];
const sweep = (heights) => {
let worst = 0, at = '';
for (const d of DIRS) {
const storm = makeStubWind({ seed: 7, stormLen: 45, dir: { x: d.x, y: 0, z: d.z } });
const p = runStorm(rig(heights), storm, 45);
if (p > worst) { worst = p; at = d.name; }
}
return { worst, at };
};
const flat = sweep(HEIGHTS_FLAT);
const hypar = sweep(HEIGHTS_HYPAR);
assert(
hypar.worst < flat.worst * 0.8,
`hypar worst ${kN(hypar.worst)} (${hypar.at}) should be well under flat worst ${kN(flat.worst)} (${flat.at})`
);
return `flat worst ${kN(flat.worst)} from ${flat.at} -> hypar worst ${kN(hypar.worst)} from ${hypar.at} (sheds ${((1 - hypar.worst / flat.worst) * 100).toFixed(0)}%)`;
});
test('cascade: losing a corner spikes its neighbours', () => {
const w = constantWind({ x: 0, y: 0, z: 22 });
const r = rig(HEIGHTS_HYPAR);
runStorm(r, w, 6); // settle
const before = Math.max(r.corners[1].load, r.corners[3].load);
r.corners[0].broken = true;
r._repin(r.t);
runStorm(r, w, 2.5); // let the load redistribute
const after = Math.max(r.corners[1].load, r.corners[3].load);
assert(after >= before * 2, `neighbour went ${kN(before)} -> ${kN(after)}, wanted >= 2x`);
return `neighbour ${kN(before)} -> ${kN(after)} (${(after / before).toFixed(1)}x)`;
});
test('determinism: identical inputs give byte-equal load traces', () => {
const trace = () => {
const r = rig(HEIGHTS_HYPAR);
const w = makeStubWind({ seed: 3, stormLen: 30 });
const out = [];
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
return out;
};
const a = trace(), b = trace();
assert(a.length === b.length, 'traces differ in length');
for (let i = 0; i < a.length; i++) assert(a[i] === b[i], `sample ${i} diverged: ${a[i]} vs ${b[i]}`);
return `${a.length} load samples identical`;
});
test('determinism: variable frame dt matches fixed dt', () => {
// Lane A's render loop delivers ragged dt. The internal accumulator has to
// absorb that, or nothing the selftest proves applies to the real game.
const w1 = makeStubWind({ seed: 5, stormLen: 20 });
const fixed = rig(HEIGHTS_HYPAR);
for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
const w2 = makeStubWind({ seed: 5, stormLen: 20 });
const ragged = rig(HEIGHTS_HYPAR);
const rand = rng(99);
let acc = 0;
while (acc < 20) {
const dt = 0.004 + rand() * 0.02; // 4-24 ms frames
ragged.step(dt, w2, acc);
acc += dt;
}
for (let k = 0; k < 4; k++) {
const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N between fixed and ragged dt`);
}
return 'ragged frame times converge on the fixed-dt trace';
});
test('tension dial changes load (drum tight shock-loads)', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
const tightPeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 1.35 }), w, 8);
assert(tightPeak > loosePeak, `tight ${kN(tightPeak)} should exceed loose ${kN(loosePeak)}`);
return `loose ${kN(loosePeak)} vs tight ${kN(tightPeak)}`;
});
test('porous shade cloth carries less load than solid membrane', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const solid = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0 }), w, 8);
const porous = runStorm(rig(HEIGHTS_HYPAR, { porosity: 0.35 }), w, 8);
assert(porous < solid, `porous ${kN(porous)} should be under solid ${kN(solid)}`);
return `solid ${kN(solid)} vs porous ${kN(porous)}`;
});
test('coverage: sail shades the ground under it, not beside it', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4);
// world.gardenBed rects are CENTRE + size, so this bed straddles the origin.
const under = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 });
const beside = r.coverageOver({ x: 14, z: 14, w: 4, d: 4 });
assert(under > 0.9, `ground under the sail only ${(under * 100).toFixed(0)}% shaded`);
assert(beside === 0, `ground 14 m away reported ${(beside * 100).toFixed(0)}% shaded`);
return `under sail ${(under * 100).toFixed(0)}%, off to the side ${(beside * 100).toFixed(0)}%`;
});
test('coverage tracks a low sun off to the side', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4);
const noon = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0, y: 1, z: 0 });
const lowSun = r.coverageOver({ x: 0, z: 0, w: 4, d: 4 }, { x: 0.9, y: 0.25, z: 0 });
assert(noon > lowSun, `shadow should slide off the bed as the sun drops (noon ${noon}, low ${lowSun})`);
return `noon ${(noon * 100).toFixed(0)}% -> low sun ${(lowSun * 100).toFixed(0)}%`;
});
// PLAN3D §7 definition of done, in miniature.
test('cheap flat rig cascades; twisted mixed rig survives', () => {
const storm = () => makeStubWind({ seed: 11, stormLen: 90 });
const cheap = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.35 });
runStorm(cheap, storm(), 90);
const cheapBroken = cheap.corners.filter((c) => c.broken).length;
const good = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2], tension: 0.95 });
runStorm(good, storm(), 90);
const goodBroken = good.corners.filter((c) => c.broken).length;
assert(cheapBroken >= 2, `flat drum-tight carabiner rig only lost ${cheapBroken} corners — should cascade`);
assert(goodBroken === 0, `twisted rated-shackle rig lost ${goodBroken} corners — should survive`);
return `cheap flat lost ${cheapBroken}/4, good hypar lost ${goodBroken}/4`;
});
// PLAN3D §5-B: "broken corner frees the node -> flogging is emergent". This
// drives a REAL overload failure rather than setting broken by hand, because
// hand-setting it was exactly what hid the bug where _checkFailure marked a
// corner broken but never gave its node its mass back — so a blown corner
// stayed welded in mid-air and the sail never flogged.
test('a blown corner is freed and flies (flogging is emergent)', () => {
const w = makeStubWind({ seed: 11, stormLen: 90 });
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // cheap and tight: this one lets go
const broke = [];
r.events.on('break', (e) => broke.push(e));
// step until the first corner lets go
let i = 0;
for (const end = Math.round(90 / SIM_DT); i < end && !broke.length; i++) r.step(SIM_DT, w, i * SIM_DT);
assert(broke.length > 0, 'a carabiner rig should have blown a corner somewhere in a 90 s storm');
const k = r.corners.indexOf(broke[0].corner);
const node = r.cornerIdx[k], ci = node * 3;
const anchor = r.corners[k].anchor.pos;
const before = [r.pos[ci], r.pos[ci + 1], r.pos[ci + 2]];
for (let j = 0; j < Math.round(3 / SIM_DT); j++) r.step(SIM_DT, w, (i + j) * SIM_DT);
const moved = Math.hypot(r.pos[ci] - before[0], r.pos[ci + 1] - before[1], r.pos[ci + 2] - before[2]);
const fromAnchor = Math.hypot(r.pos[ci] - anchor.x, r.pos[ci + 1] - anchor.y, r.pos[ci + 2] - anchor.z);
assert(r.invMass[node] > 0, 'blown corner still has infinite mass — it is welded in mid-air, not flogging');
assert(moved > 0.05, `blown corner only drifted ${moved.toFixed(3)} m in 3 s — it is not flogging`);
assert(fromAnchor > 0.2, `blown corner is still ${fromAnchor.toFixed(2)} m from its anchor — it never let go`);
return `corner ${broke[0].anchorId} blew at t=${broke[0].t.toFixed(1)}s, tore ${fromAnchor.toFixed(2)} m off its anchor and is flying`;
});
test('break and repair emit on the events Emitter', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const r = rig(HEIGHTS_HYPAR, { hw: UNBREAKABLE });
const seen = [];
r.events.on('break', (e) => seen.push(e));
r.events.on('repair', (e) => seen.push(e));
runStorm(r, w, 4);
r.corners[0].broken = true;
r._repin(r.t);
runStorm(r, w, 1);
assert(r.corners[0].load === 0, 'broken corner should carry no load');
assert(r.repairCorner(0, UNBREAKABLE), 'repairCorner should report success');
runStorm(r, w, 3);
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
assert(seen.some((e) => e.type === 'repair' && e.corner === r.corners[0]), 'no repair event with {type, corner}');
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
});
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 };