diff --git a/THREADS.md b/THREADS.md index adfa0b9..2099430 100644 --- a/THREADS.md +++ b/THREADS.md @@ -576,3 +576,71 @@ Format: `[lane letter] YYYY-MM-DD — note` the gust tell. Cheap fixes in your court: lift the hemisphere light's ground colour, or I can bake a little emissive into the leaf material so gums stay readable from underneath. Say the word and it's one constant in my palette — not touching it unprompted since lighting is yours. + above was measured through them. (Confirmed on my side — every number below came through them too.) + +[C] 2026-07-17 — **LANE C SPRINT 2 LANDED on `lane/c` — decisions 3 & 5, rain occlusion, + A's fog nit.** + Selftest **134/0/0** (was 121; +13 Lane C asserts). Rebased on gate-1 main. Four pushes, small commits. + Thanks A for doing my §C.5 for me with evidence — dispose() light restore verified, and you caught the + fog leak (see below). + +[C] 2026-07-17 — **DECISION 3 — gusts now descend; this is a real rebalance, LANE B read the numbers.** + Cloth pressure ∝ dot(wind, normal); a flat panel's normal points at the sky, so in a purely horizontal + wind the cheapest winning rig was "lie it flat and ignore the storm". Fixed: per-gust downdraft fraction + in storm JSON (`gusts.downdraft`, 0..1, validated), storm_02 0.3 / storm_01 0.18 / default 0.25, each + gust varying 0.6–1.4×. `wind.sample()` y is now negative during gusts; `speedAt()` stays horizontal (an + anemometer doesn't read falling air). Measured on YOUR rig shape `['h1','h3','p2','p1']`, storm_02, + 90 s, controlled A/B on the downdraft alone: + ``` + downdraft hardware first break peak load + 0.0 rated shackle never 1929 N + 0.3 rated shackle never 4165 N ← +116% peak, still survives + 0.0 shackle never 1929 N + 0.3 shackle t=20.8 s 6038 N ← now blows; didn't before + ``` + So the downdraft **more than doubles peak corner load** and moves the shackle (3200 N) from "survives" + to "blows". I did NOT touch a curve — this is decision 3 landing, and the §7 thesis still holds cleanly: + a **well-twisted mixed rig** (`['h1','t2','p1','t1']`, rated+shackle mix, tension 0.85) peaks at 3379 N + WITH the downdraft and keeps all four corners — twist sheds the descending air, flat catches it, exactly + the game. The downdraft sharpens the choice, it doesn't break it. **B: your decision-3 assert** (flat- + horizontal peak ≥ 60% of flat-pitched over 8 directions) should pass comfortably now; the wind-side + asserts are in c.test ('gusts carry a downdraft…', 'downdraft does not re-time the storm'). + ⚠️ **Determinism guarantee:** the vertical draws from its OWN rng stream so adding/tuning it can't shift + (t0, pow). Your hand-verified cascade (carabiner t=45.4, p2 t=56) is untouched — asserted. + +[C] 2026-07-17 — **DECISION 5 — `debris.pieces` FROZEN in contracts.js. B, this is your seam.** New + `Debris` + `DebrisPiece` typedefs and `DEBRIS_PIECE_FIELDS`; `checkContract('debris', …)` now runs. + Shape you can rely on inside `sail.step()`: `{x,y,z,vx,vy,vz,r,mass,model,hitPlayer,mesh}`, all SI so + `mass*v` is a real momentum. Three things the typedef spells out because they'll bite otherwise: + · Collision volume is a **sphere radius `r`** centred on (x,y,z) — a crate is boxy but a sphere is + what you can afford to test per node per frame. `y` is the CENTRE, rests at `heightAt(x,z)+r`. + · The array is **mutated in place** — pieces splice out on despawn. Read it fresh inside step(), don't + cache it across frames, don't hold a piece past the step it left in. `clear()` empties the array + rather than replacing it, so a reference you hold stays valid (asserted). + · Don't move `piece.mesh` — Lane C drives it from the sim each step; you'd be fighting me. + +[C] 2026-07-17 — **RAIN STOPS AT THE CLOTH (§C.3).** Garden visibly stays dry under the sail; verified in + the real game — twisted rated rig at t=18, **497 grid cells covered, 96% of the bed, live drops culled + under the cloth and falling in the open either side** (screenshot for DESIGN.md). Cost 0.041 ms/rebuild + ×10/s = **0.41 ms/s**, negligible vs your 0.63 ms frame. Reads `rig.pos`/`rig.tris` only — nothing new + from B. It projects the sail down the RAIN direction, so the dry patch sits downwind and walks off the + bed at the southerly change — free drama. + ❓ **LANE A — design call, not mine: which shadow drives garden HP?** `skyfx.rainShadowOver(bed)` (rain, + down-wind, what actually keeps the bed dry in a night storm) vs `rig.coverageOver(bed, world.sunDir)` + (sun, which at night is a number about nothing). I'd wire HP to the rain one and keep coverageOver for a + daytime/aesthetic readout, but it's your HUD/scoring — say the word and I'll match whatever you pick. + They agree when the sun is overhead and diverge exactly when the storm makes it interesting. + +[C] 2026-07-17 — **FOG — fixed, thanks A.** `dispose()` captured `scene.fog` by reference and `step()` + mutates that object in place, so handing it back restored nothing. Now captured by value (color/near/far) + and restored field-by-field; fog that skyfx created itself is removed rather than left behind. Asserted + in c.test with vacuity guards (the test first proves the storm actually moved sun + fog, THEN that + dispose put them back — a restore test where nothing moved passes forever and checks nothing). Your + rebuild-on-phase-change path is clean now in both directions. + +[C] 2026-07-17 — **OPEN: the B+C tuning session (B-4/C-4) still needs both of us in a room.** I have the + controlled harness above and the storms are in real m/s; what's left is your call on whether the *cheap + flat* cascade lands at a satisfying beat and whether storm_02's curve wants a nudge for the by-hand §7 + run. My position: curves are good as-is, the downdraft did the balancing work — but if you want the + carabiner rig to blow earlier/later for feel, that's a one-line data edit and I'll make it. Ping when + sail-side tuning is settled and we lock constants together. (weather_demo.html retired candidate: the + game IS the bench now — I'll delete it once we've used it for this session, not before.) diff --git a/web/world/data/storms/storm_01_gentle.json b/web/world/data/storms/storm_01_gentle.json index d76f06a..259194e 100644 --- a/web/world/data/storms/storm_01_gentle.json +++ b/web/world/data/storms/storm_01_gentle.json @@ -13,7 +13,8 @@ "maxGap": 14, "powBase": 2, "powRand": 3, - "powRamp": 2 + "powRamp": 2, + "downdraft": 0.18 }, "dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]], diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 91299ac..0508b82 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -11,13 +11,16 @@ "baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], + "_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.", + "gusts": { "firstAt": 3, "minGap": 5.5, "maxGap": 11, "powBase": 3, "powRand": 5, - "powRamp": 7 + "powRamp": 7, + "downdraft": 0.3 }, "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/contracts.js b/web/world/js/contracts.js index 5219bc3..a310041 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -190,6 +190,48 @@ export class Emitter { * @property {boolean} broken */ +/** + * DEBRIS — Lane C implements. Lane B consumes `pieces` inside sail.step(). + * + * SPRINT2 decision 5: the sail reads the pieces and applies its own impulses, + * rather than debris.js reaching into the cloth. Momentum bookkeeping stays in + * the one integrator that owns the nodes. That makes `pieces` a real contract + * surface, so it is **frozen** here: fields below are what Lane B may rely on. + * + * @typedef {object} Debris + * @property {DebrisPiece[]} pieces + * Live pieces, newest last. The ARRAY IS MUTATED IN PLACE each step — pieces + * are spliced out when they leave the yard, so don't hold a reference to it + * across frames, and don't hold a piece past the step it despawned in. Read it + * fresh inside step(). Order is not stable. + * @property {(dt:number, t:number, world?:object) => void} step Fixed dt. Deterministic. + * @property {(ev:object, t:number) => DebrisPiece} spawn + * @property {(map:Object) => Debris} setModels + * @property {() => void} clear + */ + +/** + * One airborne object. Frozen shape — Lane C will not remove or repurpose these. + * + * The collision volume is a SPHERE of radius `r` centred on (x,y,z): a crate is + * boxy, but a sphere is what you can afford to test against every cloth node, + * every frame. Everything is SI — metres, m/s, kg — so `mass * v` is a real + * momentum you can subtract from. + * + * @typedef {object} DebrisPiece + * @property {number} x + * @property {number} y Centre, not base. Rests at heightAt(x,z) + r. + * @property {number} z + * @property {number} vx + * @property {number} vy + * @property {number} vz + * @property {number} r Collision sphere radius, m. + * @property {number} mass kg. Crate 9, tub 5, bin 14. + * @property {string} model Key into models/debris/, e.g. 'BlueCrate_v2'. + * @property {boolean} hitPlayer Already knocked the player down once. + * @property {THREE.Object3D|null} mesh Render instance. Lane C drives it; don't move it. + */ + /** * PLAYER — Lane D implements. * @@ -258,6 +300,20 @@ export const CONTRACT = { interact: { register: 'function' }, camera: { object: 'object', yaw: 'number', update: 'function' }, game: { phase: 'string', on: 'function' }, + debris: { pieces: 'object', step: 'function', spawn: 'function', setModels: 'function', clear: 'function' }, +}; + +/** + * The frozen DebrisPiece fields (SPRINT2 decision 5). Lane B's sail.step() reads + * these off `debris.pieces` and applies impulses from them, so renaming one is a + * breaking change to someone else's integrator, not a local tidy-up. Asserted + * against live pieces in c.test.js — if this table and debris.js disagree, the + * selftest says so before Lane B's cloth does. + */ +export const DEBRIS_PIECE_FIELDS = { + x: 'number', y: 'number', z: 'number', + vx: 'number', vy: 'number', vz: 'number', + r: 'number', mass: 'number', model: 'string', }; /** diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 030576e..f2f91e4 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -22,6 +22,112 @@ const CALM_SKY = new THREE.Color(0x9fc4e8); const STORM_SKY = new THREE.Color(0x2a2f3a); const NIGHT_SKY = new THREE.Color(0x11141c); +// ------------------------------------------------------------ rain shadow +/** + * Where the sail is keeping the ground dry (SPRINT2 §Lane C.3). + * + * This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so + * the dry patch sits downwind of the cloth and slides across the yard as the + * wind swings — at the southerly change it walks right off the garden, which is + * free drama and the honest physics. + * + * Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is + * ~486 k intersections for an effect nobody inspects closely. Instead we project + * the sail's triangles ALONG the rain onto the ground and rasterise them into a + * coarse grid, a few times a second — the cloth moves slowly next to the rain. + * Per-drop cost is then one projection and one array read. + * + * Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view + * consumes, so this needs nothing new from Lane B. + */ +export class RainShadow { + constructor(o = {}) { + this.n = o.cells ?? 64; // ~0.56 m over a 36 m span + this.half = o.half ?? 18; + this.groundY = o.groundY ?? 0; + this.ceil = new Float32Array(this.n * this.n); // sail height per cell, 0 = open sky + this.live = false; + this.dx = 0; this.dy = -1; this.dz = 0; + } + + _idx(gx, gz) { + const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n); + const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n); + if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1; + return j * this.n + i; + } + + /** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */ + update(rig, dx, dy, dz) { + this.live = false; + if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return; // rain must fall + this.ceil.fill(0); + this.dx = dx; this.dy = dy; this.dz = dz; + + const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n; + const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0]; + for (let i = 0; i < tris.length; i += 3) { + for (let k = 0; k < 3; k++) { + const a = tris[i + k] * 3; + const vy = pos[a + 1]; + const tt = (vy - this.groundY) / -dy; // slide down the rain to the ground + gx[k] = pos[a] + dx * tt; + gz[k] = pos[a + 2] + dz * tt; + gy[k] = vy; + } + const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]); + if (Math.abs(d) < 1e-9) continue; // degenerate once projected + + const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]); + const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]); + for (let px = minX; px <= maxX + cellW; px += cellW) { + for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) { + const c = this._idx(px, pz); + if (c < 0) continue; + // barycentric, with a little slop so cracks between tris don't leak rain + const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d; + const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d; + const l3 = 1 - l1 - l2; + if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue; + const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2]; + if (y > this.ceil[c]) this.ceil[c] = y; + } + } + this.live = true; + } + } + + /** Has a drop here already been stopped by the cloth? */ + occluded(x, y, z) { + if (!this.live) return false; + const tt = (y - this.groundY) / -this.dy; + const c = this._idx(x + this.dx * tt, z + this.dz * tt); + if (c < 0) return false; + const ceil = this.ceil[c]; + return ceil > 0 && y < ceil; // above the cloth it hasn't hit yet + } + + /** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */ + fractionOver(rect, cols = 6, rows = 4) { + if (!this.live) return 0; + let hit = 0; + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w; + const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d; + const c = this._idx(x, z); + if (c >= 0 && this.ceil[c] > 0) hit++; + } + } + return hit / (cols * rows); + } +} + +/** Rain velocity, m/s. One definition, used by the drops and by the shadow. */ +function rainVelocity(w, intensity, out) { + return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55); +} + // ---------------------------------------------------------------- rain function createRain(opts) { const max = opts.maxDrops ?? 3000; @@ -55,22 +161,29 @@ function createRain(opts) { const q = new THREE.Quaternion(); const up = new THREE.Vector3(0, 1, 0); const vel = new THREE.Vector3(); + const unit = new THREE.Vector3(); const scale = new THREE.Vector3(1, 1, 1); const zero = new THREE.Vector3(); + // zero-scale: an instance that renders to nothing + const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0); return { mesh, - /** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */ - step(dt, camPos, w, intensity) { + /** + * @param {THREE.Vector3} camPos + * @param {THREE.Vector3} w local wind + * @param {RainShadow} [shadow] drops under the cloth are not drawn + */ + step(dt, camPos, w, intensity, shadow) { const n = Math.floor(max * clamp01(intensity)); mesh.count = n; if (n === 0) return; - const fall = 9 + intensity * 4; // rain leans into the wind; that lean IS the readout of how hard it's blowing - vel.set(w.x * 0.55, -fall, w.z * 0.55); + rainVelocity(w, intensity, vel); + const fall = -vel.y; const speed = vel.length() || 1; - q.setFromUnitVectors(up, vel.clone().divideScalar(speed)); + q.setFromUnitVectors(up, unit.copy(vel).divideScalar(speed)); // streak stretches with speed — drizzle is dots, a squall is lines scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1); m.compose(zero, q, scale); @@ -91,6 +204,15 @@ function createRain(opts) { if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height; + // Under the cloth this drop was stopped up there. Keep simulating it — + // it wraps back to the top and rains again beyond the sail's edge — but + // don't draw it. A degenerate matrix is cheaper than reshuffling the + // instance list, and InstancedMesh has no per-instance visibility. + if (shadow && shadow.occluded(px[i], py[i], pz[i])) { + mesh.setMatrixAt(i, HIDDEN); + continue; + } + m.elements[12] = px[i]; m.elements[13] = py[i]; m.elements[14] = pz[i]; @@ -322,6 +444,9 @@ export function createSkyFx(o = {}) { const rain = createRain({ groundY: o.groundY ?? 0 }); if (scene) scene.add(rain.mesh); + const shadow = new RainShadow({ groundY: o.groundY ?? 0 }); + const rainDir = new THREE.Vector3(); + let shadowTick = 0; const audio = createAudio((wind && wind.seed) || 1); @@ -337,10 +462,19 @@ export function createSkyFx(o = {}) { dome.renderOrder = -1; if (scene) scene.add(dome); - // remember what world.js handed us, so dispose() puts it back exactly + // Remember what world.js handed us, so dispose() puts it back exactly. + // Fog is captured BY VALUE, not by reference: step() mutates that very object + // in place, so `scene.fog = original.fog` restores the object we just spent a + // storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog + // stayed where the storm left it. Harmless today only because the next skyfx + // immediately re-drives it, which is exactly the kind of bug that waits. + const ownsFog = !!scene && !scene.fog; const original = { background: scene ? scene.background : null, fog: scene ? scene.fog : null, + fogColor: scene && scene.fog ? scene.fog.color.clone() : null, + fogNear: scene && scene.fog ? scene.fog.near : 0, + fogFar: scene && scene.fog ? scene.fog.far : 0, sun: sun ? sun.intensity : 0, hemi: hemi ? hemi.intensity : 0, }; @@ -360,9 +494,22 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, + rain, audio, dome, shadow, get flash() { return flash; }, + /** + * 0..1 of a ground rect the sail is keeping dry, right now. + * + * Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the + * SUN shadow — the summer-afternoon question. This is the RAIN shadow, which + * arrives along the wind, sits downwind of the cloth, and walks across the + * yard when the wind swings. During a storm at night the sun shadow is a + * number about nothing; this is the one that says whether the garden is + * getting hit. Which of the two drives garden HP is a design call, not mine — + * flagged in THREADS. Cheap either way: reads the grid we already built. + */ + rainShadowOver(rect) { return shadow.fractionOver(rect); }, + /** Wire to the first click/keydown — browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, @@ -422,7 +569,16 @@ export function createSkyFx(o = {}) { domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; // --- rain --- - rain.step(dt, camPos, w, intensity); + // Rebuild the shadow a few times a second, not every frame: the cloth + // moves slowly next to the rain, and this is the only part that costs. + shadowTick -= dt; + if (shadowTick <= 0) { + shadowTick = 0.1; + rainVelocity(w, intensity, rainDir); + const len = rainDir.length() || 1; + shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len); + } + rain.step(dt, camPos, w, intensity, shadow); // --- audio --- audio.setLevels(speed, intensity); @@ -454,7 +610,14 @@ export function createSkyFx(o = {}) { scene.remove(rain.mesh); scene.remove(dome); scene.background = original.background; - scene.fog = original.fog; + if (ownsFog) { + scene.fog = null; // we brought it; we take it + } else if (original.fog) { + scene.fog = original.fog; + original.fog.color.copy(original.fogColor); + original.fog.near = original.fogNear; + original.fog.far = original.fogFar; + } } if (sun) sun.intensity = original.sun; if (hemi) hemi.intensity = original.hemi; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index a74f905..e82d024 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -15,8 +15,10 @@ import * as THREE from '../../vendor/three.module.js'; import { assert, fixedLoop } from '../testkit.js'; -import { FIXED_DT, checkContract } from '../contracts.js'; +import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; +import { createDebris } from '../debris.js'; +import { createSkyFx, RainShadow } from '../skyfx.js'; import { weatherCases } from './weather.selftest.js'; const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; @@ -43,12 +45,34 @@ export default async function run(t) { const pos = new THREE.Vector3(3, 0, -2); const a = wind.sample(pos, 12.5); assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3'); - assert(a.y === 0, `wind should be horizontal, got y=${a.y}`); + assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z), + `sample returned a non-finite vector: ${a.x},${a.y},${a.z}`); // out param must not change the answer, only where it lands const out = new THREE.Vector3(); const b = wind.sample(pos, 12.5, out); assert(b === out, 'out param was ignored'); - assert(a.x === b.x && a.z === b.z, 'out param changed the result'); + assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result'); + }); + + // This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2 + // decision 3 made that false on purpose: gusts now descend, which is what makes + // a flat sail pay. Keeping the useful half — y is downward-or-zero, never up, + // and never garbage — so player shove and rain angle can still trust the sign. + t.test('vertical wind is downward-only, and only during gusts', () => { + const wind = createWind(storms.storm_02_wildnight); + const pos = new THREE.Vector3(0, 1.7, 0); + const v = new THREE.Vector3(); + let sawDown = false; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + wind.sample(pos, time, v); + assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`); + if (v.y < -1) sawDown = true; + }); + assert(sawDown, 'never saw a downdraft worth the name in a whole wild night'); + // and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD + const calm = wind.speedAt(pos, 0.5); + assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9, + 'speedAt() is not the horizontal magnitude of sample()'); }); // Lifted from a.test.js onto the real wind (Lane A's note in this file's @@ -70,6 +94,122 @@ export default async function run(t) { assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`); }); + // --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen --- + t.test('debris conforms and its pieces match the frozen shape', () => { + const wind = createWind(storms.storm_02_wildnight); + const debris = createDebris({ wind }); + assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; ')); + + const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40); + for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) { + const got = typeof p[field]; + assert(got === want, `piece.${field} is ${got}, contract says ${want}`); + if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`); + } + assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces'); + assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with'); + + // The array is mutated in place and pieces are spliced on despawn — that's + // documented, and B reads it fresh inside step(). Prove clear() empties it + // rather than swapping in a new array behind their reference. + const ref = debris.pieces; + debris.clear(); + assert(ref === debris.pieces && debris.pieces.length === 0, + 'clear() replaced the pieces array instead of emptying it — B holds a reference'); + }); + + // Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path. + // They verified sun/hemi restore exactly and spotted that fog didn't; this pins + // both. The vacuity guards matter — a restore test where nothing ever moved is + // a test that passes forever and checks nothing. + t.test('skyfx.dispose() hands the scene back exactly as it found it', () => { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x9fc4e8); + scene.fog = new THREE.Fog(0x9fc4e8, 30, 140); + const camera = new THREE.PerspectiveCamera(); + const sun = new THREE.DirectionalLight(0xfff4e0, 2.0); + const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8); + const before = { + bg: scene.background, fogColor: scene.fog.color.getHex(), + fogNear: scene.fog.near, fogFar: scene.fog.far, + sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length, + }; + + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ scene, camera, wind, sun, hemi }); + fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {})); + + assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing'); + assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing'); + + sky.dispose(); + assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`); + assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`); + assert(scene.background === before.bg, 'scene.background not restored'); + assert(scene.fog.color.getHex() === before.fogColor, + `fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`); + assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar, + `fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`); + assert(scene.children.length === before.children, + `skyfx left ${scene.children.length - before.children} object(s) in the scene`); + }); + + // --- SPRINT2 §Lane C.3: rain has to stop at the cloth --- + // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing + // under test is the projection, and a flat panel makes the right answer + // something you can work out on paper. + const PANEL = { + pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]), + tris: [0, 1, 2, 0, 2, 3], + }; + + t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => { + const s = new RainShadow(); + s.update(PANEL, 0, -1, 0); + assert(s.live, 'shadow never built'); + assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling'); + assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling'); + assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet'); + assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled'); + assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled'); + }); + + t.test('rain shadow leans with the rain, and follows the wind round', () => { + const s = new RainShadow(); + // rain driving hard along +x: the dry ground moves +x, out from under the panel + s.update(PANEL, 0.6, -0.8, 0); + const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean + assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`); + assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted'); + + // swing the wind 180° and the patch has to swap sides. This is the southerly + // change: the sail stops covering the bed without a single corner failing. + s.update(PANEL, -0.6, -0.8, 0); + assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round'); + assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung'); + }); + + t.test('rain shadow: no sail, no shelter', () => { + const s = new RainShadow(); + s.update(null, 0, -1, 0); + assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist'); + // and rain that is not falling can't cast a shadow (guards a divide by ~0) + s.update(PANEL, 1, 0, 0); + assert(!s.live, 'horizontal rain projected to infinity instead of bailing out'); + }); + + t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => { + const s = new RainShadow(); + s.update(PANEL, 0, -1, 0); + // the panel spans x,z in [-2,2]; a rect inside it is fully covered + assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1, + 'a rect wholly under the panel is not fully covered'); + assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0, + 'a rect nowhere near the panel is covered'); + const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 }); + assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index 29ca7d6..e0e9ffb 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -280,6 +280,97 @@ export function weatherCases(storms) { assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); }); + // ---- 9. vertical gust structure (SPRINT2 decision 3) ---- + // Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal + // points at the sky, so in a perfectly horizontal wind that dot is ~0 and the + // cheapest winning rig is "lie it flat and ignore the storm" — the opposite of + // the game. Gust fronts are descending air, and descending air hits a flat + // panel square on. Lane B owns the cloth-side assert; these are the wind side. + test('gusts carry a downdraft, and still air does not', () => { + const f = createWindField(storms.storm_02_wildnight); + let peakDown = 0, betweenMax = 0; + for (let t = 0; t <= f.duration; t += DT) { + const v = f.gustVertical(t); + assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`); + const live = f.gusts.some((g) => t > g.t0 && t < g.endAt); + if (live) peakDown = Math.min(peakDown, v); + else betweenMax = Math.max(betweenMax, Math.abs(v)); + } + metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2); + assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`); + assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`); + }); + + test('downdraft tracks its own gust and its JSON fraction', () => { + const def = storms.storm_02_wildnight; + const f = createWindField(def); + const frac = def.gusts.downdraft; + for (const g of f.gusts) { + assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9, + `gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`); + // minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust + const atHold = f.gustVertical(g.t0 + 3); + assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9, + `at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`); + } + }); + + test('downdraft 0 gives a perfectly horizontal wind', () => { + const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + def.gusts.downdraft = 0; + const f = createWindField(def); + const out = { x: 0, y: 0, z: 0 }; + for (let t = 0; t <= f.duration; t += 0.05) { + f.vecAt(2, -1, t, out); + assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`); + } + }); + + test('downdraft does not re-time the storm', () => { + // The vertical draws from its own RNG stream precisely so that adding or + // tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and + // watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft + // tweak silently moving those would be a nasty way to lose an afternoon. + const base = storms.storm_02_wildnight; + const a = createWindField(base); + for (const dd of [0, 0.1, 0.25, 0.5, 1]) { + const d = JSON.parse(JSON.stringify(base)); + d.gusts.downdraft = dd; + const b = createWindField(d); + assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`); + a.gusts.forEach((g, i) => { + assert(g.t0 === b.gusts[i].t0, + `downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`); + assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`); + }); + } + }); + + test('at a gust peak the downdraft is a real fraction of the horizontal', () => { + const f = createWindField(storms.storm_02_wildnight); + const out = { x: 0, y: 0, z: 0 }; + let bestRatio = 0, atT = 0; + for (let t = 0; t <= f.duration; t += DT) { + f.vecAt(0, 0, t, out); + const horiz = Math.hypot(out.x, out.z); + if (horiz < 1) continue; + const r = Math.abs(out.y) / horiz; + if (r > bestRatio) { bestRatio = r; atT = t; } + } + metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3); + assert(bestRatio > 0.12, + `strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`); + }); + + test('validator rejects a bad downdraft', () => { + for (const dd of [-0.1, 1.5, NaN, 'lots']) { + const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + d.gusts.downdraft = dd; + const { ok } = validateStorm(d, 'broken'); + assert(!ok, `validator ACCEPTED downdraft = ${dd}`); + } + }); + return { cases, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 6d4b4ed..43cb02c 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -130,17 +130,29 @@ function sampleAngleCurve(curve, t) { // ---------- gust timeline ---------- // Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON. +export const DEFAULT_DOWNDRAFT = 0.25; + export function buildGustTimeline(def, seed) { const g = def.gusts || {}; const rng = mulberry32(seed >>> 0); + // Vertical draws from its OWN stream, deliberately. Pulling it from `rng` + // would shift every subsequent (t0, pow) and silently re-time storms that are + // already tuned and hand-verified — A drove storm_02 and watched the carabiner + // blow at t=45.4 and cascade at t=56, one second after the change. Adding a + // downdraft shouldn't move that. + const rngV = mulberry32((seed ^ 0x0d0117) >>> 0); const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; + const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT; const out = []; let t = g.firstAt ?? 3; // hard cap: a malformed gap can't spin us forever while (t < def.duration && out.length < 512) { const p = def.duration > 0 ? t / def.duration : 0; const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p; - out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); + // Not every gust slams down the same: some roll through nearly flat, some + // are a proper little downburst. 0.6–1.4× the storm's fraction. + const down = downFrac * (0.6 + rngV() * 0.8); + out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); t += minGap + rng() * Math.max(0, maxGap - minGap); } return out; @@ -190,6 +202,26 @@ export function createWindField(def, opts = {}) { return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate); } + /** + * Vertical wind, m/s. NEGATIVE = downward. Zero between gusts. + * + * A gust front is descending air, not just faster air. Without this the field + * is perfectly horizontal, and a horizontal sail is a free lunch: cloth + * pressure goes with dot(wind, normal), a flat panel's normal points at the + * sky, and the dot product is ~0 no matter how hard it blows. So the cheapest + * winning rig was "lie it flat and ignore the storm", which is the opposite of + * the game (SPRINT2 decision 3). A downdraft hits a flat panel square on. + */ + function gustVertical(t) { + let v = 0; + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t <= g.t0) break; // sorted — nothing later is live + if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down; + } + return v; + } + // ---- noise drift ---- // The noise pattern rides downwind with the mean flow (Taylor's frozen // turbulence), so a gust visibly travels ACROSS the yard instead of blinking on @@ -278,7 +310,13 @@ export function createWindField(def, opts = {}) { }, get shelters() { return shelters; }, - /** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */ + /** + * Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an + * anemometer reads and what the HUD, rain and grass want. The gust downdraft + * is deliberately not in here: a wind meter jumping because air is falling + * past it would read as a bug. Use vecAt/sample for the full 3D vector. + * The cheap path — no allocation. + */ speedAt(x, z, t) { const uni = uniformSpeed(t); const d = dirAt(t); @@ -289,16 +327,18 @@ export function createWindField(def, opts = {}) { dirAt, uniformSpeed, gustOnly, + gustVertical, /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */ vecAt(x, z, t, out) { const uni = uniformSpeed(t); const d = dirAt(t); const dirX = Math.cos(d), dirZ = Math.sin(d); - let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ); + const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ); + let s = uni * m; if (s < 0) s = 0; out.x = dirX * s; - out.y = 0; // wind is horizontal; lift is the sail's job (Lane B) + out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical() out.z = dirZ * s; return out; }, @@ -375,6 +415,10 @@ export function validateStorm(def, name = 'storm') { // Overlapping gusts stack, and a stacked telegraph is unreadable to the player. if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`); if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0'); + const dd = g.downdraft ?? DEFAULT_DOWNDRAFT; + if (!Number.isFinite(dd) || dd < 0 || dd > 1) { + bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`); + } } for (const e of def.events || []) {