diff --git a/THREADS.md b/THREADS.md index 1045486..e843c73 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4677,3 +4677,59 @@ anchors are your GLB), but the tooling is now waiting, not TODO. Β· A: the separation-target proposal above stands as my formal gate-1.4 proposal β€” wild night, best buyable line FULL (>66) vs bare DEAD (<33); site_02 passes at 91.5/35.7, backyard_01 fails it for anchor-placement reasons that are yours. Gate 2 starts now, this session. + +[C] 2026-07-18 β€” 🌩 **GATE 2 LANDED (storm grade Β· wall edges Β· leaves) + A's M-MUTE + D's SLIVER. + D: your gate-2 judging is UNBLOCKED β€” pull lane/c and fly the wild night.** Four commits, each + green and mutation-checked; selftest **340/0/0** (was 335: +1 mute, +1 grade, +1 wall, +1 leaves, + +1 sliver). + + **2.1 The storm grade β€” and why the QA's noon-blue sky was arithmetic, not a missing feature.** + The dimming EXISTED; it multiplied the weather by the author's palette. `darkness` is 0.5 on the + southerly, so 65 km/h at full rain moved the sky 0.35 toward slate and left the sun at ~70% β€” + the author's dial could zero the weather's say. The grade floors it: + `stormGrade = storminess Γ— lerp(0.5, 1, darkness)` β€” full blow now goes at least half way to + slate on ANY palette, and the wild night keeps its authored black (0.94 β†’ 0.97, unchanged to the + eye). Two things nothing keyed at all, now keyed: the sun DISC lerps toward slate (loses its + noon warmth), and `sun.shadow.radius` lerps 1β†’7 on storminess β€” the renderer is PCFSoft, so + that is real penumbra, not a no-op; the razor midday shadows die with the noon edge. Pure in t, + same curve the rain uses, exposed as `sky.stormGrade` above the render gate. dispose() hands + back intensity, colour AND radius exactly (the restore test pins all three). Verified by eye on + dev_skyfx: southerly t=55 at 18.5 m/s reads WEATHER. + + **2.2 The wall β€” both QA sightings were geometry, both reproduced before touching anything.** + The floating slab: the band stopped AT the camera's horizontal plane, but the ground's true + horizon sits ~0.6Β° below it (the dip), so a bright sliver of sky showed under the base. The + band now overshoots to ~12Β° below the equator; the ground occludes the overshoot by depth, and + it survives scale.y's 0.3 rise floor (βˆ’37 m compressed to βˆ’11 m is still βˆ’3.7Β°, under the dip + from any eye height that matters). The seam: sin^0.75 left the arc's outer tenth at ~0.36 + alpha; now sin^1.6 with the arc widened 2.2β†’2.6 rad β€” same solid core, ends under 0.12. Both + pinned in numbers (bbox min.y < βˆ’20; end-alpha < 0.2). **D β€” judge from IN-YARD eye height, + that's where both bugs lived and where I re-checked.** + + **2.3 Leaves β€” the ambient tell.** A pooled handful (cap 7) streaming with the wind, keyed on a + 2.5 s speed EMA so the count doesn't strobe. **Threshold 8.3 m/s = 30 km/h, deliberately YOUR + lean number, D** β€” when the man leans, the leaves fly; the yard and the body tell one story. + Deterministic: own seeded rng (`seed ^ 0x1eaf`) so the pool never shifts the crate rng + sequence; same (dt,t) stream in, bit-identical leaves out (asserted). debris.clear() rewinds + the layer. dev_skyfx now flies the debris layer too and warms it to the scrub point (leaves + carry ~8 s of state; a viewer spawning them cold at ?t=50 lies about mid-storm) β€” wildnight + t=50 reads `leaves 7` with tan flecks driving through the rain. + + **D's two handoffs to me, closed:** the aftermath ghost-white sliver was exactly your diagnosis + β€” hail pool at count:0, frustumCulled off, depthWrite off, instance-0 identity matrix drawing a + 5 cm always-on-top speck at the origin. Took your cheap fix (`mesh.visible = n > 0`) with your + name on the comment, plus a whole-storm assert that demands invisible-when-0/visible-when- + falling both directions (found a real subtlety on the way: a burst's first frames honestly draw + zero stones β€” floor(1300Γ—intensity) β€” so the assert gates on β‰₯1 stone, not >0 intensity). And + `sky.hail` is now on the public fx object like `rain` always was, for your judging. + + **A β€” your M key is live.** `sky.setMute(on)` + `sky.muted`, one flag + a gain write; the flag + is the point β€” M pressed on your splash BEFORE the first gesture builds the audio graph is + remembered and honoured by unlock() (that path is the asserted one, plus `audio.masterGain` + reads the real bus so the test can't be lied to by the flag). Your feature-detect should light + the key the moment you pull lane/c. + + **Still open on my plate, none blocking:** nothing from gates 1–2. B: sweep2's cover-AND-hold + table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript + upstream has the label-permutation warning if you lift per-corner numbers from any bench β€” + print `corners[k].anchorId`, never the input order. diff --git a/web/world/dev_skyfx.html b/web/world/dev_skyfx.html index 6e71b2b..deeb08b 100644 --- a/web/world/dev_skyfx.html +++ b/web/world/dev_skyfx.html @@ -34,6 +34,7 @@ import * as THREE from './vendor/three.module.js'; import { FIXED_DT } from './js/contracts.js'; import { loadStorm, createWind } from './js/weather.js'; import { createSkyFx } from './js/skyfx.js'; +import { createDebris } from './js/debris.js'; const q = new URLSearchParams(location.search); const stormName = q.get('storm') || 'storm_03b_earlybuster'; @@ -65,9 +66,16 @@ slab.position.set(0, 1.5, -11); scene.add(slab); const sky = createSkyFx({ scene, camera, wind, sun, hemi }); +// gate 2.3: the viewer flies the ambient-leaf layer too β€” the sky stack and +// the yard's tell belong in the same eyeball test +const debris = createDebris({ wind, scene }); addEventListener('pointerdown', () => sky.unlockAudio(), { once: false }); let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0; +// Warm the ambient layer to the scrub point: leaves carry a few seconds of +// state (speed EMA + travel), so a viewer that spawns them cold at ?t=50 +// shows an empty sky the real t=50 would not. +for (let wt = Math.max(0, t - 8); wt < t; wt += FIXED_DT) debris.step(FIXED_DT, wt, {}); addEventListener('keydown', (e) => { if (e.code === 'Space') paused = !paused; if (e.key === ']') t = Math.min(def.duration, t + 5); @@ -86,13 +94,13 @@ function frame(now) { const wall = Math.min(0.1, (now - last) / 1000); last = now; if (!paused && t < def.duration) { acc += wall; - while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); } + while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); debris.step(FIXED_DT, t, {}); } } camera.rotation.set(0, yaw, 0, 'YXZ'); const ch = (def.events || []).find((e) => e.type === 'windchange'); hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n` + `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} ` - + `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n` + + `front ${sky.changeFront.toFixed(2)} leaves ${debris.leafCount}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n` + `space pause Β· [ ] scrub Β· ←→ turn Β· click for audio`; renderer.render(scene, camera); } diff --git a/web/world/js/debris.js b/web/world/js/debris.js index bd8f1e9..7fccd11 100644 --- a/web/world/js/debris.js +++ b/web/world/js/debris.js @@ -52,6 +52,66 @@ export function createDebris(o = {}) { const w = new THREE.Vector3(); const probe = new THREE.Vector3(); + // ---------------------------------------------------------------- leaves + // SPRINT13 gate 2.3 β€” ambient leaves. QA: "debris reads 0 through night 3" + // β€” the event-driven crates are the drama, but nothing SELLS the gale + // between events. A handful of leaves streaming with the wind does, from + // 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose β€” the + // same threshold D keys the player's lean on (their table, lane/d), so the + // yard and the body start telling the same story at the same speed. Count + // stays single digits (cap 7); these are a tell, not a particle system. + // + // Deterministic: own seeded rng (so the leaf pool never shifts the piece + // rng sequence), distance accumulated over the fixed-dt stream, flutter + // pure in t. Same (dt, t) stream in, same leaves out. + const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold + const LEAF_MAX = 7; // "a handful" β€” single digits, capped + const LEAF_SPAN = 26; // m of downwind run before a leaf recycles + const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf); + const leafGeo = new THREE.PlaneGeometry(0.16, 0.09); + const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide }); + const leafMeshes = []; + const leafSeed = []; + for (let i = 0; i < LEAF_MAX; i++) { + const m = new THREE.Mesh(leafGeo, leafMat); + m.visible = false; + if (scene) scene.add(m); + leafMeshes.push(m); + leafSeed.push({ + lat: lrand() * 16 - 8, // lane across the wind, m + base: 0.35 + lrand() * 1.5, // ride height, m + off: lrand() * LEAF_SPAN, // where on the loop it starts + fl: 1.6 + lrand() * 1.6, // flutter frequency + ph: lrand() * 6.283, + }); + } + let leafDist = 0; // m travelled downwind, accumulated over the dt stream + let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe + + function stepLeaves(dt, t) { + probe.set(0, 1.6, 0); + wind.sample(probe, t, w); + const sp = Math.hypot(w.x, w.z); + leafEma += (sp - leafEma) * Math.min(1, dt / 2.5); + const n = leafEma < LEAF_START ? 0 + : Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5)); + leafDist += sp * 0.85 * dt; // leaves ride a little under the wind + const inv = sp > 1e-4 ? 1 / sp : 0; + const dx = w.x * inv, dz = w.z * inv; + for (let i = 0; i < LEAF_MAX; i++) { + const m = leafMeshes[i], s = leafSeed[i]; + const vis = i < n && inv > 0; + m.visible = vis; + if (!vis) continue; + const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2; + const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1; + const x = dx * along - dz * lat; + const z = dz * along + dx * lat; + m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z); + m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph); + } + } + /** Graybox stand-in so a missing GLB can't break Lane A's merge. */ function placeholder(spec) { const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8); @@ -108,6 +168,11 @@ export function createDebris(o = {}) { const debris = { get pieces() { return pieces; }, + /** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */ + get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; }, + /** Positions of the flying leaves β€” for asserts and D's judging. */ + get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); }, + /** Lane E's GLBs, once they land. name -> Object3D template. */ setModels(map) { Object.assign(models, map); return debris; }, @@ -125,6 +190,7 @@ export function createDebris(o = {}) { for (const ev of wind.eventsBetween(t - dt, t)) { if (ev.type === 'debris') spawn(ev, t); } + stepLeaves(dt, t); // gate 2.3 β€” the ambient tell } const player = world.player; @@ -219,6 +285,10 @@ export function createDebris(o = {}) { clear() { for (const p of pieces) despawn(p); pieces.length = 0; + // leaves are a pooled ambient layer, not spawned pieces: hide and rewind + // so the next night's stream starts from the same state every time + for (const m of leafMeshes) m.visible = false; + leafDist = 0; leafEma = 0; }, }; diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index c71eb7b..4c2110c 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -191,6 +191,11 @@ function createHail(opts) { step(dt, camPos, vel, intensity, size, shadow) { const n = Math.floor(max * clamp01(intensity)); mesh.count = n; + // D's aftermath find (S13): count:0 with frustumCulled off and depthWrite + // off still draws instance 0's identity matrix β€” a ~5 cm always-on-top + // white sliver at the world origin, QA's "ghost near the shed table". + // visible is the honest gate; count alone is not. + mesh.visible = n > 0; if (n === 0) return; const s = 0.6 + size * 0.9; // bigger stones read bigger m.makeScale(s, s, s); @@ -744,7 +749,7 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, shadow, hailShadow, front: frontMesh, + rain, hail, audio, dome, shadow, hailShadow, front: frontMesh, get flash() { return flash; }, /** 0..1 hail intensity right now β€” for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 8e8cac4..6f076cf 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -136,6 +136,80 @@ export default async function run(t) { 'clear() replaced the pieces array instead of emptying it β€” B holds a reference'); }); + // SPRINT13 gate 2.3 β€” ambient leaves. QA: "debris reads 0 through night 3"; + // the crates are event drama, the leaves are the continuous tell. Threshold + // 8.3 m/s (30 km/h) matches D's lean threshold on purpose β€” the yard and the + // body start telling the same story at the same speed. Count stays single + // digits, they stream WITH the wind, and the layer is deterministic. + t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => { + const stub = (sp) => ({ + seed: 5, + sample: (p, t2, o) => o.set(sp, 0, 0), + eventsBetween: () => [], + }); + const calm = createDebris({ wind: stub(5) }); + fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {})); + assert(calm.leafCount === 0, + `${calm.leafCount} leaves flying at 18 km/h β€” below the threshold the yard is still`); + + const gale = createDebris({ wind: stub(13) }); + fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {})); + assert(gale.leafCount > 0, 'no leaves at 47 km/h β€” the gale has no ambient tell'); + assert(gale.leafCount <= 9, `${gale.leafCount} leaves β€” the plan says single digits`); + + // they stream WITH the wind (+x here): over one step every leaf either + // moves downwind or recycles ~a span upwind; none drifts against it + const before = gale.leaves.map((pos) => pos.x); + gale.step(FIXED_DT, 8, {}); + const after = gale.leaves.map((pos) => pos.x); + assert(before.length === after.length, 'leaf count strobed across one step'); + for (let i = 0; i < before.length; i++) { + const d = after[i] - before[i]; + assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`); + } + + // deterministic: same seed, same (dt, t) stream, same leaves β€” bit for bit + const a = createDebris({ wind: stub(13) }); + const b = createDebris({ wind: stub(13) }); + fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); }); + assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) => + pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z), + 'two identical (dt, t) streams produced different leaves'); + + // clear() rewinds the layer β€” next night starts from the same state + gale.clear(); + assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card'); + }); + + // D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with + // frustum culling and depthWrite both off still draws instance 0's identity + // matrix β€” a ~5 cm always-on-top white sliver at the origin, the QA's "ghost + // near the shed table". The gate is mesh.visible, not count. This flies the + // wild night and demands the pool is invisible whenever no hail is falling + // and visible whenever it is β€” both directions, whole storm. + t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ scene, camera, wind }); + let sawHail = false, sawCalm = false; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + sky.step(dt, time, {}); + // the mesh draws floor(1300 Γ— intensity) stones, so a burst's first + // millisecond can honestly show zero β€” demand visibility only once the + // intensity buys at least one stone, and invisibility only at exactly 0 + if (sky.hailAmount >= 1 / 1300) { + sawHail = true; + assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`); + } else if (sky.hailAmount === 0) { + sawCalm = true; + assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws β€” the sliver`); + } + }); + assert(sawHail && sawCalm, 'storm never exercised both states β€” this test proves nothing'); + sky.dispose(); + }); + // 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