diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 24e06c2..8a0b972 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -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. diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 373be0e..921d172 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -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.