diff --git a/web/world/js/sail.js b/web/world/js/sail.js index b31b0f2..f6a745d 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -836,6 +836,33 @@ export class SailRig { return this; } + /** + * RMS speed of the cloth's nodes, m/s — read straight out of verlet, since + * position IS the state here and velocity is just (pos - prev)/dt. + * + * This is what "is the sail settled" actually asks. Corner LOAD cannot answer + * it: measured on the dressed yard, a load-trend guard reads 17% on a rig with + * NO settle and 96% on a properly settled one, because what it really sees is + * the rig loading up when the wind changes — the better-settled the cloth, the + * sharper that ramp. Motion inverts none of that. Under unchanged conditions: + * + * 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007 + * + * an order of magnitude, and in the direction the word "settled" means. Sample + * it over a window, not per-step: the cloth breathes and never reaches zero. + */ + nodeSpeed() { + const p = this.pos, q = this.prev; + if (!p || !q) return 0; + let sum = 0, n = 0; + for (let i = 0; i < p.length; i += 3) { + const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2]; + sum += dx * dx + dy * dy + dz * dz; + n++; + } + return n ? Math.sqrt(sum / n) / SIM_DT : 0; + } + /** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */ _checkFailure(dt) { for (let k = 0; k < 4; k++) { diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 0089484..e26e233 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -147,7 +147,7 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) { * Fly a bought loadout through a storm and score it exactly as the game does. * @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}} */ -async function fly(yard, session, stormName, { repair = false, broom = false } = {}) { +async function fly(yard, session, stormName, { repair = false, broom = false, settleSecs = 12 } = {}) { const def = await loadStorm(stormName); const wind = createWind(def); // The settle below runs on the CALM day, because that is what main.js hands the rig outside a @@ -211,65 +211,88 @@ async function fly(yard, session, stormName, { repair = false, broom = false } = // anything. The settle is a fidelity fix, not the corner-count cause. B's arithmetic was right: // t2 pulls ~4.5 kN and a shackle is rated 3.2 — the line cannot hold, at any tension, settled or // not. See THREADS. - { - const settleWind = calmWind || wind; - // Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL - // duration replayed all of storm_01's gusts inside 12 s of sim, so the rig - // arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in - // the live game is the calm day's opening minutes, not its highlight reel — - // hold the settle inside storm_01's pre-gust window (first gust >= 3 s). - const period = 3; - for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) { - rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period); + const settleWind = calmWind || wind; + // Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL + // duration replayed all of storm_01's gusts inside 12 s of sim, so the rig + // arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in + // the live game is the calm day's opening minutes, not its highlight reel — + // hold the settle inside storm_01's pre-gust window (first gust >= 3 s). + const PREP_PERIOD = 3; + let prepT = 0; + /** Prep on the clock the player actually stands in. Returns mean cloth speed, m/s. */ + const prep = (secs) => { + let sum = 0; + const n = Math.round(secs / FIXED_DT); + for (let i = 0; i < n; i++) { + rig.step(FIXED_DT, settleWind, prepT % PREP_PERIOD); + prepT += FIXED_DT; + sum += rig.nodeSpeed(); } - } - /** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */ - const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0)); - - // D's settled-at-entry guard (SPRINT8 gate 0'). + return sum / n; + }; + if (settleSecs > 0) prep(settleSecs); // settleSecs 0 = the unsettled control, below + // D's settled-at-entry guard (SPRINT8 gate 0') — REDESIGNED, SPRINT9, B with + // D's demotion measurements. D: this is the change you're owed a veto on. // // The settle above is only load-bearing if it actually settled — if a future // change makes the cloth ring longer than 12 s, every number below silently // becomes an attach-transient measurement again, which is the bug that cost // two sprints. So prove it. // - // It has to be a TREND test, not an instant one. Measured: with the wind held - // constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN, - // because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind - // drag is meant to do the damping). There is no single settled value; the - // cloth breathes. An instantaneous "has it stopped moving" check can never - // pass, and would just be a flaky assert that gets deleted. Same lesson as the - // statics assert in sail.selftest: compare time-AVERAGED windows. - const meanLoad = (secs) => { - let sum = 0, n = Math.round(secs / FIXED_DT); - for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); } - return sum / n; - }; - const w1 = meanLoad(2); - const w2 = meanLoad(2); - const trend = Math.abs(w2 - w1) / Math.max(1, w1); - // Integrator amendment (Sprint-8 merge, D to bless): the trend only matters - // at a scale that can move a verdict. The transient that cost two sprints was - // kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads - // as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating. - // ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign, - // not a threshold. It was authored before ponding merged, and at a held clock - // storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING - // the guard's own windows — the "trend" it reads is water arriving, which no - // settle length fixes (measured tonight: 105% with full-curve settle, 46% - // dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly). - // The guard's idea is right; its clock is wrong. Redesign: measure the trend - // over the storm's own advancing first seconds (rain then follows the real - // curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it - // WARNS instead of failing so the merged suite reports the balance truthfully. - if (trend > 0.35 && Math.max(w1, w2) > 600) { - console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` + - `${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` + - `(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` + - `Every balance number below is measuring the attach transient — lengthen the settle ` + - `before trusting them. (Oscillation is expected and fine; a TREND is not.)`); + // THE OBSERVABLE WAS THE BUG, not the clock. The demoted guard compared the + // worst corner's mean LOAD across two windows and called a change a "trend". + // Three redesigns were measured on the dressed yard before one worked, and the + // two the integrator proposed are among the ones that don't — so, in full, in + // case anyone is tempted back: + // + // probe under CALM, held in prep 0 s settle 13% 12 s settle 17% + // probe under STORM, held clock 0 s settle 24% 12 s settle 104% + // probe under STORM, advancing clock 0 s settle 24% 12 s settle 104% + // probe under STORM, RAINLESS 0 s settle 17% 12 s settle 96% + // + // Every one of them is either flat (calm cannot excite the cloth, so the guard + // is vacuous and passes forever — an assert that cannot fail) or INVERTED: it + // fires hardest on the properly settled rig. Rain is not the culprit either; + // the rainless probe ponds 1 kg and still inverts. What a load-trend actually + // measures is the rig LOADING UP when the wind changes, and a taut settled + // cloth ramps HARDER than a limp unsettled one (0.63 -> 1.23 kN vs + // 0.44 -> 0.52). The metric was reading the storm's arrival, not the cloth. + // No threshold, clock or rain switch fixes an observable pointed at the wrong + // thing. (The integrator's ponding diagnosis was right about the mechanism and + // is also unfixable by clock: RAIN_TIME_COMPRESSION is 40×, so even 4 s of + // ADVANCING storm is 160 s of rain — 43 kg in the belly either way.) + // + // "Settled" is a statement about the CLOTH, so ask the cloth. rig.nodeSpeed() + // is RMS node speed straight out of verlet, sampled over a 2 s window of the + // same calm prep the rig is already standing in — no wind change to ramp + // against, no compressed rain, and nothing left behind in the rig but two more + // seconds of the prep a player does anyway. Measured, dressed yard: + // + // settle 0 s 4 s 12 s 30 s + // speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 -> 0.028) + // + // An order of magnitude, in the direction the word means. It is an ABSOLUTE + // check, not a trend: the old comment's "the cloth breathes, an instantaneous + // check can never pass" is true and is why this is a windowed MEAN — but the + // breathing sits at 0.02 m/s and the transient at 0.19, so there is a real gap + // to put a line in. 0.08 is ~3× above the worst settled rig here and ~2.4× + // below the loosest unsettled one. + // + // This is a FAILURE again, not a warning. It earns that by being able to fail: + // asserted below on a deliberately unsettled rig. — B, SPRINT9. D: your veto. + const SETTLED_SPEED = 0.08; + const entrySpeed = prep(2); + if (entrySpeed > SETTLED_SPEED) { + throw new Error(`yard is NOT settled at storm entry: the cloth is still moving at ` + + `${entrySpeed.toFixed(3)} m/s (settled is <${SETTLED_SPEED}) after a ${settleSecs} s settle. ` + + `Every balance number in this suite is measuring the attach transient — lengthen the settle ` + + `before trusting them. (Breathing is expected and fine; ~0.19 m/s is a cloth still falling ` + + `into shape.)`); } + /** Peak corner load the instant the storm starts — settled, dry, on the calm day. */ + const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0)); + let hp = 100, pond = 0, used = 0; const steps = Math.round(def.duration / FIXED_DT); for (let i = 0; i < steps; i++) { @@ -352,6 +375,24 @@ export default async function run(t) { const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null; + // THE GUARD'S OWN CONTROL. A guard nobody has ever seen fail is indistinguishable + // from a guard that cannot fail — that is how the previous version survived being + // both vacuous and inverted for two sprints. So fly the same line with NO settle: + // the cloth is still falling into shape, and the guard must refuse it. Flown here, + // up front, because testkit's Suite.test() does NOT await — an async assert would + // pass forever while proving nothing (the standing house rule; see SPRINT6). + let unsettled; + { + const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); + if (s) s.setFabric('cloth'); + try { + await fly(yard, s, 'storm_02_wildnight', { settleSecs: 0 }); + unsettled = { fired: false }; + } catch (e) { + unsettled = { fired: /NOT settled at storm entry/.test(e.message), err: e.message }; + } + } + // --- then judge ----------------------------------------------------------- // SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount. @@ -405,6 +446,21 @@ export default async function run(t) { return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`; }); + // The guard is load-bearing for every number in this suite, so it does not get + // to be decoration. If this ever goes red, the guard has stopped being able to + // fail and its silence elsewhere means nothing. + t.test('balance: the settled-at-entry guard can fail — a 0 s settle trips it', () => { + if (!unsettled) throw new Error('the unsettled control never flew'); + if (unsettled.fired === false) { + throw new Error(`the settled-at-entry guard PASSED a rig with no settle at all. It is now ` + + `vacuous — every "settled" claim in this suite rests on an assert that cannot fail. ` + + `Check rig.nodeSpeed() still reads verlet, and that SETTLED_SPEED is not above the ` + + `~0.19 m/s an unsettled cloth actually moves at.`); + } + if (!unsettled.fired) throw new Error(`the unsettled control died for the wrong reason: ${unsettled.err}`); + return 'a 0 s settle trips the guard — its silence on the real flights means something'; + }); + // FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the // forecast to be the price — so the only thing that can justify the choice is // that it changes the night. Measured, same $80 line, one variable: