Merge remote-tracking branch 'origin/lane/c'

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 01:01:48 +10:00
commit ac5021d279
8 changed files with 584 additions and 18 deletions

View File

@ -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 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 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. 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.61.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.)

View File

@ -13,7 +13,8 @@
"maxGap": 14, "maxGap": 14,
"powBase": 2, "powBase": 2,
"powRand": 3, "powRand": 3,
"powRamp": 2 "powRamp": 2,
"downdraft": 0.18
}, },
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]], "dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],

View File

@ -11,13 +11,16 @@
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], "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": { "gusts": {
"firstAt": 3, "firstAt": 3,
"minGap": 5.5, "minGap": 5.5,
"maxGap": 11, "maxGap": 11,
"powBase": 3, "powBase": 3,
"powRand": 5, "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]], "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],

View File

@ -190,6 +190,48 @@ export class Emitter {
* @property {boolean} broken * @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<string,THREE.Object3D>) => 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. * PLAYER Lane D implements.
* *
@ -258,6 +300,20 @@ export const CONTRACT = {
interact: { register: 'function' }, interact: { register: 'function' },
camera: { object: 'object', yaw: 'number', update: 'function' }, camera: { object: 'object', yaw: 'number', update: 'function' },
game: { phase: 'string', on: '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',
}; };
/** /**

View File

@ -22,6 +22,112 @@ const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a); const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c); 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 // ---------------------------------------------------------------- rain
function createRain(opts) { function createRain(opts) {
const max = opts.maxDrops ?? 3000; const max = opts.maxDrops ?? 3000;
@ -55,22 +161,29 @@ function createRain(opts) {
const q = new THREE.Quaternion(); const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0); const up = new THREE.Vector3(0, 1, 0);
const vel = new THREE.Vector3(); const vel = new THREE.Vector3();
const unit = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1); const scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3(); const zero = new THREE.Vector3();
// zero-scale: an instance that renders to nothing
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
return { return {
mesh, 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)); const n = Math.floor(max * clamp01(intensity));
mesh.count = n; mesh.count = n;
if (n === 0) return; 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 // 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; 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 // streak stretches with speed — drizzle is dots, a squall is lines
scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1); scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
m.compose(zero, q, scale); m.compose(zero, q, scale);
@ -91,6 +204,15 @@ function createRain(opts) {
if (py[i] < groundY) py[i] += height; if (py[i] < groundY) py[i] += height;
else if (py[i] > top) 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[12] = px[i];
m.elements[13] = py[i]; m.elements[13] = py[i];
m.elements[14] = pz[i]; m.elements[14] = pz[i];
@ -322,6 +444,9 @@ export function createSkyFx(o = {}) {
const rain = createRain({ groundY: o.groundY ?? 0 }); const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh); 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); const audio = createAudio((wind && wind.seed) || 1);
@ -337,10 +462,19 @@ export function createSkyFx(o = {}) {
dome.renderOrder = -1; dome.renderOrder = -1;
if (scene) scene.add(dome); 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 = { const original = {
background: scene ? scene.background : null, background: scene ? scene.background : null,
fog: scene ? scene.fog : 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, sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0, hemi: hemi ? hemi.intensity : 0,
}; };
@ -360,9 +494,22 @@ export function createSkyFx(o = {}) {
const w = new THREE.Vector3(); const w = new THREE.Vector3();
const fx = { const fx = {
rain, audio, dome, rain, audio, dome, shadow,
get flash() { return flash; }, 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. */ /** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); }, unlockAudio() { audio.unlock(); },
@ -422,7 +569,16 @@ export function createSkyFx(o = {}) {
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain --- // --- 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 ---
audio.setLevels(speed, intensity); audio.setLevels(speed, intensity);
@ -454,7 +610,14 @@ export function createSkyFx(o = {}) {
scene.remove(rain.mesh); scene.remove(rain.mesh);
scene.remove(dome); scene.remove(dome);
scene.background = original.background; 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 (sun) sun.intensity = original.sun;
if (hemi) hemi.intensity = original.hemi; if (hemi) hemi.intensity = original.hemi;

View File

@ -15,8 +15,10 @@
import * as THREE from '../../vendor/three.module.js'; import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.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 { loadStorm, createWind } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { weatherCases } from './weather.selftest.js'; import { weatherCases } from './weather.selftest.js';
const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; 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 pos = new THREE.Vector3(3, 0, -2);
const a = wind.sample(pos, 12.5); const a = wind.sample(pos, 12.5);
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3'); 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 // out param must not change the answer, only where it lands
const out = new THREE.Vector3(); const out = new THREE.Vector3();
const b = wind.sample(pos, 12.5, out); const b = wind.sample(pos, 12.5, out);
assert(b === out, 'out param was ignored'); 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 // 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`); 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', () => { t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass // 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'); assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');

View File

@ -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'); 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.61.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 }; return { cases, metrics };
} }

View File

@ -130,17 +130,29 @@ function sampleAngleCurve(curve, t) {
// ---------- gust timeline ---------- // ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON. // 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) { export function buildGustTimeline(def, seed) {
const g = def.gusts || {}; const g = def.gusts || {};
const rng = mulberry32(seed >>> 0); 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 minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
const out = []; const out = [];
let t = g.firstAt ?? 3; let t = g.firstAt ?? 3;
// hard cap: a malformed gap can't spin us forever // hard cap: a malformed gap can't spin us forever
while (t < def.duration && out.length < 512) { while (t < def.duration && out.length < 512) {
const p = def.duration > 0 ? t / def.duration : 0; const p = def.duration > 0 ? t / def.duration : 0;
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p; 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.61.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); t += minGap + rng() * Math.max(0, maxGap - minGap);
} }
return out; return out;
@ -190,6 +202,26 @@ export function createWindField(def, opts = {}) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate); 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 ---- // ---- noise drift ----
// The noise pattern rides downwind with the mean flow (Taylor's frozen // 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 // 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; }, 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) { speedAt(x, z, t) {
const uni = uniformSpeed(t); const uni = uniformSpeed(t);
const d = dirAt(t); const d = dirAt(t);
@ -289,16 +327,18 @@ export function createWindField(def, opts = {}) {
dirAt, dirAt,
uniformSpeed, uniformSpeed,
gustOnly, gustOnly,
gustVertical,
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */ /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
vecAt(x, z, t, out) { vecAt(x, z, t, out) {
const uni = uniformSpeed(t); const uni = uniformSpeed(t);
const d = dirAt(t); const d = dirAt(t);
const dirX = Math.cos(d), dirZ = Math.sin(d); 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; if (s < 0) s = 0;
out.x = dirX * s; 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; out.z = dirZ * s;
return out; return out;
}, },
@ -375,6 +415,10 @@ export function validateStorm(def, name = 'storm') {
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player. // 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 (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'); 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 || []) { for (const e of def.events || []) {