HardYards/web/world/js/debris.js
type-two afb61b2926 Lane C S18: the mid-storm entry seam — a crate is a pure function of its event
The emergency callout arrives at t≈30, which makes a NON-ZERO START a first-class
case for the storm clock. Measured before writing anything, and the debris module
failed two ways that had nothing to do with the callout:

  · the same storm flown twice through one module spawned DIFFERENT crates
    (wildnight height 1.582 m then 1.165 m). main.js builds one debris module at
    boot and clear()s it per phase change; clear() rewound the leaf accumulators
    and not the piece PRNG. Live on main today — night 2 of the seven-night week
    never flew night 1's crates, and no test looked.
  · an entry at t=45, past the wildnight's t=38 event, gave every LATER crate the
    skipped crate's height, spin axis and phase — the stream sat 5 draws behind.
    Skipping history corrupted the future, not just the present.

One cause: a crate's jitter came off a shared stream whose POSITION encoded how
much history had happened. Keyed on the event itself now, so a crate is a pure
function of its event — the move weather.core.js's header already argues for.

debris.enterAt(t0, dt) for the one part that is a genuine path integral: the
ambient leaf layer. A t=0 run has 6 leaves crossing the grass at t=30 and a cold
entry has ZERO, staying becalmed ~7 s while the 2.5 s EMA climbs off zero —
DESIGN.md line 149 makes that moving grass the gust front's tell, and gate 1's
whole premise is that the grass is the only briefing a callout gets. It runs the
ladder rather than skipping it, and the ladder ACCUMULATES: i*dt is the more
accurate ladder and therefore the wrong one (1770 of 1800 steps disagree, first
at step six), because main.js, fixedLoop and sail.js all accumulate.

Pins: +5 (522 -> 527). Wind order-independence broadened to every storm and five
scalar observables plus the exact callout ladder, and a BACKWARDS peak scan
cross-checking what the dispatch will quote. All four mutations went red; the
first draft of the stormStats case could not fail (it memoises per def, so it was
comparing an object to itself) and was rewritten rather than shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:21:29 +10:00

382 lines
17 KiB
JavaScript

