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

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-16 22:14:20 +10:00
commit e7639c4264
11 changed files with 2052 additions and 17 deletions

View File

@ -254,3 +254,94 @@ Format: `[lane letter] YYYY-MM-DD — note`
fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to
re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox?
Either way it's one constant for me — the script regenerates everything.
[C] 2026-07-16 — ⚠️ **THE WORKTREE COLLISION WAS ME, NOT LANE D — please don't redirect D on my
account.** Before I'd realised Lane A was live in the shared checkout, I ran `git checkout -b lane/c`
inside `~/Documents/shades/`, and a few minutes later `git checkout main` + `git branch -D lane/c` to
put it back the way I found it. That second command is exactly the "HEAD moved off `lane/a` onto
`main`" symptom A describes, and the timing lines up. Nothing was lost — the delete reported
`was 8338c0f`, i.e. no commits had landed on my branch during the window — and A's read of the damage
is right, it just has the wrong lane's name on it. Lane C has been on `~/Documents/shades-laneC/`
since. (Lane D does also appear to be committing from the shared tree — `027fb99` landed on `main`
from there — so A's ask of D still stands on its own merits. I'm only disowning the checkout.)
Apologies for the noise; PLAN3D §0's clone rule earns its keep.
[C] 2026-07-16 — **LANE C LANDED on `lane/c` — weather.js, skyfx.js, debris.js, 2 storms.** Rebased on
M0; `c.test.js` is live (19 asserts) and Lane A's selftest reads **37 pass / 3 skip**. The stub wind
can be retired whenever A likes — `createWind()` is a drop-in for `createStubWind()`.
· `wind.sample(pos,t)` / `wind.gustTelegraph(t)` per contract; `checkContract('wind', …)` clean.
· Gusts are a **precomputed timeline**, not an integrator. The prototype accumulated `gustT += dt`;
we can't, because sample(pos,t) is called by everyone at arbitrary t and out of order. Same
envelope though — telegraph 1.5 / ramp 0.8 / hold 1.7 / fade 1.0, straight off the prototype.
· Storms are **data**: `data/storms/*.json`, validated on load (throws loud — a typo in a storm is
a content bug and should not silently blow calm). Tune curves without touching code.
[C] 2026-07-16 — **CONTRACT — one addition, backward compatible.** `wind.sample(pos, t, out?)` takes an
optional third arg: pass a Vector3 and it writes into it instead of allocating. Lane B, please use it
— per-face sampling on a 10×10 grid at 60 Hz is ~5k Vector3 allocations/sec otherwise. Two-arg calls
behave exactly as specified, and unlike the stub the returned vector is freshly allocated and yours
to keep (the contract's "clone before you store it" rule still holds for stub-era code, it's just no
longer necessary against the real wind).
[C] 2026-07-16 — **ASKS, one per lane. All degrade silently — nothing here blocks a merge.**
· **Lane A** — trees don't shelter anything until you tell me where they are:
`wind.setSheltersFromTrees(world.anchors.filter(a => a.type === 'tree'))` after the yard builds.
Unset = no wind shadows, which is just a flatter yard. Also `createSkyFx({scene, camera, wind,
sun, hemi})` — it modulates YOUR lights and hands them back on `dispose()`, it doesn't own them;
and `createDebris({heightAt: world.heightAt})` so debris bounces off your terrain, not y=0.
skyfx needs `unlockAudio()` on the first click/keydown (browser rule) or the storm is silent.
· **Lane B** — ❓ **the debris-vs-sail seam is your call.** I have the impulse maths but not your
nodes: contracts exposes `sailRig.corners`, not the cloth. Two options — (a) I keep driving it and
you expose `sailRig.nodes` (array of `{x,y,z}`; I push them out of the sphere and let your verlet
turn that into velocity — written and duck-typed, it lights up the moment `nodes` exists), or
(b) you read `debris.pieces` (`{x,y,z,vx,vy,vz,r,mass}`) in `sail.step` and do it yourself, since
you own the integrator. I'd take (b) if you want the momentum bookkeeping in one place. Say which
and I'll match it.
· **Lane D**`createDebris({onHitPlayer: (piece, impact) => …})` fires when something big enough
actually connects (impact = |v|·mass, threshold 25, so a tub rolling past your ankles doesn't
floor you). The knockdown state machine is yours per §5-D.3; I only report the hit.
· **Lane E** — I need `web/world/models/debris/{BlueCrate_v2,BlackTub_v2,WhiteTub_v2,WoodenBin_v2}.glb`
(your §5-E.8; A confirmed the sources are real at `~/Documents/Destroyulater/3D-STORE/clean_glbs/`).
Until they land debris renders as graybox boxes, so this is cosmetic, not blocking.
`debris.setModels({name: Object3D})`. Collision is one sphere per piece — radii in `MODEL_SPEC` in
debris.js assume a ~0.6 m crate; if you scale them differently, tell me rather than fighting it.
[C] 2026-07-16 — **Lane B: tune cloth ρ against these, not against the stub.** Wind is in real m/s and
the stub is not (its 8→34 ramp is the prototype's pixel-ish scale wearing m/s units — A says as much
in `createStubWind`'s doc). `storm_01_gentle`: sustained peaks 6.5, worst gust 11.3 m/s (41 km/h) —
the sail should breathe and nothing should break. `storm_02_wildnight`: sustained peaks 20 (72 km/h),
worst gust 32.3 (116 km/h, BOM 'destructive'), southerly change swings 2.09 rad at t=5559 with the
peak landing just after it. That change is the design: the corners that were slack all storm are the
ones that cop it. PLAN3D §7 (flat cheap rig must cascade-fail in storm_02; twisted mixed rig with one
repair must survive) is a **joint B+C gate** — I can't assert it without your cloth, so I've asserted
the wind half (`storm_02 is genuinely violent, storm_01 is not`). Ping me when sail.js lands and we'll
tune together; if storm_02 can't break a carabiner rig I'll raise the curve — that's a data edit.
[C] 2026-07-16 — Notes on my own files, so nobody trips over them:
· `weather.core.js` imports **nothing** — no THREE, no DOM, no Date.now. Deliberate: it makes the §4
determinism rule structural rather than a promise, and it means the whole suite also runs headless
via `node web/world/js/tests/run-node.mjs` (~1 s, no browser, no server). Tuning a storm curve
through a browser round trip is miserable. `weather.js` is the thin THREE adapter over it.
· Cost of that: `weather.core.js` carries its own copy of mulberry32 rather than importing
`contracts.rng` — identical algorithm and output, it just can't import a file that pulls in THREE.
`debris.js` and `skyfx.js` do use `contracts.rng`. Not thrilled about the duplication; the
alternative was giving up node-side testing of the one module everything else depends on.
· Asserts live in `js/tests/weather.selftest.js` as a plain case list; `c.test.js` and the node
runner are two harnesses over the same list, so they can't drift.
· `weather_demo.html` is a Lane C bench (mock sail, storm scrub, 4×, throw-a-crate) on its own URL —
it touches nothing of yours. Delete it whenever it stops earning its place.
· **Lane A:** a.test.js's 'gust telegraph always gives at least 1.2 s of warning' is now also
asserted against the real wind in c.test.js, per your note in the stub. Yours to drop when the
stub goes.
[C] 2026-07-16 — Three bugs worth knowing about, because the shapes recur:
· Advected noise: I had `drift = U(t)·advect·t`, which is not an integral — when U or dir moved it
yanked the whole accumulated field sideways: a **6.8 m/s jump in one frame** at the southerly
change. Now integrated once at build time into a table. If you ever advect anything by time,
integrate it.
· Debris friction: `v *= 0.86` per frame while grounded is `0.86^60` per second — glue, not scrape.
It pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Anything per-frame that should be per-second
needs dt.
· `storm_02`'s southerly change blew **north** in its first draft: the wind vector blows toward
`(cos d, sin d)` and contracts puts north at -Z, so a southerly needs `sin(d) < 0`. Worth a second
look at anything that reasons about wind direction.
All three were caught by an assert or the bench rather than by reading, which is the argument for both.

View File

@ -0,0 +1,29 @@
{
"name": "Sea Breeze",
"blurb": "Fresh afternoon breeze, chance of a light shower. Good day to test a rig.",
"rating": 1,
"seed": 1017,
"duration": 90,
"baseCurve": [[0, 3.0], [20, 5.0], [50, 6.5], [75, 6.0], [90, 5.5]],
"gusts": {
"firstAt": 4,
"minGap": 6,
"maxGap": 14,
"powBase": 2,
"powRand": 3,
"powRamp": 2
},
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
"dirWander": { "amp": 0.35, "rate": 0.09 },
"spatial": { "amp": 0.15, "scale": 12, "advect": 0.5 },
"events": [],
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
}

View File

@ -0,0 +1,41 @@
{
"name": "Wild Night",
"blurb": "Front crossing after dark. Southerly change around the hour mark, damaging gusts. Rig for the swing, not the lull.",
"rating": 4,
"seed": 20260716,
"duration": 90,
"_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.",
"_dirCurve_comment": "Radians in the XZ plane; the wind blows TOWARD (cos d, sin d). contracts.js puts north at -Z, so a southerly (blowing toward the north, into the house) needs sin(d) < 0. Starts ~0.85 = blowing toward the SE, i.e. the hot NW'er before a change; slews to ~-1.35 = blowing toward the NNE, i.e. a SSW buster off the open south side of the yard. 55->59s is the slew: ~110 deg in four seconds.",
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
"gusts": {
"firstAt": 3,
"minGap": 5.5,
"maxGap": 11,
"powBase": 3,
"powRand": 5,
"powRamp": 7
},
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
"dirWander": { "amp": 0.25, "rate": 0.13 },
"spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 38, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" },
{ "t": 52, "type": "lightning", "power": 0.7 },
{ "t": 55, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" },
{ "t": 64, "type": "lightning", "power": 1.0 },
{ "t": 66, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 5, "text": "someone's tub is airborne" },
{ "t": 74, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" },
{ "t": 79, "type": "lightning", "power": 0.5 }
],
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
}

254
web/world/js/debris.js Normal file
View File

@ -0,0 +1,254 @@
'use strict';
// SHADES — Lane C — debris: things that should have been tied down.
//
// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps.
// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays.
//
// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the
// wind field carry it across. Drag goes with speed², same as the sail, so the
// same gust that spikes a corner is the one that launches the neighbour's bin.
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
const RHO = 1.2; // air density, kg/m³
const GRAVITY = -9.81;
// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a
// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point.
const GROUND_FRICTION = 3.5;
// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the
// collision sphere, not the render bounds — a crate is boxy, but a sphere is
// what you can afford to test 6 of per node per frame.
const MODEL_SPEC = {
BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 },
BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 },
WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 },
LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 },
};
const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 };
/**
* @param {object} o
* @param {object} o.wind from weather.js
* @param {THREE.Object3D} o.scene
* @param {Object<string,THREE.Object3D>} [o.models] name -> template (Lane E's GLBs)
* @param {function} [o.onHitPlayer] (piece, impact) Lane D knocks the player down
* @param {function} [o.onEvent] (text) HUD ticker
* @param {object} [o.bounds] {x, z} half-extents before despawn
*/
export function createDebris(o = {}) {
const wind = o.wind;
const scene = o.scene || null;
const models = o.models || {};
const bounds = o.bounds || { x: 26, z: 20 };
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
// Flat fallback so this still runs against a graybox yard.
const groundAt = o.heightAt || (() => o.groundY ?? 0);
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
const pieces = [];
const w = new THREE.Vector3();
const probe = new THREE.Vector3();
/** 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);
const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 });
return new THREE.Mesh(g, m);
}
function spawn(ev, t) {
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
// upwind of the yard, offset sideways, so it crosses the whole thing
const d = wind.dirAt(t);
const dx = Math.cos(d), dz = Math.sin(d);
const lat = ev.lateral ?? 0;
const dist = ev.spawnDist ?? 18;
const x = -dx * dist - dz * lat;
const z = -dz * dist + dx * lat;
const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2);
const tmpl = models[ev.model];
const mesh = tmpl ? tmpl.clone(true) : placeholder(spec);
mesh.castShadow = true;
if (scene) scene.add(mesh);
// already moving — it's been blowing across the neighbour's yard for a while
probe.set(x, y, z);
wind.sample(probe, t, w);
const piece = {
model: ev.model,
x, y, z,
vx: w.x * 0.8, vy: 0, vz: w.z * 0.8,
// spin axis is arbitrary but seeded; rate scales with airspeed in step()
sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1,
spin: 0,
phase: rand() * 6.283, // so two crates don't hop in lockstep
r: spec.r, mass: spec.mass, cd: spec.cd,
area: Math.PI * spec.r * spec.r,
hitPlayer: false,
mesh,
alive: true,
};
pieces.push(piece);
if (ev.text && o.onEvent) o.onEvent(ev.text);
return piece;
}
function despawn(p) {
p.alive = false;
if (scene && p.mesh) scene.remove(p.mesh);
}
const debris = {
get pieces() { return pieces; },
/** Lane E's GLBs, once they land. name -> Object3D template. */
setModels(map) { Object.assign(models, map); return debris; },
/** Manual spawn — handy for tuning and for Lane A's debug keys. */
spawn,
/**
* @param {number} dt fixed step
* @param {number} t storm time
* @param {object} [world] {player, sail} both optional, both duck-typed
*/
step(dt, t, world = {}) {
// storm JSON drives the spawns; poll the window so nothing is missed
if (wind) {
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'debris') spawn(ev, t);
}
}
const player = world.player;
const sail = world.sail;
for (let i = pieces.length - 1; i >= 0; i--) {
const p = pieces[i];
probe.set(p.x, p.y, p.z);
wind.sample(probe, t, w);
// drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v)
const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz;
const rel = Math.hypot(rx, ry, rz);
const k = 0.5 * RHO * p.cd * p.area * rel / p.mass;
p.vx += rx * k * dt;
p.vy += ry * k * dt + GRAVITY * dt;
p.vz += rz * k * dt;
// A tumbling bluff body doesn't just get shoved, it gets picked up: lift
// flips sign as it rolls, which is why a bin HOPS across a yard instead
// of sliding. Wind is horizontal (weather.js keeps y=0), so without this
// there is no vertical force at all once it's down and it just skates.
p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
// ground
const floor = groundAt(p.x, p.z) + p.r;
if (p.y <= floor) {
p.y = floor;
if (p.vy < -0.5) {
// a real impact: bounce, and lose some tangential speed to the hit
p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub
p.vx *= 0.72; p.vz *= 0.72;
} else {
if (p.vy < 0) p.vy = 0;
// Resting: rolling friction as a dt-scaled DECELERATION, not a
// per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per
// second — that isn't scrape, it's glue, and it pinned a 9 kg crate
// at 0.7 m/s in a 19 m/s wind.
const sp = Math.hypot(p.vx, p.vz);
if (sp > 1e-4) {
const drop = Math.min(sp, GROUND_FRICTION * dt);
p.vx -= (p.vx / sp) * drop;
p.vz -= (p.vz / sp) * drop;
}
}
}
// tumble rate follows airspeed — becalmed debris shouldn't keep spinning
p.spin += rel * 0.35 * dt;
if (p.mesh) {
p.mesh.position.set(p.x, p.y, p.z);
p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin);
}
// --- sphere vs player: knockdown ---
// Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3),
// so we just report the hit and let them run the state machine.
if (player && player.pos && !p.hitPlayer) {
const px = player.pos.x, pz = player.pos.z;
const py = player.pos.y + 0.9; // centre of mass, not feet
const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2;
const hit = p.r + 0.35;
if (dsq < hit * hit) {
const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass;
// a bin rolling gently past your ankles shouldn't floor you
if (impact > 25 && o.onHitPlayer) {
p.hitPlayer = true; // one knockdown per piece
o.onHitPlayer(p, impact);
p.vx *= 0.4; p.vz *= 0.4;
}
}
}
// --- sphere vs sail nodes: impulse ---
// Duck-typed: lights up the moment Lane B exposes nodes, silent until
// then. See THREADS — B owns sail.js, so this is the seam we agreed on.
if (sail && sail.nodes) applyToSail(p, sail);
if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) {
despawn(p);
pieces.splice(i, 1);
}
}
},
/** Drop everything (phase change, restart). */
clear() {
for (const p of pieces) despawn(p);
pieces.length = 0;
},
};
/**
* Shove any cloth node the piece is intersecting, and lose some of the piece's
* own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we
* move position and let the integrator turn it into velocity).
*/
function applyToSail(p, sail) {
const nodes = sail.nodes;
const reach = p.r + 0.15;
const reachSq = reach * reach;
let hits = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z;
const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > reachSq || dsq < 1e-9) continue;
const d = Math.sqrt(dsq);
// push the node out to the sphere surface along the contact normal
const push = (reach - d) / d;
n.x += dx * push; n.y += dy * push; n.z += dz * push;
hits++;
}
if (hits) {
const drag = Math.min(0.5, (hits * p.mass) / 400);
p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag;
if (sail.onDebrisHit) sail.onDebrisHit(p, hits);
}
}
return debris;
}

