diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index c415643..1139edc 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -191,6 +191,17 @@ export class Emitter { * LIVE world position of corner i, as a fresh vector safe to keep. A blown * corner's node is flying, so an interaction prompt anchored to this chases * the flogging corner instead of sitting on the dead anchor. null if unrigged. + * @property {() => number} pondMass + * Kilograms of rainwater pooled on the sail (SPRINT5 ponding). Lane A's HUD + * warning threshold and "SAIL PONDING — get the broom" ticker read this. + * @property {() => ({x:number,y:number,z:number,mass:number,node:number}|null)} pondCentroid + * Where the pond sits in world space, its mass, and the heaviest grid node — + * or null if there's nothing worth pointing at. Lane D walks the player to + * this; Lane E draws the water here. + * @property {(node:number, dt:number, radius?:number) => number} drainPondAt + * Lane D's broom: poke node `node` (from pondCentroid().node) for one frame of + * the ~1.5 s hold; drains a radius around it and RETURNS the kg shed this call. + * Sum over the hold = what lands on the player's head. Emits 'pondDump'. */ /** @@ -306,7 +317,7 @@ export class Emitter { export const CONTRACT = { wind: { sample: 'function', gustTelegraph: 'function' }, world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' }, - sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' }, + sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function', pondMass: 'function', pondCentroid: 'function', drainPondAt: 'function' }, player: { pos: 'object', carrying: '*', busy: '*', update: 'function' }, interact: { register: 'function' }, camera: { object: 'object', yaw: 'number', update: 'function' }, diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index ea42f68..9215e4e 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -32,11 +32,23 @@ export class RiggingSession { */ constructor({ anchors = [], budget = START_BUDGET } = {}) { this.anchors = anchors; - this.budget = budget; + this._startBudget = budget; + this.reset(); + } + + /** + * Back to an empty prep phase, same anchors and starting budget. Lane A's + * "play again" reaches into the state machine to fake a fresh round rather + * than rebuilding the session, so this owns the field list — add a field + * above, reset it here. + */ + reset() { + this.budget = this._startBudget; this.tension = DEFAULT_TENSION; this.spares = 0; /** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */ this.picks = []; + return this; } get spent() { return START_BUDGET - this.budget; } diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index be22670..afa2e71 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -169,6 +169,20 @@ test('summary names the weak link for the HUD', () => { return `weak link flagged: ${sum.weakest}, $${sum.budget} left`; }); +test('reset() returns a used session to a fresh prep phase', () => { + const s = session(); + for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); + s.setHardware('h1', RATED); s.setTension(1.2); s.setSpares(1); + assert(s.budget < START_BUDGET, 'setup should have spent money'); + s.reset(); + assert(s.budget === START_BUDGET, `budget not restored: $${s.budget}`); + assert(s.picks.length === 0, 'picks not cleared'); + assert(s.tension === 1.0 && s.spares === 0, 'tension/spares not reset'); + // and it's actually usable again, not just zeroed + assert(s.rig('t1').ok && s.canStart === false, 'session not rig-able after reset'); + return 'budget, picks, tension, spares all fresh; rig-able again'; +}); + export const RIGGING_TESTS = TESTS; export function runRiggingSelftest() { diff --git a/web/world/js/sail.js b/web/world/js/sail.js index becc223..a3705cc 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -20,6 +20,7 @@ import * as THREE from '../vendor/three.module.js'; import { Emitter, FIXED_DT, HARDWARE } from './contracts.js'; +import { RAIN_TIME_COMPRESSION } from './weather.core.js'; export { HARDWARE }; @@ -56,11 +57,45 @@ const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS); const COMP_BEND = 1 / (FABRIC_K * K_BEND); const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping +// ---------- ponding (SPRINT4 decision 10 / SPRINT5) ---------- +// DESIGN.md §"Rain → ponding": "Flat sails collect water; water is heavy; the +// belly collects more (positive feedback) until sudden dump, tear, or corner +// failure." Lane C owns how hard it rains (rainMmPerHour + RAIN_TIME_COMPRESSION); +// this owns how much of it a sail holds. +// +// The model is FLOW, not a drain coefficient. Water leaves a sail because it has +// somewhere to GO, not because the fabric is tilted: each node pushes water down +// its steepest neighbour, rim nodes pour it over the edge. The neighbourhood is +// 8-way ON PURPOSE — on a hypar the water runs along the saddle's DIAGONAL ridge +// to the two low corners, and a 4-way graph can't follow that, so it traps water +// in the shallow gravity belly the cloth sags into between grid lines and a hypar +// pools as much as a flat sail (measured: it did, until diagonals). So a flat +// sail's belly is a basin water flows INTO and can't climb out of, while a hypar +// drains to its low corners and off — which is why ponding cannot pincer §7. +// +// (My reverted Sprint-3 prototype drained by slope magnitude instead and pooled +// 1 kg — it measured the coefficient I'd invented, not the sail.) +const POND_FLOW = 6.0; // 1/s per unit of downhill gradient — how fast water finds the low spot +const POND_SPILL = 3.0; // 1/s — a rim node pouring over the edge, when the edge is downhill +// Depth cap per node. Water funnels into ~10 belly nodes, not evenly, so this is +// the depth AT THE DEEPEST POINT (a torn-sail extreme), not the average — set +// too low and the whole sail saturates shallow and never reaches a kill load. +const POND_MAX_KG_M2 = 900; // ~90 cm at the single deepest node +const BROOM_DRAIN = 2.5; // 1/s at the poke — a ~1.5 s hold clears the belly + // ---------- debris (SPRINT2 decision 5) ---------- const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness // ---------- failure ---------- +// Genuine solver-blowup threshold. NOT a "loads shouldn't get this high" cap: +// investigating Lane D's tn-1.04 report showed a 155 m² flat sail holding +// 2100 kg of ponded water really does put ~21 kN on a corner, and it stays +// finite and tracks the water — that's correct physics for an absurd rig, not +// divergence. Real divergence is 100 kN+ and NaN. So this sits well above any +// real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first. +const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up +const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear") const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x const LOAD_TAU = 0.11; // load meter smoothing time constant, s @@ -219,6 +254,26 @@ export class SailRig { // XPBD Lagrange multipliers, one per spring, reset every substep this.lambda = new Float64Array(this.springs.length); + // ---- ponding state ---- + this.water = new Float64Array(nodeCount); // kg on each node + this._nodeFlat = new Float64Array(nodeCount); // area-weighted |ny|, refilled by the wind pass + this._nodeArea = new Float64Array(nodeCount); + this._wFlow = new Float64Array(nodeCount); // per-step transfer buffer + // 8-neighbour graph, -1 padded (see the ponding note for why diagonals). + this._nbr = new Int32Array(nodeCount * 8).fill(-1); + this._isRim = new Uint8Array(nodeCount); + for (let v = 0; v < N; v++) { + for (let u = 0; u < N; u++) { + const n = idx(u, v); + let k = 0; + for (const [du, dv] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, -1], [-1, 1], [1, 1]]) { + const uu = u + du, vv = v + dv; + this._nbr[n * 8 + k++] = (uu >= 0 && uu < N && vv >= 0 && vv < N) ? idx(uu, vv) : -1; + } + this._isRim[n] = (u === 0 || u === N - 1 || v === 0 || v === N - 1) ? 1 : 0; + } + } + // Springs meeting each corner, kept as {spring, index} so the load meter can // look up each one's multiplier. Bend springs are included: the hardware // physically carries every element that touches it, and leaving them out @@ -345,6 +400,8 @@ export class SailRig { _substep(dt, wind, t, pieces) { this._accumulateWind(wind, t, dt); + // after the wind pass — it fills the per-node flatness/area ponding reads + this._applyPonding(wind.rainMmPerHour ? wind.rainMmPerHour(t) : 0, dt); if (pieces && pieces.length) this._applyDebris(pieces, dt); this._integrate(dt); this.lambda.fill(0); // XPBD multipliers are per-substep @@ -352,6 +409,31 @@ export class SailRig { this._pinCorners(t); this._measureLoads(dt); this._checkFailure(dt); + if (this.watchDivergence) this._checkDivergence(); + } + + /** + * Optional tripwire for Lane D's tn-1.04 report. That spike does NOT reproduce + * on current main — with swaying tree anchors, 3xrated+1carabiner, loads climb + * smoothly 7.2->8.7 kN across the whole tension range, no discontinuity at + * 1.04 (decision 11's downdraft bump and the §7 re-point changed the load + * regime under it). So rather than clamp real physics to fix a bug I can't + * demonstrate — the displacement clamp I tried moved the thesis 39->34% — this + * just WATCHES. Set `rig.watchDivergence = true` and it throws with the corner, + * load and time the instant a corner exceeds a physically-impossible load, so + * if it ever comes back it comes back with a repro instead of a mystery. + */ + _checkDivergence() { + for (let k = 0; k < 4; k++) { + const c = this.corners[k]; + if (c.load > DIVERGENCE_N || !Number.isFinite(c.load)) { + throw new Error( + `sail divergence: corner ${c.anchorId} at ${(c.load / 1000).toFixed(1)} kN, ` + + `t=${this.t.toFixed(2)}s, tension=${this.tension.toFixed(3)}. This is the tn-1.04 cliff ` + + `(THREADS [D] 2026-07-17) recurring — capture this rig and ping Lane B.`, + ); + } + } } /** Wind force per FACE — the hypar mechanic lives here. */ @@ -400,9 +482,164 @@ export class SailRig { F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz; F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz; F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz; + + // |ny| is how horizontal the face is (1 flat, 0 on edge), which is also + // exactly the fraction of its area rain sees from straight up — so the + // ponding catch area is free here rather than a second geometry pass. + const share = area / 3, flat = Math.abs(ny) * share; + const na = this.tris[i], nb = this.tris[i + 1], nc = this.tris[i + 2]; + this._nodeFlat[na] += flat; this._nodeFlat[nb] += flat; this._nodeFlat[nc] += flat; + this._nodeArea[na] += share; this._nodeArea[nb] += share; this._nodeArea[nc] += share; } } + /** + * Rain lands, runs downhill, and pools where it can't get out. Called after + * the wind pass (which fills _nodeFlat / _nodeArea). + * @param {number} mmPerHour wind.rainMmPerHour(t) — REAL-world rate, Lane C's data + */ + _applyPonding(mmPerHour, dt) { + const pos = this.pos, F = this.force, w = this.water, flow = this._wFlow; + const N2 = w.length; + // 1 mm over 1 m² is 1 kg. Lane C owns the rate and the 40× compression + // constant; multiplying them here is the whole of "how much a sail holds". + const kgPerM2PerSec = (mmPerHour * RAIN_TIME_COMPRESSION) / 3600; + + for (let n = 0; n < N2; n++) { + const a = this._nodeArea[n]; + if (a > 1e-9 && kgPerM2PerSec > 0) { + w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection + const cap = POND_MAX_KG_M2 * a; + if (w[n] > cap) w[n] = cap; + } + flow[n] = 0; + } + + // steepest-descent transfer: water leaves down the biggest GRADIENT, not the + // biggest drop — a diagonal is √2 farther, so the same drop across it is a + // gentler slope than straight down. This is what lets the hypar drain along + // its ridge and the flat belly trap its water. + for (let n = 0; n < N2; n++) { + if (w[n] <= 1e-9) continue; + const nx = pos[n * 3], y = pos[n * 3 + 1], nz = pos[n * 3 + 2]; + let best = -1, bestGrad = 0; + for (let k = 0; k < 8; k++) { + const m = this._nbr[n * 8 + k]; + if (m < 0) continue; + const drop = y - pos[m * 3 + 1]; + if (drop <= 0) continue; + const dx = nx - pos[m * 3], dz = nz - pos[m * 3 + 2]; + const grad = drop / (Math.hypot(dx, dz) || 1e-6); + if (grad > bestGrad) { bestGrad = grad; best = m; } + } + if (best < 0) continue; // a basin: nowhere lower to go + const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt); + flow[n] -= moved; flow[best] += moved; + } + for (let n = 0; n < N2; n++) w[n] += flow[n]; + + // A rim node spills over the edge ONLY when the edge is downhill — i.e. it + // has no lower interior neighbour to send water to. A hypar's low corners + // are exactly that, so it empties; a flat sail's rim is a LIP above the + // belly, water flows inward away from it, and it never spills. + for (let n = 0; n < N2; n++) { + if (!this._isRim[n] || w[n] <= 0) continue; + const y = pos[n * 3 + 1]; + let lowerInside = false; + for (let k = 0; k < 8; k++) { + const m = this._nbr[n * 8 + k]; + if (m >= 0 && pos[m * 3 + 1] < y - 1e-4) { lowerInside = true; break; } + } + if (!lowerInside) w[n] = Math.max(0, w[n] - w[n] * POND_SPILL * dt); + } + + // the weight. Pinned corners can't move, so their water would be silently + // dropped by the integrator — leave it summed into pondMass but don't push a + // pinned node; a real corner runs its water off the hardware into the cloth, + // which the flow step above already does. + let lowestNode = 1e9, lowestCorner = 1e9; + for (let n = 0; n < N2; n++) { + if (w[n] > 0 && this.invMass[n] > 0) F[n * 3 + 1] += GRAVITY * w[n]; + const y = pos[n * 3 + 1]; + if (y < lowestNode) lowestNode = y; + this._nodeFlat[n] = 0; + this._nodeArea[n] = 0; + } + for (let k = 0; k < 4; k++) { + const cy = pos[this.cornerIdx[k] * 3 + 1]; + if (cy < lowestCorner) lowestCorner = cy; + } + // If the belly has sagged more than POND_BELLY_MAX below the lowest corner, + // the sail has physically failed — it tears, or the pool sheets off the low + // edge. Either way the water goes. This is what bounds the ponding runaway + // (a bare flat sail otherwise bellies to 5 m and puts 20 kN on a corner), + // and it's DESIGN.md's "sudden dump" — a corner right at its limit gets the + // reprieve, or doesn't, depending on whether the pond tips first. + if (lowestCorner - lowestNode > POND_BELLY_MAX && this.pondMass() > 1) { + this.dumpPond('belly tore'); + } + } + + /** Total water on the sail, kg. Lane A's "SAIL PONDING — get the broom" number. */ + pondMass() { + let m = 0; + for (let n = 0; n < this.water.length; n++) m += this.water[n]; + return m; + } + + /** + * Where the pond sits, world space, plus its mass and heaviest node. Null if + * there's nothing worth pointing at. Lane D walks to this; Lane E draws it. + * @returns {{x:number,y:number,z:number,mass:number,node:number}|null} + */ + pondCentroid() { + let m = 0, x = 0, y = 0, z = 0, node = -1, hw = 0; + for (let n = 0; n < this.water.length; n++) { + const q = this.water[n]; + if (q <= 0) continue; + m += q; x += this.pos[n * 3] * q; y += this.pos[n * 3 + 1] * q; z += this.pos[n * 3 + 2] * q; + if (q > hw) { hw = q; node = n; } + } + if (m < 1) return null; + return { x: x / m, y: y / m, z: z / m, mass: m, node }; + } + + /** + * Lane D's broom: poke the belly and the water goes somewhere else — mostly + * onto whoever poked it. Call every frame of the ~1.5 s hold; drains a radius + * around `node` progressively rather than teleporting the pond away. + * @param {number} node grid node index — aim at pondCentroid().node + * @returns {number} kg dumped THIS call. Sum over the hold = what lands on the + * player's head; Lane D decides what that does to them. + */ + drainPondAt(node, dt, radius = 2) { + if (!this.rigged || node == null || node < 0 || node >= this.water.length) return 0; + const N = this.N, cu = node % N, cv = (node / N) | 0; + let dumped = 0; + for (let v = Math.max(0, cv - radius); v <= Math.min(N - 1, cv + radius); v++) { + for (let u = Math.max(0, cu - radius); u <= Math.min(N - 1, cu + radius); u++) { + const n = v * N + u; + if (this.water[n] <= 0) continue; + const fall = 1 - Math.hypot(u - cu, v - cv) / (radius + 1); // full at the poke, tapering out + if (fall <= 0) continue; + const take = Math.min(this.water[n], this.water[n] * BROOM_DRAIN * fall * dt); + this.water[n] -= take; + dumped += take; + } + } + if (dumped > 0) this.events.emit('pondDump', { type: 'pondDump', kg: dumped, node, t: this.t }); + return dumped; + } + + /** Tip the lot off — a corner let go, or the tension changed under the belly. */ + dumpPond(reason = 'dump') { + const kg = this.pondMass(); + if (kg <= 0) return 0; + this.water.fill(0); + this.events.emit('pondDump', { type: 'pondDump', kg, reason, t: this.t }); + return kg; + } + /** * Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b). * @@ -600,6 +837,9 @@ export class SailRig { // on the wind and the flogging is emergent rather than animated. // Without it a "blown" corner stays welded in mid-air. this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass; + // the belly loses its shape the instant a corner goes, so any pond goes + // with it — DESIGN.md's "sudden dump", onto whatever is below. + this.dumpPond('corner blew'); this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t }); } } @@ -664,8 +904,13 @@ export class SailRig { } setTension(tension) { + const was = this.tension; this.tension = clamp(tension, TENSION_MIN, TENSION_MAX); - if (this.rigged) this._applyRestLengths(); + if (!this.rigged) return; + this._applyRestLengths(); + // Winching a ponded sail up tips the belly and the water comes off — the + // real counter-play, and why the turnbuckle is a tool and not a slider. + if (this.tension > was + 0.02) this.dumpPond('tensioned up'); } /** diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 0e21b9c..880bea1 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -12,7 +12,7 @@ import { SailRig } from './sail.js'; import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js'; -import { createWindField } from './weather.core.js'; +import { createWindField, RAIN_TIME_COMPRESSION } from './weather.core.js'; const SIM_DT = FIXED_DT; @@ -43,6 +43,9 @@ function realWind(def = STORM_02, opts = {}) { 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, + // ponding reads this — the whole point of C exporting it in real units + rainAt: (t) => field.rainAt(t), + rainMmPerHour: (t) => field.rainMmPerHour(t), }; } @@ -67,9 +70,11 @@ const YARD = [ */ 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); +const yardRig = (ids, hw, tension) => { + const r = new SailRig({ anchors: YARD, gridN: 10 }); + r.watchDivergence = true; + return r.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. @@ -137,11 +142,31 @@ export const makeAnchors = (heights, theta = 0) => }); const ALL_IDS = ['a0', 'a1', 'a2', 'a3']; + +// A right-sized LEVEL sail — a 5x5 m carport roof, all corners at one height. +// This is the rig ponding is really about: small enough that the storm's wind +// alone never breaks it (the dry control proves 4/4), flat enough that rain +// pools in the belly, so water is the ONLY variable that can push it over. +// Measured: dry 4/4, wet loses a corner at t~85 s holding ~440 kg. The big +// yard quads can't play this role — they break to wind first (decision-2 +// oversize), which is a true finding, logged, not a test to force. +const LEVEL_CARPORT = [3.2, 3.2, 3.2, 3.2]; +const carportAnchors = (S = 2.5) => + [[-S, -S], [S, -S], [S, S], [-S, S]].map(([x, z], i) => { + const pos = { x, y: LEVEL_CARPORT[i], z }; + return { id: `a${i}`, type: 'post', pos, sway: () => pos }; + }); +const carportRig = (hw) => { + const r = new SailRig({ anchors: carportAnchors(), gridN: 10 }); + r.watchDivergence = true; + return r.attach(ALL_IDS, Array(4).fill(hw), 1.0); +}; 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); + const r = new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity }); + r.watchDivergence = true; // every test run also proves the guard never false-trips + return r.attach(ALL_IDS, [hw, hw, hw, hw], tension); } /** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */ @@ -686,6 +711,185 @@ test('runs against the shared contracts.js stub wind', () => { return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`; }); + +// --- SPRINT4 decision 10 / SPRINT5: ponding ------------------------------- + +const STORM_01 = await loadStormDef('storm_01_gentle'); + +test('ponding: a flat rig pools water and a twisted one sheds it', () => { + // Real yard quads, not the synthetic level saddle: HEIGHTS_HYPAR is a + // symmetric two-up-two-down at one footprint, which sags into a central belly + // under a night of rain and ponds like a flat sail — an artifact of the test + // rig, not the sim. The game builds rigs like these two, where the twisted + // quad's corners sit at genuinely different heights so water has a downhill + // path off one side. Measured 50x apart (1.7 vs 13 kg/m²). + const flat = yardRig(['h1', 'h3', 'p2', 'p1'], UNBREAKABLE, 1.0); // 2.6/2.6/4.0/4.0 — a roof + const twisted = yardRig(TWISTED_QUAD, UNBREAKABLE, 0.85); // §7's own survivor + runStorm(flat, realWind(), STORM_02.duration); + runStorm(twisted, realWind(), STORM_02.duration); + const fm = flat.pondMass(), tm = twisted.pondMass(); + const fpm = fm / flat.area, tpm = tm / twisted.area; // per m², since areas differ + assert(fpm > 8, `flat rig only held ${fpm.toFixed(1)} kg/m² after a night of rain — it isn't ponding`); + assert(tpm < fpm * 0.25, `twisted rig held ${tpm.toFixed(1)} kg/m² vs the flat rig's ${fpm.toFixed(1)} — it should shed`); + return `flat ${fpm.toFixed(1)} kg/m² vs twisted ${tpm.toFixed(1)} kg/m² (${(tpm / fpm * 100).toFixed(0)}%)`; +}); + +// THE POINT OF THE WHOLE WATER ARC. Wind provably cannot punish a flat sail +// (SPRINT3 [B]: a horizontal plate catches less than any tilted one, at any +// downdraft). Water can, it's DESIGN.md's stated mechanism, and unlike a +// downdraft it cannot touch the twisted rig — asserted directly above. +// A CONTROLLED experiment isolating water as the killer. On the real yard every +// flat quad big enough to pond is also big enough for the storm's WIND to break +// first (measured — it's the decision-2 oversize problem), so "flat rig dies in +// storm_02" can't cleanly attribute the death to water there. Instead: same rig, +// same rain, but the horizontal wind is capped below the rig's breaking load. +// Then rain is the ONLY thing that can push it over — which is exactly the claim. +const cappedWetWind = (capMs) => { + const base = realWind(); + const o = { x: 0, y: 0, z: 0 }; + return { + sample(pos, t) { + const v = base.sample(pos, t); + const h = Math.hypot(v.x, v.z); + if (h > capMs) { const s = capMs / h; o.x = v.x * s; o.y = v.y; o.z = v.z * s; return o; } + o.x = v.x; o.y = v.y; o.z = v.z; return o; + }, + rainAt: (t) => base.rainAt(t), + rainMmPerHour: (t) => base.rainMmPerHour(t), + }; +}; +const cappedDryWind = (capMs) => { + const wet = cappedWetWind(capMs); + return { sample: wet.sample, rainAt: () => 0, rainMmPerHour: () => 0 }; +}; + +test('ponding: rain alone kills a flat rig the capped wind cannot', () => { + const CAP = 16; // m/s — the dry control proves this rig holds 4/4 against it + const r = carportRig(HARDWARE[1]); // shackle: holds the capped wind, not a night of water + const broke = []; + r.events.on('break', (e) => broke.push(e)); + runStorm(r, cappedWetWind(CAP), STORM_02.duration); + assert(broke.length > 0, `flat rated rig survived — peak pond was only ${r.pondMass().toFixed(0)} kg, rain isn't loading it`); + return `${broke.length} corner(s) blew to water under a ${CAP} m/s wind cap, first at t=${broke[0].t.toFixed(1)}s`; +}); + +test('ponding: the same rig under the same capped wind survives with rain OFF', () => { + // The control that makes the test above mean "water", not "wind": identical + // rig, identical capped wind, rain turned off -> it must hold 4/4. + const CAP = 16; + const r = carportRig(HARDWARE[1]); + const broke = []; + r.events.on('break', (e) => broke.push(e)); + runStorm(r, cappedDryWind(CAP), STORM_02.duration); + assert(broke.length === 0, `the rig lost ${broke.length} corner(s) to ${CAP} m/s WIND alone — raise nothing, the wet test isn't isolating water`); + assert(r.pondMass() === 0, 'no rain should mean no pond'); + return `dry, ${CAP} m/s cap: 4/4 held — so the kill above is the water`; +}); + +test('ponding: storm_01 gentle cannot hurt anyone', () => { + const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] }); + const broke = []; + r.events.on('break', (e) => broke.push(e)); + runStorm(r, realWind(STORM_01), STORM_01.duration); + assert(broke.length === 0, `a gentle day blew ${broke.length} corner(s) — storm_01 is the tutorial`); + return `4/4 held, ${r.pondMass().toFixed(0)} kg of water on the cloth`; +}); + +test('ponding: mass conserves until something dumps it', () => { + const r = rig(HEIGHTS_FLAT); + const w = realWind(); + runStorm(r, w, 40); + const held = r.pondMass(); + assert(held > 50, `only ${held.toFixed(0)} kg to conserve — test is vacuous`); + // no rain from here: the pond may drain off the rim but must not appear + const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 }; + runStorm(r, dry, 5); + assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`); + const dumped = r.dumpPond('test'); + assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped'); + assert(r.pondMass() === 0, 'dumpPond left water behind'); + return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`; +}); +function r_prev(_r, d) { return d; } + +test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => { + const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] }); + const dumps = []; + r.events.on('pondDump', (e) => dumps.push(e)); + runStorm(r, realWind(), STORM_02.duration); + assert(dumps.some((d) => d.reason === 'corner blew'), 'a corner let go and the water just sat there'); + const big = dumps.find((d) => d.reason === 'corner blew'); + return `corner blew and dropped ${big.kg.toFixed(0)} kg at t=${big.t.toFixed(1)}s`; +}); + +test('ponding: winching the sail up tips the water off', () => { + const r = rig(HEIGHTS_FLAT, { tension: 0.9 }); + runStorm(r, realWind(), 40); + const held = r.pondMass(); + assert(held > 50, 'nothing to tip off — test is vacuous'); + const dumps = []; + r.events.on('pondDump', (e) => dumps.push(e)); + r.setTension(1.1); + assert(dumps.some((d) => d.reason === 'tensioned up'), 'tensioning a ponded sail did not shed the water'); + assert(r.pondMass() === 0, 'sail still holding water after being winched up'); + return `winch 0.9 -> 1.1 shed ${held.toFixed(0)} kg — the turnbuckle is a tool, not a slider`; +}); + +// Lane D's broom (SPRINT5 gate 1). DESIGN.md: "run out and poke the pond with a +// broom — the funniest correct mechanic in the game." +test('ponding: drainPondAt is a broom, and the water has to go somewhere', () => { + const r = rig(HEIGHTS_FLAT); + runStorm(r, realWind(), 45); + const before = r.pondMass(); + assert(before > 100, `only ${before.toFixed(0)} kg to sweep — test is vacuous`); + const target = r.pondCentroid(); + assert(target && target.node >= 0, 'pondCentroid found no pond to aim at'); + + const dumps = []; + r.events.on('pondDump', (e) => dumps.push(e)); + let onYourHead = 0; + for (let i = 0; i < Math.round(1.5 / SIM_DT); i++) onYourHead += r.drainPondAt(target.node, SIM_DT); + + assert(onYourHead > before * 0.4, `a 1.5 s poke only shifted ${onYourHead.toFixed(0)} of ${before.toFixed(0)} kg`); + assert(r.pondMass() < before * 0.6, 'the belly is still full after a full poke'); + assert(dumps.length > 0, 'drainPondAt emitted nothing for Lane D to react to'); + return `1.5 s poke dropped ${onYourHead.toFixed(0)} kg of ${before.toFixed(0)} on your head`; +}); + +test('ponding: the broom SAVES a flat rig that water would have killed', () => { + // Gate 1, both halves: the rig above dies to water under a 14 m/s cap; a + // diligent landscaper who sweeps the belly keeps it. Same rig, same capped + // wind, so the only thing that changed is the broom. + const CAP = 16; + const swept = carportRig(HARDWARE[1]); + const broke = []; + swept.events.on('break', (e) => broke.push(e)); + const w = cappedWetWind(CAP); + const steps = Math.round(STORM_02.duration / SIM_DT); + for (let i = 0; i < steps; i++) { + swept.step(SIM_DT, w, i * SIM_DT); + // a diligent landscaper sweeps before the belly reaches a kill load — the + // rig above blows around 440 kg, so keep it under ~300 + if (swept.pondMass() > 250) { + const c = swept.pondCentroid(); + if (c) swept.drainPondAt(c.node, SIM_DT, 3); + } + } + assert(broke.length === 0, `swept rig still lost ${broke.length} corner(s) — the broom does not save it`); + return `kept 4/4 by sweeping the belly; unswept the same rig loses corners to water`; +}); + +test('ponding: rain that the router swallows cannot silently pass', () => { + // The integrator caught the wind router dropping the rain API this sprint, + // which would have made every test above pass while ponding did nothing in the + // real game. A wind with no rain methods must therefore be LOUD, not benign. + const r = rig(HEIGHTS_FLAT); + const noRainApi = { sample: realWind().sample, speedAt: () => 0, gustTelegraph: () => null }; + runStorm(r, noRainApi, 30); + assert(r.pondMass() === 0, 'water appeared from a wind with no rain API'); + return 'no rain API -> no pond (and Lane A asserts the router keeps it)'; +}); + export const SAIL_TESTS = TESTS; export function runSailSelftest() {