'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);
/**
* A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18]
*
* This was `const rand = rng(seed ^ 0x5eed1e)` — one stream, drawn from inside
* spawn(). A stream's POSITION encodes how many pieces have already spawned,
* so history leaked into every later crate, and it cost us two determinism
* breaks measured on the wildnight (crate heights, m):
*
* the same storm twice through one module night 1 y 1.582 night 2 y 1.165
* (main.js builds ONE debris module at boot and clear()s it per phase
* change; clear() rewound leafDist/leafEma and NOT this stream, so night 2
* of the seven-night week flew different crates than night 1 — live on
* main today, no emergency callout required.)
* an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413
* t=45 y 1.623 sx 0.581 ph 0.839
* (every crate after a skipped event wore the SKIPPED crate's clothes: the
* stream sat 5 draws behind. Skipping history corrupted the future, not
* just the present — the thing that makes a mid-storm arrival unpinnable.)
*
* Keyed on `ev.t` (stable authored data, unique per event in every shipped
* storm) so the crate at t=38 is the same crate whether you watched the first
* 38 seconds or arrived at 30. Manual spawns — A's debug keys, tuning — have no
* `ev.t` and fall back to the spawn time, which differs per keypress.
*
* This is the same move weather.core.js's header argues for: determinism
* STRUCTURAL, not a promise. A crate is now a pure function of its event.
*/
const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0;
const eventRng = (ev, t) => {
const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000);
return rng((seedBase ^ Math.imul(key, 374761393)) | 0);
};
const pieces = [];
const w = new THREE.Vector3();
const probe = new THREE.Vector3();
// ---------------------------------------------------------------- leaves
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3"
// — the event-driven crates are the drama, but nothing SELLS the gale
// between events. A handful of leaves streaming with the wind does, from
// 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose — the
// same threshold D keys the player's lean on (their table, lane/d), so the
// yard and the body start telling the same story at the same speed. Count
// stays single digits (cap 7); these are a tell, not a particle system.
//
// Deterministic: own seeded rng (so the leaf pool never shifts the piece
// rng sequence), distance accumulated over the fixed-dt stream, flutter
// pure in t. Same (dt, t) stream in, same leaves out.
const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold
const LEAF_MAX = 7; // "a handful" — single digits, capped
const LEAF_SPAN = 26; // m of downwind run before a leaf recycles
const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf);
const leafGeo = new THREE.PlaneGeometry(0.16, 0.09);
const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide });
const leafMeshes = [];
const leafSeed = [];
for (let i = 0; i < LEAF_MAX; i++) {
const m = new THREE.Mesh(leafGeo, leafMat);
m.visible = false;
if (scene) scene.add(m);
leafMeshes.push(m);
leafSeed.push({
lat: lrand() * 16 - 8, // lane across the wind, m
base: 0.35 + lrand() * 1.5, // ride height, m
off: lrand() * LEAF_SPAN, // where on the loop it starts
fl: 1.6 + lrand() * 1.6, // flutter frequency
ph: lrand() * 6.283,
});
}
let leafDist = 0; // m travelled downwind, accumulated over the dt stream
let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe
function stepLeaves(dt, t) {
probe.set(0, 1.6, 0);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
leafEma += (sp - leafEma) * Math.min(1, dt / 2.5);
const n = leafEma < LEAF_START ? 0
: Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5));
leafDist += sp * 0.85 * dt; // leaves ride a little under the wind
const inv = sp > 1e-4 ? 1 / sp : 0;
const dx = w.x * inv, dz = w.z * inv;
for (let i = 0; i < LEAF_MAX; i++) {
const m = leafMeshes[i], s = leafSeed[i];
const vis = i < n && inv > 0;
m.visible = vis;
if (!vis) continue;
const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2;
const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1;
const x = dx * along - dz * lat;
const z = dz * along + dx * lat;
m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z);
m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph);
}
}
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
function placeholder(spec) {
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
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;
const rand = eventRng(ev, t); // this crate's own stream — see eventRng above
// 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; },
/** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */
get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; },
/** Positions of the flying leaves — for asserts and D's judging. */
get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); },
/** Lane E's GLBs, once they land. name -> Object3D template. */
setModels(map) { Object.assign(models, map); return debris; },
/** 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} — optional, duck-typed. (A `sail` key is
* accepted and ignored: debris-vs-sail is sail.js's `_applyDebris`.)
*/
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);
}
stepLeaves(dt, t); // gate 2.3 — the ambient tell
}
const player = world.player;
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;
}
}
}
// debris-vs-sail lives in sail.js (`_applyDebris` reads `debris.pieces`
// each step — SPRINT2 decision 5, option b). The old duck-typed seam
// here (`if (sail.nodes) applyToSail(...)`) is deleted, not dormant:
// had Lane B ever exposed `.nodes`, every hit would have been applied
// TWICE through two different contact models.
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);
}
}
},
/**
* ARRIVE MID-STORM at t0. [SPRINT18 — the emergency callout's entry]
*
* The storm ran; you just weren't there. Three classes of state, and only one
* of them can be teleported:
*
* 1. closed-form in t — the wind field, rain rate, hail rate. Measured
* order-independent (sample t=30 cold, or after walking 0→30, or after
* scrambling 90/0/45/12/88/30: byte-identical). Needs nothing.
* 2. a pure function of its own event — crate jitter, as of eventRng above.
* Needs nothing, now.
* 3. a genuine path integral — `leafDist` (metres travelled downwind) and
* `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and
* pretending otherwise is what made the arrival read wrong: measured on
* the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a
* cold entry has 0 — and stays becalmed for ~7 s while the EMA climbs
* off zero. DESIGN.md line 149 makes the moving grass the gust front's
* tell, and gate 1's whole premise is that the sky and the grass are the
* only briefing a callout gets. Losing the tell for the first seven
* seconds robs exactly the read the dispatch is asking the player to make.
*
* So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and
* nothing more — the same ladder, the same code path, no shortcut. It is
* cheap because the ambient layer is two scalars and a wind sample (crates
* spawn, fly and despawn honestly on the way past). Do NOT "optimise" this
* into a teleport; the pin in c.test.js exists to catch that, and the
* negative control beside it records what the teleport actually costs.
*
* ⚠️ The ladder ACCUMULATES — `t += dt`, not `i * dt`. I wrote it as `i * dt`
* first (exact multiplication, no drift) and the pin went red: 6 leaves in
* both yards, in different places. `i * dt` is the MORE ACCURATE ladder and
* that is precisely why it is wrong — main.js's `phaseT += dt`, testkit's
* `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up
* that multiplies reproduces a storm the game never flies. Measured: over
* 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a
* different `t` on 1770 of those 1800 steps, and they first disagree at step
* SIX (0.09999999999999999 against 0.1). A number gathered from a harness
* that is a hair better than the game's is still a number from the wrong
* harness — the class of bug this repo hunts, in its smallest possible form.
*
* @param {number} t0 storm time to arrive at, seconds
* @param {number} dt fixed step — pass contracts.FIXED_DT, the same ladder
* the caller will keep stepping on
*/
enterAt(t0, dt) {
const steps = Math.round(t0 / dt);
let t = 0;
for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; }
return t;
},
/** Drop everything (phase change, restart). */
clear() {
for (const p of pieces) despawn(p);
pieces.length = 0;
// leaves are a pooled ambient layer, not spawned pieces: hide and rewind
// so the next night's stream starts from the same state every time
for (const m of leafMeshes) m.visible = false;
leafDist = 0; leafEma = 0;
},
};
return debris;
}