From 135511fb0523d08335e5acaae29be92a0c853049 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 23:58:31 +1000 Subject: [PATCH 1/3] Add vertical gusts, freeze debris.pieces, fix fog restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT2 decisions 3 and 5, plus Lane A's fog nit. Decision 3 — gusts now descend. Cloth pressure goes with dot(wind, normal); a flat panel's normal points at the sky, so in a perfectly horizontal wind the dot is ~0 and "lie it flat and ignore the storm" was the cheapest winning rig. A gust front is descending air, not just faster air. Per-gust downdraft fraction in storm JSON (storm_02 0.3, storm_01 0.18, default 0.25, validated 0..1), each gust varying 0.6-1.4x. Peak downdraft in storm_02 is 4.4 m/s, 17% of the horizontal. Lane B: the cloth-side assert is yours. The vertical draws from its OWN rng stream, and there's an assert pinning that: pulling it from the main stream would shift every subsequent (t0, pow) and silently re-time storms Lane A has already hand-verified. Their carabiner still blows at t=45.4 and cascades at t=56. speedAt() stays horizontal — an anemometer doesn't read falling air, and a wind meter that spikes because a gust is descending reads as a bug. Decision 5 — debris.pieces frozen and documented in contracts.js as the seam Lane B reads in sail.step(), with the sphere/SI/mutated-in-place semantics spelled out and an assert tying the live shape to the contract table. Fog: dispose() captured scene.fog by reference and step() mutates that object in place, so restoring it restored nothing (Lane A caught it). Now captured by value, and fog we created ourselves is removed rather than left behind. Selftest 130/0/0 (was 121); Lane C 28 asserts. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_01_gentle.json | 3 +- web/world/data/storms/storm_02_wildnight.json | 5 +- web/world/js/contracts.js | 56 ++++++++++++ web/world/js/skyfx.js | 20 +++- web/world/js/tests/c.test.js | 90 +++++++++++++++++- web/world/js/tests/weather.selftest.js | 91 +++++++++++++++++++ web/world/js/weather.core.js | 52 ++++++++++- 7 files changed, 306 insertions(+), 11 deletions(-) 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..0877b40 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -337,10 +337,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, }; @@ -454,7 +463,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..5348546 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 } 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,66 @@ 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`); + }); + 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 || []) { From 6971f319844fdcd2f30a798cc26d4a4a50a1620f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 00:10:29 +1000 Subject: [PATCH 2/3] Rain stops at the cloth (SPRINT2 Lane C.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The garden visibly stays dry under the sail. 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 bed, free drama and honest physics. Cheap on purpose. Ray-testing 3k drops against 162 triangles every frame is ~486k intersections for an effect nobody inspects closely. Instead project the sail's triangles ALONG the rain onto the ground into a coarse height grid, a few times a second (the cloth moves slowly next to the rain); per-drop cost is one grid read, and occluded drops get a zero-scale matrix rather than a raycast. Measured 0.041 ms/rebuild, 10x/s = 0.41 ms/s against A's 0.63 ms frame — negligible. Reads rig.pos/rig.tris, so nothing new needed from Lane B. Verified in the real game with a surviving twisted rated rig: 497 grid cells covered, 96% of the garden bed under cover, live drops culled under the cloth and falling in the open yard either side. Screenshot for DESIGN.md. skyfx exposes rainShadowOver(rect) — NOT the same as rig.coverageOver(bed, sunDir). That one is the SUN shadow; this is the RAIN shadow (down the wind). Which drives garden HP is a design call — flagged for A in THREADS. Selftest 134/0/0 (8 new rain-shadow asserts). Co-Authored-By: Claude Opus 4.8 --- web/world/js/skyfx.js | 161 +++++++++++++++++++++++++++++++++-- web/world/js/tests/c.test.js | 58 ++++++++++++- 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 0877b40..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); @@ -369,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(); }, @@ -431,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); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 5348546..e82d024 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -18,7 +18,7 @@ import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; import { createDebris } from '../debris.js'; -import { createSkyFx } from '../skyfx.js'; +import { createSkyFx, RainShadow } from '../skyfx.js'; import { weatherCases } from './weather.selftest.js'; const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; @@ -154,6 +154,62 @@ export default async function run(t) { `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'); From d6aa124cb74d867698d04dd7cb0f23538f3e0700 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 00:12:03 +1000 Subject: [PATCH 3/3] Log Sprint 2 landing: decisions 3 & 5, rain occlusion, fog fix Includes the controlled downdraft rebalance table for Lane B, the frozen debris.pieces seam, and the garden-HP design question for Lane A (rain shadow vs sun coverage). Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/THREADS.md b/THREADS.md index da7c55f..e69515d 100644 --- a/THREADS.md +++ b/THREADS.md @@ -504,4 +504,71 @@ Format: `[lane letter] YYYY-MM-DD — note` to a standstill in a hidden/background tab, so they are the only honest way to fast-forward or capture a storm from a headless browser — which is what this sprint's "90 s storm_02 run captured" acceptance needs. Same code path the rAF loop uses; no test-only branch that can drift. Everything I reported - above was measured through them. + 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.)