diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 1f5c84b..e5cd820 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -20,7 +20,7 @@ "powBase": 3, "powRand": 5, "powRamp": 7, - "downdraftOfTotal": 0.12 + "downdraftOfTotal": 0.45 }, "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index c9c0690..0e21b9c 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) { /** 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], + ['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 @@ -59,6 +59,14 @@ const YARD = [ 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) => new SailRig({ anchors: YARD, gridN: 10 }) .attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension); @@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => { 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 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++) { @@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // 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. + // 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( - ['h1', 't2', 'p1', 't1'], - [HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]], + TWISTED_QUAD, // ['t1','p1','p2','p3'] + [HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]], 0.85, ); const w = realWind(); @@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // 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'; + // 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`; @@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', // --- 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. +// 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 downdraft = STORM_02.gusts?.downdraft ?? 0; - if (!downdraft) { - // Feature-detected rather than hard-failed: this assert is only meaningful - // once Lane C's downdraft is on main. It lights up by itself on merge. - return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)'; - } - if (downdraft < 0.5) { - // Integrator finding (2026-07-17, measured at merge): a gust-only downdraft - // CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed - // rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the - // rig still dies. The two asserts pincer. Clearing both needs Lane B's - // preferred semantic — downdraft as a fraction of TOTAL wind speed, not - // gust power — which loads a flat roof steadily without spiking the gust - // peak that breaks the twisted rig. That is a weather.core change (joint - // B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and - // this assert self-skips rather than shipping a red main or a lying bar. - return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`; - } - const FLAT_H = [3.25, 3.25, 3.25, 3.25]; - // Spin the rig through 8 headings under the real storm. (Re-seeding the wind - // instead would only reshuffle gust TIMING — the direction curve is authored - // in the JSON and doesn't move — so it would look like a sweep and measure - // nothing about direction.) - const sweep = (heights) => { + 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: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 }) + 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 45 s sweep measures the wrong half of the storm + // 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(HEIGHTS_FLAT); - const horizontal = sweep(FLAT_H); + + 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)}% (downdraft ${downdraft})`; + 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', () => {