470
web/world/js/skyfx.js Normal file
View File

@ -0,0 +1,470 @@
'use strict';
// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all.
//
// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights;
// this MODULATES them as the storm builds and hands them back on dispose(), so
// two lanes never fight over one scene.
//
// Everything is duck-typed and optional — no sun light, no audio, no sail? Then
// those layers just don't run. Lane A can wire the pieces as they land.
//
// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN
// and no deps, and a filtered-noise bed tracks wind speed better than a loop.
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
import { valueNoise2 } from './weather.core.js';
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
// ---------------------------------------------------------------- rain
function createRain(opts) {
const max = opts.maxDrops ?? 3000;
const half = opts.half ?? 18; // box half-extent around the camera
const height = opts.height ?? 24;
const groundY = opts.groundY ?? 0;
const rand = rng(0xd309);
// one thin quadish streak, instanced — cheap and reads as rain in motion
const geo = new THREE.BoxGeometry(0.015, 1, 0.015);
const mat = new THREE.MeshBasicMaterial({
color: 0xb4d2ff, transparent: true, opacity: 0.34,
depthWrite: false, fog: false,
});
const mesh = new THREE.InstancedMesh(geo, mat, max);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.renderOrder = 2;
mesh.count = 0;
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
const jitter = new Float32Array(max);
for (let i = 0; i < max; i++) {
px[i] = (rand() * 2 - 1) * half;
py[i] = groundY + rand() * height;
pz[i] = (rand() * 2 - 1) * half;
jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop
}
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const vel = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3();
return {
mesh,
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
step(dt, camPos, w, intensity) {
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);
const speed = vel.length() || 1;
q.setFromUnitVectors(up, vel.clone().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);
const top = groundY + height;
for (let i = 0; i < n; i++) {
const j = jitter[i];
px[i] += w.x * 0.55 * j * dt;
py[i] -= fall * j * dt;
pz[i] += w.z * 0.55 * j * dt;
// wrap the box around the camera instead of respawning — no bookkeeping,
// and the rain is always exactly where the player is looking
let d = px[i] - camPos.x;
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
d = pz[i] - camPos.z;
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
if (py[i] < groundY) py[i] += height;
else if (py[i] > top) py[i] -= height;
m.elements[12] = px[i];
m.elements[13] = py[i];
m.elements[14] = pz[i];
mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
},
dispose() { geo.dispose(); mat.dispose(); },
};
}
// ------------------------------------------------------------ cloud dome
function cloudTexture(size = 256, seed = 7) {
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// fbm at integer frequencies, each octave wrapped at its own period, so
// the texture tiles: it's set to repeat(3,2) and it scrolls forever, and
// an unwrapped octave puts a dead straight seam across the sky.
let n = 0, amp = 0.5, f = 4;
for (let o = 0; o < 4; o++) {
n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f);
amp *= 0.5; f *= 2;
}
const v = clamp01((n - 0.28) * 2.2);
const i = (y * size + x) * 4;
const shade = 150 + v * 70;
img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12;
img.data[i + 3] = v * 235;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(3, 2);
return tex;
}
// ---------------------------------------------------------------- audio
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
// everything is built lazily on unlock() and silently absent before it.
function createAudio(seed = 1) {
let ctx = null, master = null;
let windGain, windFilter, windHowl, howlGain;
let rainGain, rainFilter;
let gustGain, gustFilter;
let noiseBuf = null;
let creakNext = 0, flogNext = 0;
let started = false;
function noiseBuffer(c) {
const len = c.sampleRate * 4;
const buf = c.createBuffer(1, len, c.sampleRate);
const d = buf.getChannelData(0);
const rand = rng(seed ^ 0x0157);
let last = 0;
for (let i = 0; i < len; i++) {
const white = rand() * 2 - 1;
last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end
d[i] = last * 3.5;
}
return buf;
}
function loop(buf, dest, filter) {
const src = ctx.createBufferSource();
src.buffer = buf; src.loop = true;
src.connect(filter); filter.connect(dest);
src.start();
return src;
}
return {
get ready() { return started; },
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
* got built a suspended context is still silent, so the HUD reports this. */
get state() { return ctx ? ctx.state : 'none'; },
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
levels() {
if (!started) return null;
return {
wind: +windGain.gain.value.toFixed(4),
howl: +howlGain.gain.value.toFixed(4),
rain: +rainGain.gain.value.toFixed(4),
cutoff: Math.round(windFilter.frequency.value),
};
},
/** Call from the first click/keydown. Safe to call repeatedly. */
unlock() {
if (started) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
if (ctx.state === 'suspended') ctx.resume();
master = ctx.createGain();
master.gain.value = 0.55;
master.connect(ctx.destination);
noiseBuf = noiseBuffer(ctx);
// wind bed: brown noise through a lowpass that opens as it blows harder
windGain = ctx.createGain(); windGain.gain.value = 0;
windFilter = ctx.createBiquadFilter();
windFilter.type = 'lowpass'; windFilter.frequency.value = 400;
windGain.connect(master);
loop(noiseBuf, windGain, windFilter);
// howl: a resonant band on top — this is the bit that sounds like a gale
howlGain = ctx.createGain(); howlGain.gain.value = 0;
windHowl = ctx.createBiquadFilter();
windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6;
howlGain.connect(master);
loop(noiseBuf, howlGain, windHowl);
rainGain = ctx.createGain(); rainGain.gain.value = 0;
rainFilter = ctx.createBiquadFilter();
rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800;
rainGain.connect(master);
loop(noiseBuf, rainGain, rainFilter);
gustGain = ctx.createGain(); gustGain.gain.value = 0;
gustFilter = ctx.createBiquadFilter();
gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5;
gustGain.connect(master);
loop(noiseBuf, gustGain, gustFilter);
started = true;
},
/** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */
burst({ freq, q, gain, attack, decay, type = 'bandpass' }) {
if (!started) return;
const now = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = ctx.createBiquadFilter();
f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack);
g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay);
src.connect(f); f.connect(g); g.connect(master);
src.start(now);
src.stop(now + attack + decay + 0.05);
},
/** @param {number} speed m/s @param {number} rain 0..1 */
setLevels(speed, rain) {
if (!started) return;
const now = ctx.currentTime;
const s = clamp01(speed / 32);
// gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one
windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15);
windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2);
howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2);
windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25);
rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3);
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
},
/** Telegraph cue: you hear it coming before you feel it. */
whoosh(power, eta) {
if (!started) return;
const now = ctx.currentTime;
const p = clamp01(power / 18);
gustGain.gain.cancelScheduledValues(now);
gustGain.gain.setValueAtTime(gustGain.gain.value, now);
gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta));
gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2);
gustFilter.frequency.cancelScheduledValues(now);
gustFilter.frequency.setValueAtTime(220, now);
gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8);
},
/** Rope creak — rate and pitch both ride the worst corner. */
creak(dt, loadFrac) {
if (!started || loadFrac < 0.35) return;
creakNext -= dt;
if (creakNext > 0) return;
creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65));
this.burst({
freq: 180 + loadFrac * 420, q: 9,
gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16,
});
},
/** Freed corner: canvas cracking itself to pieces. */
flog(dt, speed) {
if (!started) return;
flogNext -= dt;
if (flogNext > 0) return;
flogNext = Math.max(0.09, 0.5 - speed * 0.011);
this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 });
},
thunder(power) {
if (!started) return;
this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 });
},
dispose() { if (ctx) ctx.close(); started = false; },
};
}
// ---------------------------------------------------------------- skyfx
/**
* @param {object} o
* @param {THREE.Scene} o.scene
* @param {THREE.Camera} o.camera
* @param {object} o.wind from weather.js
* @param {THREE.Light} [o.sun] Lane A's directional light we dim it
* @param {THREE.Light} [o.hemi] Lane A's hemisphere light
* @param {boolean} [o.night] storm_02 is a wild NIGHT
* @param {function} [o.onEvent] (text) HUD ticker
*/
export function createSkyFx(o = {}) {
const { scene, camera, wind } = o;
const sun = o.sun || null;
const hemi = o.hemi || null;
const def = (wind && wind.def) || {};
const skyDef = def.sky || {};
const darkness = skyDef.darkness ?? 0.7;
const scroll = skyDef.cloudScroll ?? 0.06;
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh);
const audio = createAudio((wind && wind.seed) || 1);
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff);
const dome = new THREE.Mesh(
new THREE.SphereGeometry(180, 24, 16),
new THREE.MeshBasicMaterial({
map: domeTex, side: THREE.BackSide, transparent: true,
depthWrite: false, fog: false, opacity: 0,
}),
);
dome.renderOrder = -1;
if (scene) scene.add(dome);
// remember what world.js handed us, so dispose() puts it back exactly
const original = {
background: scene ? scene.background : null,
fog: scene ? scene.fog : null,
sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0,
};
const baseSky = (scene && scene.background && scene.background.isColor)
? scene.background.clone() : CALM_SKY.clone();
const skyCol = baseSky.clone();
if (scene) {
scene.background = skyCol;
if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140);
}
let flash = 0; // decaying lightning brightness
let flashQueue = []; // {at, power} — double-strike
let lastTelegraph = null;
const camPos = new THREE.Vector3();
const w = new THREE.Vector3();
const fx = {
rain, audio, dome,
get flash() { return flash; },
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
/**
* @param {number} dt
* @param {number} t storm time
* @param {object} [world] {sail} duck-typed, for creak/flog
*/
step(dt, t, world = {}) {
if (!camera) return;
camera.getWorldPosition(camPos);
wind.sample(camPos, t, w);
const speed = Math.hypot(w.x, w.z);
const intensity = wind.rainAt(t);
const storminess = clamp01(Math.max(intensity, speed / 26));
// --- events: lightning + the ticker ---
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'lightning') {
const p = ev.power ?? 0.7;
flashQueue.push({ at: t, power: p });
flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter
// thunder lags the flash — distance you can hear
const delay = (ev.distance ?? 1.2) * 0.9;
flashQueue.push({ at: t + delay, power: 0, thunder: p });
} else if (ev.type === 'windchange' && ev.text && o.onEvent) {
o.onEvent(ev.text);
}
}
for (let i = flashQueue.length - 1; i >= 0; i--) {
if (flashQueue[i].at <= t) {
const f = flashQueue[i];
if (f.thunder) audio.thunder(f.thunder);
else flash = Math.max(flash, f.power);
flashQueue.splice(i, 1);
}
}
flash *= Math.max(0, 1 - dt * 7);
if (flash < 0.004) flash = 0;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
if (scene) {
if (scene.fog) {
scene.fog.color.copy(skyCol);
scene.fog.near = lerp(40, 8, storminess);
scene.fog.far = lerp(160, 55, storminess);
}
}
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
dome.position.copy(camPos);
dome.material.opacity = storminess * 0.85;
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain ---
rain.step(dt, camPos, w, intensity);
// --- audio ---
audio.setLevels(speed, intensity);
const tg = wind.gustTelegraph(t);
if (tg && tg !== lastTelegraph) {
// fires once per gust, right as the telegraph opens
if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) {
audio.whoosh(tg.power, tg.eta);
}
}
lastTelegraph = tg;
const sail = world.sail;
if (sail && sail.corners) {
let worst = 0, broken = false;
for (const c of sail.corners) {
if (c.broken) { broken = true; continue; }
const rating = c.hw && c.hw.rating ? c.hw.rating : 1;
worst = Math.max(worst, c.load / rating);
}
audio.creak(dt, worst);
if (broken) audio.flog(dt, speed);
}
},
/** Hand Lane A's scene back exactly as we found it. */
dispose() {
if (scene) {
scene.remove(rain.mesh);
scene.remove(dome);
scene.background = original.background;
scene.fog = original.fog;
}
if (sun) sun.intensity = original.sun;
if (hemi) hemi.intensity = original.hemi;
rain.dispose();
dome.geometry.dispose();
dome.material.dispose();
domeTex.dispose();
audio.dispose();
},
};
return fx;
}

