Re-run the §7 gate against real storm wind; stop allocating per face

B-4. The gate used to run on my own stub wind — uniform, horizontal,
tuned by nobody — so it proved the cloth was self-consistent, not that
the game works. It now loads the shipped storm JSON and drives the cloth
through weather.core.js (pure and import-free, so the same path runs in
node and in selftest.html; weather.js's own loadStorm can't help because
its STORM_DIR is a file:// URL that node's fetch won't open).

All three halves of §7 now run on real storm_02 and the real yard:
a flat drum-tight carabiner rig cascades 4/4; a well-twisted mixed rig
holds all four; and a twisted rig with one dodgy corner blows it and
finishes 4/4 intact after a single repair — the DoD scenario, in an
assert.

Perf: Lane C added an `out` param to wind.sample specifically so sail.js
wouldn't allocate, and I wasn't passing it — 162 faces at 60 Hz is ~9.7k
throwaway Vector3s a second. Now passing a scratch vector. Stub winds
that ignore `out` still work; we read the return value.

Two tests skip rather than pass vacuously while Lane C's downdraft is
unmerged: a "nothing broke" repair scenario would go green forever and
check nothing. Both light up by themselves on merge, and the repair one
hard-fails if a downdraft IS present and still can't threaten the rig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 00:25:25 +10:00
parent cb557a1d6f
commit 2034593a30
2 changed files with 178 additions and 4 deletions

View File

@ -125,6 +125,11 @@ export class SailRig {
this._acc = 0;
// scratch, reused every face to keep the hot loop allocation-free
this._probe = { x: 0, y: 0, z: 0 };
// Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
// one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
// a second otherwise. A stub wind that ignores `out` still works: we read
// the RETURN value, not this.
this._windOut = new THREE.Vector3();
}
/**
@ -373,7 +378,7 @@ export class SailRig {
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
const w = wind.sample(probe, t);
const w = wind.sample(probe, t, this._windOut);
// Relative wind, not absolute: as the cloth accelerates downwind the load
// bleeds off by itself. This is what stops flogging from exploding.

View File

@ -12,9 +12,57 @@
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,
@ -66,10 +114,17 @@ const FOOT = [
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) =>
/**
* 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 pos = { x: f.x, y: heights[i], z: f.z };
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 };
});
@ -472,6 +527,120 @@ test('decision 5: no debris and empty debris are both fine', () => {
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)';
}
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.