View File

@ -1,25 +1,81 @@
/**
* Lane C selftests wind field, storm timelines, rain, debris.
*
* Lane C owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
* The asserts live in weather.selftest.js as a plain case list, because they
* import nothing but weather.core.js (no THREE, no DOM) and so also run under
* `node web/world/js/tests/run-node.mjs` a one-second loop for tuning a storm
* curve, instead of a browser round trip. This file is the browser half: it
* feeds them the fetched storms and adds the checks that need the real
* weather.js adapter (contract shape, THREE.Vector3 out).
*
* The asserts PLAN3D §5-C asks for, once weather.js lands:
* 1. gust telegraph lead 1.2 s the promise the storm rests on. Lane A's
* suite already asserts this against the stub wind (see a.test.js,
* 'gust telegraph always gives at least 1.2 s of warning'); lift that test
* onto the real wind.sample and delete the stub version's claim to it.
* 2. wind.sample continuity no frame-to-frame jump beyond a sane bound, or
* the cloth explodes and it looks like Lane B's bug.
* 3. storm JSON schema validation for everything in data/storms/.
*
* Useful imports:
* import { FIXED_DT, STORM_LEN } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order.
* Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning'
* is lifted onto the real wind below, per your note the stub's claim to it is
* now redundant and yours to drop whenever suits. Logged in THREADS.
*/
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
import { weatherCases } from './weather.selftest.js';
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('weather.js not landed yet — Lane C');
export default async function run(t) {
const storms = {};
for (const name of STORMS) storms[name] = await loadStorm(name);
// --- the shared case list (also runs headless in node) ---
const { cases } = weatherCases(storms);
for (const c of cases) t.test(c.name, c.fn);
// --- the bits that need the THREE adapter, not just the core ---
t.test('weather.js satisfies the wind contract', () => {
const wind = createWind(storms.storm_02_wildnight);
const problems = checkContract('wind', wind);
assert(problems.length === 0, problems.join('; '));
});
t.test('sample() returns a Vector3 and honours an out param', () => {
const wind = createWind(storms.storm_02_wildnight);
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}`);
// 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');
});
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
// header). This is the promise the whole storm rests on: the player can
// always react. Deliberately walks the public surface, not the core.
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
const wind = createWind(storms.storm_02_wildnight);
let had = false, edges = 0;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
const tel = wind.gustTelegraph(time);
if (tel && !had) {
edges++;
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power');
assert(Number.isFinite(tel.dir), 'telegraph carried no direction');
}
had = !!tel;
});
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
});
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');
for (const [name, def] of Object.entries(storms)) {
assert(def.duration > 0, `${name} has no duration`);
assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
}
});
}

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
// SHADES — Lane C — headless runner for the weather suite.
//
// node web/world/js/tests/run-node.mjs
//
// The same suite Lane A's selftest.html runs, but with no browser and no server,
// so tuning a storm curve is a one-second loop. Exits non-zero on failure.
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runWeatherSuite } from './weather.selftest.js';
const here = dirname(fileURLToPath(import.meta.url));
const stormDir = join(here, '..', '..', 'data', 'storms');
const storms = {};
for (const f of readdirSync(stormDir).filter((f) => f.endsWith('.json')).sort()) {
storms[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(stormDir, f), 'utf8'));
}
const r = runWeatherSuite(storms);
for (const res of r.results) {
console.log(res.ok ? ` ok ${res.name}` : ` FAIL ${res.name}\n ${res.err}`);
}
console.log('\n metrics (Lane B: tune cloth rho against these):');
for (const [k, v] of Object.entries(r.metrics)) console.log(` ${k.padEnd(32)} ${v}`);
console.log(`\n ${r.pass} passed, ${r.fail} failed\n`);
process.exit(r.fail ? 1 : 0);

View File

@ -0,0 +1,299 @@
'use strict';
// SHADES — Lane C — weather selftest.
//
// Imports the pure core only (no THREE, no DOM), so this runs in node OR from
// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs
// — PLAN3D §0).
//
// node web/world/js/tests/run-node.mjs
//
// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and
// call it with the fetched storm defs.
import {
createWindField, validateStorm, gustEnvelope, GUST,
} from '../weather.core.js';
const DT = 1 / 60;
// yard is ~30×20 m, origin at centre — probe the corners and the middle
const PROBES = [
{ x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 },
{ x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 },
];
// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT")
const TREE_SHELTERS = [
{ x: -9, z: 2, radius: 3, strength: 0.45, length: 14 },
{ x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 },
];
/**
* The case list, defined once and run by two harnesses: run-node.mjs for a
* one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at
* merge time. Cases are sync, matching testkit's Suite.test(label, fn).
*
* @param {Object<string, object>} storms parsed storm defs, keyed by name
* @returns {{cases: {name:string, fn:() => void}[], metrics: object}}
* `metrics` fills in as the cases run read it after, not before.
*/
export function weatherCases(storms) {
const cases = [];
const metrics = {};
const test = (name, fn) => cases.push({ name, fn });
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const defs = Object.entries(storms);
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
// collapses, the storm stops being fair and starts being a dice roll.
for (const [name, def] of defs) {
test(`${name}: gust telegraph lead >= 1.2s`, () => {
const field = createWindField(def);
assert(field.gusts.length > 0, 'storm scheduled no gusts at all');
const step = 1 / 240;
let worst = Infinity;
for (const g of field.gusts) {
// when does THIS gust first actually push?
let rise = null;
for (let t = g.t0; t < g.endAt; t += step) {
if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; }
}
assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`);
// when did the HUD first get told about it?
let announced = null;
for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) {
const tg = field.telegraph(t);
if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; }
}
assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`);
const lead = rise - announced;
worst = Math.min(worst, lead);
assert(lead >= 1.2,
`gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`);
// and the telegraph window must be silent — no force before the ramp
for (let t = g.t0; t < g.rampAt; t += step) {
assert(gustEnvelope(t - g.t0, g.pow) === 0,
`gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`);
}
}
metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3);
});
}
// ---- 2. wind.sample continuity ----
// Sail load goes with wind², so a discontinuity here is an impulse that can
// snap a corner out of nowhere. Both axes matter: time (a standing player)
// and space (a running one).
const MAX_JUMP = 1.5; // m/s per 1/60 frame
for (const [name, def] of defs) {
test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => {
const field = createWindField(def).setShelters(TREE_SHELTERS);
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
let worst = 0, worstT = 0, worstP = null;
for (const p of PROBES) {
field.vecAt(p.x, p.z, 0, a);
for (let t = DT; t <= field.duration; t += DT) {
field.vecAt(p.x, p.z, t, b);
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (d > worst) { worst = d; worstT = t; worstP = p; }
a.x = b.x; a.y = b.y; a.z = b.z;
}
}
metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4);
assert(worst < MAX_JUMP,
`jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`);
});
test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => {
const field = createWindField(def).setShelters(TREE_SHELTERS);
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
let worst = 0, worstAt = null;
// sweep the yard at the storm's angriest moments, straight through both trees
for (const t of [10, 30, 57, 64, 80]) {
for (let z = -10; z <= 10; z += 1) {
field.vecAt(-15, z, t, a);
for (let x = -15 + 0.1; x <= 15; x += 0.1) {
field.vecAt(x, z, t, b);
const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; }
a.x = b.x; a.y = b.y; a.z = b.z;
}
}
}
metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4);
assert(worst < MAX_JUMP,
`jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`);
});
}
// ---- 3. storm JSON validator ----
for (const [name, def] of defs) {
test(`${name}: validates`, () => {
const { ok, errors } = validateStorm(def, name);
assert(ok, errors.join('; '));
});
}
// A validator that only ever says yes isn't a validator.
test('validator rejects broken storms', () => {
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
const cases = [
['duration missing', (d) => { delete d.duration; }],
['duration negative', (d) => { d.duration = -5; }],
['baseCurve absent', (d) => { delete d.baseCurve; }],
['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }],
['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }],
['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }],
['dirCurve absent', (d) => { delete d.dirCurve; }],
['gusts absent', (d) => { delete d.gusts; }],
['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }],
['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }],
['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }],
['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }],
['event with no type', (d) => { d.events = [{ t: 10 }]; }],
['windchange that dirCurve never delivers', (d) => {
d.dirCurve = [[0, 0.9], [90, 1.0]];
d.events = [{ t: 55, type: 'windchange', telegraph: 6 }];
}],
];
for (const [label, mutate] of cases) {
const d = base();
mutate(d);
const { ok } = validateStorm(d, 'broken');
assert(!ok, `validator ACCEPTED a storm with: ${label}`);
}
});
// ---- 4. determinism ----
// Everything downstream (selftest fast-forward, Lane B's byte-equal load
// traces) rests on this. Two builds of the same storm must be indiscernible.
test('same def + same seed => identical trace', () => {
const def = storms.storm_02_wildnight;
const a = createWindField(def).setShelters(TREE_SHELTERS);
const b = createWindField(def).setShelters(TREE_SHELTERS);
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
for (let t = 0; t <= def.duration; t += DT) {
for (const p of PROBES) {
a.vecAt(p.x, p.z, t, va);
b.vecAt(p.x, p.z, t, vb);
assert(va.x === vb.x && va.z === vb.z,
`diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`);
}
}
assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length');
a.gusts.forEach((g, i) => {
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`);
});
});
test('different seed => different storm', () => {
const def = storms.storm_02_wildnight;
const a = createWindField(def, { seed: 1 });
const b = createWindField(def, { seed: 2 });
const same = a.gusts.length === b.gusts.length
&& a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow);
assert(!same, 'seed is being ignored — every storm would be identical');
});
// ---- 5. sampling order must not matter ----
// sample() is called by sail/player/debris/rain in whatever order the frame
// happens to run. If it ever depends on call order, storms stop replaying.
test('sample order independent', () => {
const def = storms.storm_02_wildnight;
// same fixed t values, walked in two different orders, on two fields
const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4];
const sorted = [...times].sort((a, b) => a - b);
const inOrder = createWindField(def).setShelters(TREE_SHELTERS);
const shuffled = createWindField(def).setShelters(TREE_SHELTERS);
const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 };
const seen = new Map();
for (const t of sorted) {
inOrder.vecAt(3, -2, t, va);
seen.set(t, { x: va.x, z: va.z });
}
for (const t of times) { // deliberately out of order, and jumping backwards
shuffled.vecAt(3, -2, t, vb);
const want = seen.get(t);
assert(vb.x === want.x && vb.z === want.z,
`sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`);
}
});
// ---- 6. the storms are what the design says they are (PLAN3D §7) ----
// storm_02 has to be able to destroy a flat cheap rig; storm_01 must not.
test('storm_02 is genuinely violent, storm_01 is not', () => {
const wild = createWindField(storms.storm_02_wildnight);
const gentle = createWindField(storms.storm_01_gentle);
const peak = (f) => {
let mx = 0, mxBase = 0;
for (let t = 0; t <= f.duration; t += DT) {
mx = Math.max(mx, f.uniformSpeed(t));
mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t));
}
return { mx, mxBase };
};
const w = peak(wild), g = peak(gentle);
metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2);
metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2);
metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2);
metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2);
assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`);
assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`);
assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`);
assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01');
});
// ---- 7. wind change actually swings the wind ----
test('storm_02 southerly change swings the wind', () => {
const f = createWindField(storms.storm_02_wildnight);
const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange');
assert(ev, 'storm_02 has no windchange event');
const before = f.dirAt(ev.t - 5);
const after = f.dirAt(ev.t + 8);
const swing = Math.abs(after - before);
metrics['storm_02.changeSwingRad'] = +swing.toFixed(3);
assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`);
// and the player must be warned before it lands
assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to');
});
// ---- 8. shelters ----
test('tree wind shadow bites downwind and nowhere else', () => {
const def = storms.storm_02_wildnight;
const bare = createWindField(def);
const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
const t = 30;
const d = bare.dirAt(t);
const dx = Math.cos(d), dz = Math.sin(d);
const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree
const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind
const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t);
const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t);
metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3);
assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`);
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
});
return { cases, metrics };
}
/** Run every case and collect results. Used by run-node.mjs. */
export function runWeatherSuite(storms) {
const { cases, metrics } = weatherCases(storms);
const results = cases.map((c) => {
try {
c.fn();
return { name: c.name, ok: true };
} catch (e) {
return { name: c.name, ok: false, err: e.message };
}
});
const pass = results.filter((r) => r.ok).length;
return { suite: 'weather', pass, fail: results.length - pass, results, metrics };
}

View File

@ -0,0 +1,399 @@
'use strict';
// SHADES — Lane C — wind field core.
//
// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is
// a closed-form function of (pos, t) given a storm def + seed, which buys us:
// - selftest can fast-forward a 90 s storm and get identical numbers every run
// - consumers can sample any t, in any order, as often as they like
// - the determinism rule (PLAN3D §4) is structural, not a promise
//
// weather.js wraps this to expose the contracts.js surface (Vector3 in/out).
// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't —
// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed
// into a timeline from a seeded PRNG at storm load; the envelope shape below is
// a faithful port of prototype/game.js, just read from t instead of accumulated.
// ---------- gust envelope (ported from prototype/game.js windVec) ----------
// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio)
export const GUST = Object.freeze({
TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming"
RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow
HOLD: 1.7, // 2.3 .. 4.0 → pow
FADE: 1.0, // 4.0 .. 5.0 → pow → 0
TOTAL: 5.0,
});
const RAMP_AT = GUST.TELEGRAPH; // 1.5
const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3
const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0
const END_AT = FADE_AT + GUST.FADE; // 5.0
/** Gust strength at local gust time gt (seconds since telegraph began). */
export function gustEnvelope(gt, pow) {
if (gt <= 0 || gt >= END_AT) return 0;
if (gt < RAMP_AT) return 0; // telegraph window
if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP;
if (gt < FADE_AT) return pow;
return pow * (END_AT - gt) / GUST.FADE;
}
// ---------- deterministic noise ----------
// mulberry32 — small, fast, good enough, and identical in every JS engine.
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double)
function hash2(ix, iz, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
const smooth = (f) => f * f * (3 - 2 * f);
/**
* Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps.
*
* @param {number} [period] Wrap the lattice at this many cells, making the noise
* tile seamlessly over [0, period). The wind doesn't want this (the yard would
* repeat); a scrolling cloud texture does, or every wrap boundary is a visible
* straight edge in the sky. Pass an integer that matches your frequency.
*/
export function valueNoise2(x, z, seed, period = 0) {
const ix = Math.floor(x), iz = Math.floor(z);
const ux = smooth(x - ix), uz = smooth(z - iz);
// branch, not a closure: this is the wind's hot path (the cloth alone samples
// it thousands of times a second) and a per-call allocation would show up.
let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1;
if (period > 0) {
x0 = ((x0 % period) + period) % period;
x1 = ((x1 % period) + period) % period;
z0 = ((z0 % period) + period) % period;
z1 = ((z1 % period) + period) % period;
}
const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed);
const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed);
return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz;
}
export function smoothstep(e0, e1, x) {
if (e0 === e1) return x < e0 ? 0 : 1;
const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return smooth(f);
}
// ---------- curves ----------
/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */
export function sampleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span);
}
}
return last[1];
}
/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */
export function lerpAngle(a, b, k) {
const TAU = Math.PI * 2;
let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI;
return a + d * k;
}
function sampleAngleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span);
}
}
return last[1];
}
// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
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 });
t += minGap + rng() * Math.max(0, maxGap - minGap);
}
return out;
}
// ---------- the field ----------
/**
* @param {object} def parsed storm JSON (see data/storms/*.json)
* @param {object} [opts] {seed}
*/
export function createWindField(def, opts = {}) {
const seed = (opts.seed ?? def.seed ?? 1) >>> 0;
const duration = def.duration ?? 90;
const gusts = buildGustTimeline(def, seed);
const sp = def.spatial || {};
const amp = sp.amp ?? 0.18; // ±18% speed across the yard
const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20
const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence)
const wander = def.dirWander || {};
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
const nSeed = (seed ^ 0x9e3779b9) | 0;
let shelters = [];
/** Spatially-uniform part: base curve + every gust envelope live at t. */
function uniformSpeed(t) {
let s = sampleCurve(def.baseCurve, t);
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break; // sorted — nothing later can be live
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function gustOnly(t) {
let s = 0;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break;
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function dirAt(t) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
}
// ---- 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
// everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ,
// and it has to be integrated as one: the obvious closed form `U(t)·advect·t`
// is not the integral, and it whips the whole accumulated field sideways the
// instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change,
// which the continuity assert caught. So integrate once at build time into an
// immutable table; sampling stays a pure function of t.
// Mean flow only (base curve, no gusts): eddies are carried by the wind, they
// don't surf their own gust, and it keeps the drift rate smooth.
const DRIFT_DT = 0.25;
const driftX = [], driftZ = [];
{
let dx = 0, dz = 0;
const n = Math.ceil((duration + 2) / DRIFT_DT) + 2;
for (let i = 0; i < n; i++) {
driftX.push(dx); driftZ.push(dz);
const tt = i * DRIFT_DT;
const u = sampleCurve(def.baseCurve, tt) * advect;
const d = dirAt(tt);
dx += Math.cos(d) * u * DRIFT_DT;
dz += Math.sin(d) * u * DRIFT_DT;
}
}
const drift = { x: 0, z: 0 };
function driftAt(t) {
if (t <= 0) { drift.x = 0; drift.z = 0; return drift; }
const f = t / DRIFT_DT;
let i = Math.floor(f);
if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate
const k = f - i;
drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k;
drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k;
return drift;
}
/** Speed multiplier: smooth noise, carried downwind. */
function spatialFactor(x, z, t) {
if (amp <= 0) return 1;
const d = driftAt(t);
const nx = (x - d.x) / scale;
const nz = (z - d.z) / scale;
const n = 0.65 * valueNoise2(nx, nz, nSeed)
+ 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b);
return 1 + (n - 0.5) * 2 * amp;
}
/** Trees knock a hole downwind of themselves. Cheap, and very juicy. */
function shelterFactor(x, z, dirX, dirZ) {
let f = 1;
for (let i = 0; i < shelters.length; i++) {
const s = shelters[i];
const rx = x - s.x, rz = z - s.z;
const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree
if (along <= 0 || along >= s.length) continue;
const perp = Math.abs(rx * dirZ - rz * dirX);
if (perp >= s.radius) continue;
// ramp in over the first half-radius so the shadow can't snap on at along=0
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
const fPerp = 1 - smoothstep(0, s.radius, perp);
f *= 1 - s.strength * fAlong * fPerp;
}
return f;
}
const field = {
def,
seed,
gusts,
duration,
/**
* Trees/house register wind shadows. Lane A calls this after building the
* yard; unset = no shadows, so nothing breaks before world.js lands.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) {
shelters = (list || []).map((s) => ({
x: s.x, z: s.z,
radius: s.radius ?? 2.5,
strength: Math.min(1, Math.max(0, s.strength ?? 0.45)),
length: s.length ?? (s.radius ?? 2.5) * 4,
}));
return field;
},
get shelters() { return shelters; },
/** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */
speedAt(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
return s > 0 ? s : 0;
},
dirAt,
uniformSpeed,
gustOnly,
/** 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);
if (s < 0) s = 0;
out.x = dirX * s;
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
out.z = dirZ * s;
return out;
},
/**
* The next gust that has been telegraphed but hasn't started ramping.
* eta = seconds until the wind actually rises. Null when nothing's inbound.
*/
telegraph(t) {
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet
if (t < g.rampAt) {
return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow };
}
}
return null;
},
/** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */
eventsBetween(a, b) {
const evs = def.events || [];
const out = [];
for (let i = 0; i < evs.length; i++) {
if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]);
}
return out;
},
/** 0..1 rain intensity for skyfx. */
rainAt(t) {
const r = def.rain;
if (!r) return 0;
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
return Math.min(1, Math.max(0, r.intensity ?? 0));
},
};
return field;
}
// ---------- storm JSON validator ----------
// Storms are data so design can tune without code (PLAN3D §4) — which means a
// typo is a data bug, and data bugs should fail loud, not silently blow calm.
export function validateStorm(def, name = 'storm') {
const errors = [];
const bad = (m) => errors.push(`${name}: ${m}`);
const isCurve = (c) => Array.isArray(c) && c.length > 0
&& c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]);
if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; }
if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number');
if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers');
else {
if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing');
if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0');
const end = def.baseCurve[def.baseCurve.length - 1][0];
if (Number.isFinite(def.duration) && end < def.duration) {
bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`);
}
}
if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers');
else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing');
const g = def.gusts;
if (!g || typeof g !== 'object') bad('gusts block missing');
else {
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)');
if (maxGap < minGap) bad('gusts.maxGap must be >= minGap');
// 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');
}
for (const e of def.events || []) {
if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`);
if (!e.type) bad(`event at t=${e.t} has no type`);
if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`);
// A windchange event is HUD metadata; dirCurve is the physics. If they drift
// apart the player gets warned about a swing that never comes.
if (e.type === 'windchange' && isCurve(def.dirCurve)) {
const before = sampleAngleCurve(def.dirCurve, e.t - 0.5);
const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6));
const swing = Math.abs(lerpAngle(before, after, 1) - before);
if (swing < 0.5) {
bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`);
}
}
}
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
return { ok: errors.length === 0, errors };
}

101
web/world/js/weather.js Normal file
View File

@ -0,0 +1,101 @@
'use strict';
// SHADES — Lane C — weather: the wind field everyone samples.
//
// Implements the contracts.js wind surface (PLAN3D §4):
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
// wind.gustTelegraph(t) -> {eta, dir, power} | null
//
// All the maths lives in weather.core.js (pure, no imports). This file is just
// the THREE adapter + storm loading, so the sim stays node-testable and the
// determinism rule can't be broken by accident.
import * as THREE from '../vendor/three.module.js';
import { createWindField, validateStorm, GUST } from './weather.core.js';
export { GUST, validateStorm };
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
export async function loadStorm(name, dir = STORM_DIR) {
const url = `${dir}/${name}.json`;
const res = await fetch(url);
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
const def = await res.json();
const { ok, errors } = validateStorm(def, name);
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
return def;
}
/**
* @param {object} def parsed storm JSON
* @param {object} [opts] {seed} same seed + same def = same storm, every run
* @returns the `wind` object from contracts.js
*/
export function createWind(def, opts = {}) {
const field = createWindField(def, opts);
const scratch = { x: 0, y: 0, z: 0 };
const wind = {
/**
* Wind velocity at a world position, m/s.
* @param {THREE.Vector3} pos
* @param {number} t storm time, seconds
* @param {THREE.Vector3} [out] pass one to avoid allocating sail.js
* samples per-face per-frame, so this matters
*/
sample(pos, t, out) {
const v = out || new THREE.Vector3();
field.vecAt(pos.x, pos.z, t, scratch);
return v.set(scratch.x, scratch.y, scratch.z);
},
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
speedAt(pos, t) {
return field.speedAt(pos.x, pos.z, t);
},
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
gustTelegraph(t) {
return field.telegraph(t);
},
/**
* Register wind shadows (trees, house). Lane A: call after the yard is built.
* Until then there are simply no shadows nothing breaks.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) { field.setShelters(list); return wind; },
/** Convenience: take shadows straight off world.anchors' tree entries. */
setSheltersFromTrees(trees, o = {}) {
return wind.setShelters(trees.map((tr) => ({
x: tr.pos ? tr.pos.x : tr.x,
z: tr.pos ? tr.pos.z : tr.z,
radius: o.radius ?? tr.radius ?? 3,
strength: o.strength ?? 0.45,
length: o.length ?? 14,
})));
},
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
eventsBetween(a, b) { return field.eventsBetween(a, b); },
/** 0..1 rain intensity. */
rainAt(t) { return field.rainAt(t); },
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
dirAt(t) { return field.dirAt(t); },
get duration() { return field.duration; },
get gusts() { return field.gusts; },
get def() { return field.def; },
get seed() { return field.seed; },
core: field,
};
return wind;
}

263
web/world/weather_demo.html Normal file
View File

@ -0,0 +1,263 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — Lane C — weather bench</title>
<style>
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
* { box-sizing:border-box; }
body { margin:0; overflow:hidden; background:#000;
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
canvas { display:block; }
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; min-width:250px; }
#hud b { color:var(--gold); }
#hud .warn { color:#ff6; font-weight:bold; }
#hud .bad { color:#f66; font-weight:bold; }
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
border:1px solid #26263a; z-index:3; }
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
padding:4px 9px; cursor:pointer; }
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
#ctl input[type=range] { width:220px; vertical-align:middle; }
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
.bar i { display:block; height:100%; background:var(--neon); }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud"></div>
<div id="note">
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
<br><br>drag = orbit · click = start audio
</div>
<div id="ctl"></div>
<script type="module">
import * as THREE from './vendor/three.module.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
import { createDebris } from './js/debris.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(30, 20),
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
for (const tr of TREES) {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
);
trunk.position.set(tr.x, 2, tr.z);
trunk.castShadow = true;
scene.add(trunk);
const canopy = new THREE.Mesh(
new THREE.SphereGeometry(3, 12, 8),
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
);
canopy.position.set(tr.x, 5, tr.z);
canopy.castShadow = true;
scene.add(canopy);
}
// house edge along north (-Z), for scale
const house = new THREE.Mesh(
new THREE.BoxGeometry(30, 3.2, 1),
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
);
house.position.set(0, 1.6, -10.4);
scene.add(house);
// the thing you're protecting — Lane A's gardenBed rect
const bed = new THREE.Mesh(
new THREE.BoxGeometry(6, 0.25, 4),
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
);
bed.position.set(1, 0.12, 2);
scene.add(bed);
// 1.7 m reference person
const ref = new THREE.Mesh(
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
);
ref.position.set(2, 0.85, 2);
ref.castShadow = true;
scene.add(ref);
const player = { pos: ref.position, carrying: null, busy: false };
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
sun.position.set(-12, 18, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
scene.add(hemi);
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
// debris shove it and drive the creak/flog audio off corner loads.
const N = 9;
const nodes = [];
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
}
}
const sailGeo = new THREE.BufferGeometry();
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
scene.add(sailPts);
const mockSail = {
nodes,
corners: [
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
],
};
// --- weather ---
const params = new URLSearchParams(location.search);
const stormName = params.get('storm') || 'storm_02_wildnight';
const def = await loadStorm(stormName);
const wind = createWind(def);
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
const ticker = [];
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
const debris = createDebris({
wind, scene, player,
onEvent: (s) => ticker.unshift(s),
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
});
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
// --- controls ---
let t = 0, playing = true, rate = 1;
const ctl = document.getElementById('ctl');
ctl.innerHTML = `
<button id="play">pause</button>
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
<button id="reset">reset</button>
<button id="break">break a corner</button>
<button id="crate">throw a crate</button>
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
`;
const $ = (id) => document.getElementById(id);
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
$('r1').onclick = () => { rate = 1; };
$('r4').onclick = () => { rate = 4; };
$('r0').onclick = () => { rate = 0.25; };
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => { dragging = false; });
addEventListener('pointermove', (e) => {
if (!dragging) return;
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
const DT = 1 / 60;
let acc = 0, last = performance.now();
const hud = document.getElementById('hud');
const probe = new THREE.Vector3();
const w = new THREE.Vector3();
const posAttr = sailGeo.getAttribute('position');
function frame(now) {
const real = Math.min(0.1, (now - last) / 1000);
last = now;
if (playing) acc += real * rate;
while (acc >= DT) {
acc -= DT;
t += DT;
if (t > def.duration) t = 0;
// mock cloth: nodes just bob with local wind so debris has something to hit
for (const n of nodes) {
probe.set(n.x, n.y, n.z);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
}
// mock loads so the creak layer has something to track
probe.set(0, 3.2, 0);
const sp = wind.speedAt(probe, t);
mockSail.corners.forEach((c, i) => {
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
});
debris.step(DT, t, { player, sail: mockSail });
sky.step(DT, t, { sail: mockSail });
}
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
posAttr.needsUpdate = true;
camera.position.set(
Math.sin(yaw) * Math.cos(pitch) * dist,
Math.sin(pitch) * dist + 1.5,
Math.cos(yaw) * Math.cos(pitch) * dist,
);
camera.lookAt(0, 2, 0);
$('scrub').value = t.toFixed(1);
probe.set(0, 1.7, 0);
wind.sample(probe, t, w);
const speed = Math.hypot(w.x, w.z);
const tg = wind.gustTelegraph(t);
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
hud.innerHTML = `
<div><b>${def.name}</b> — ${stormName}</div>
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
<div>flash ${sky.flash.toFixed(2)}</div>
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div>&nbsp;</div>'}
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
`;
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
</script>
</body>
</html>