From 7e8ffa307c51c7b9d0a1c5601afa45de45beaf89 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 23:58:14 +1000 Subject: [PATCH] Land decisions 4 and 5: Lane D's rig seam, debris impulses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 4 — conform to Lane D's call sites rather than the reverse (D landed first and duck-typed them): repair(i), trim(i, delta) and cornerPos(i). repair takes no hardware argument because prep sells exactly one kind of spare, so it re-rigs at shackle grade — an upgrade on a blown carabiner, a downgrade on a blown rated shackle, which is the prototype's behaviour and a real choice about which corner you run to. cornerPos returns a fresh vector at the live node, so a blown corner's prompt chases the flogging corner instead of sitting on a dead anchor (measured: 13 m off). All three are contract entries now, not PROPOSED comments, so the merge tripwire enforces the seam. Decision 5 — sail.step() takes an optional debris and applies sphere-vs- cloth impulses. The exchange is symmetric: every newton-second the cloth takes out of a crate, the crate loses. Asserted, and it conserves to 0.000% on an interior hit. Pinned corners are the deliberate exception — invMass 0 means a crate off a corner dumps its momentum into the house, which is correct, the anchor is bolted to a wall. Note this leaves debris.js's applyToSail dead: it guards on `sail.nodes`, which never existed on the rig — the cloth stores Float64Arrays. So the debris-vs-sail impulse has been silently doing nothing in the assembled game. Decision 5 puts it on this side; flagged for Lane C in THREADS. The contact radius is swept by the piece's travel because main.js steps the sail before the debris (so piece positions are a frame stale) and a 0.3 m crate at 25 m/s covers 0.42 m per frame — enough to pass clean between cloth nodes. Also: coverageOver() rays now start at heightAt(x,z) rather than y=0. Co-Authored-By: Claude Opus 4.8 --- web/world/js/contracts.js | 17 +++- web/world/js/sail.js | 154 ++++++++++++++++++++++++++++++++-- web/world/js/sail.selftest.js | 140 +++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 12 deletions(-) diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index 5219bc3..6e78521 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -177,9 +177,20 @@ export class Emitter { * angle around their centroid. tension scales spring rest lengths, 0.6–1.4 * (low = loose and floggy, high = drum tight and shock-loaded). * @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic. - * @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver - * Ground-projected shade over a rect, 0..1. + * @property {(rect: {x:number,z:number,w:number,d:number}, sunDir?: THREE.Vector3, heightAt?: (x:number,z:number)=>number) => number} coverageOver + * Ground-projected shade over a rect, 0..1. Pass world.sunDir and + * world.heightAt so the rays start at the real ground and point at the real + * sun; the defaults (overhead sun, flat y=0) are only for tests. * @property {Emitter} events Emits 'break' and 'repair' as {type, corner}. + * @property {(i: number) => void} repair + * Re-rig corner i with the carried spare (shackle grade — the only kind prep + * sells). No-op if the corner isn't broken. Lane D's 2.5 s hold-E. + * @property {(i: number, delta: number) => void} trim + * Per-corner turnbuckle; delta is ±, clamped to 0.85–1.15. Lane D's 1.2 s hold. + * @property {(i: number) => (THREE.Vector3|null)} cornerPos + * LIVE world position of corner i, as a fresh vector safe to keep. A blown + * corner's node is flying, so an interaction prompt anchored to this chases + * the flogging corner instead of sitting on the dead anchor. null if unrigged. */ /** @@ -253,7 +264,7 @@ export class Emitter { export const CONTRACT = { wind: { sample: 'function', gustTelegraph: 'function' }, world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' }, - sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object' }, + sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' }, player: { pos: 'object', carrying: '*', busy: '*', update: 'function' }, interact: { register: 'function' }, camera: { object: 'object', yaw: 'number', update: 'function' }, diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 3e01f42..24e06c2 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -18,10 +18,17 @@ * appears in createSailView(), which is imported lazily. */ +import * as THREE from '../vendor/three.module.js'; import { Emitter, FIXED_DT, HARDWARE } from './contracts.js'; export { HARDWARE }; +/** + * What a carried spare re-rigs a corner with. The prep phase sells exactly one + * kind ("spare shackle, $15"), so repair() has no hardware argument to take. + */ +const SPARE_HW = HARDWARE[1]; + // ---------- sim tunables ---------- const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches @@ -49,6 +56,10 @@ const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS); const COMP_BEND = 1 / (FABRIC_K * K_BEND); const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping +// ---------- debris (SPRINT2 decision 5) ---------- +const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces +const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness + // ---------- failure ---------- const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x @@ -306,16 +317,20 @@ export class SailRig { * so a variable-rate render loop and a fast-forwarded selftest produce * identical traces. Never reads a clock. * - * @param {number} dt seconds elapsed since last call - * @param {object} wind { sample(pos, t) -> {x,y,z} } - * @param {number} t world time, seconds + * @param {number} dt seconds elapsed since last call + * @param {object} wind { sample(pos, t) -> {x,y,z} } + * @param {number} t world time, seconds + * @param {object} [debris] Lane C's debris module, or anything with `.pieces`. + * Optional — the cloth runs fine without a storm's + * worth of crates in it. */ - step(dt, wind, t) { + step(dt, wind, t, debris = null) { if (!this.rigged) return; + const pieces = debris ? (debris.pieces ?? debris) : null; this._acc += dt; let n = 0; while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) { - this._substep(SIM_DT, wind, this.t); + this._substep(SIM_DT, wind, this.t, pieces); this._acc -= SIM_DT; this.t += SIM_DT; n++; @@ -323,8 +338,9 @@ export class SailRig { if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up } - _substep(dt, wind, t) { + _substep(dt, wind, t, pieces) { this._accumulateWind(wind, t, dt); + if (pieces && pieces.length) this._applyDebris(pieces, dt); this._integrate(dt); this.lambda.fill(0); // XPBD multipliers are per-substep for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt); @@ -382,6 +398,88 @@ export class SailRig { } } + /** + * Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b). + * + * The exchange is symmetric: every newton-second the cloth takes out of a + * crate, the crate loses. That's the point of the decision — one integrator + * does the momentum bookkeeping, so a crate punching through a sail slows + * down by exactly as much as it speeds the cloth up. Asserted in + * sail.selftest.js. + * + * Pinned corners are the deliberate exception: they have invMass 0, so a + * crate that hits one bounces off and the momentum goes into the house. That + * is correct — the anchor is bolted to a wall — and it's why the momentum + * assert uses an interior hit. + * + * @param {Array} pieces debris.pieces — {x,y,z,vx,vy,vz,r,mass} + */ + _applyDebris(pieces, dt) { + const pos = this.pos, prev = this.prev, im = this.invMass; + for (const p of pieces) { + if (p.alive === false || !Number.isFinite(p.mass) || p.mass <= 0) continue; + + // Swept: main.js steps the sail BEFORE the debris, so these positions are + // a frame stale, and a 0.3 m crate at 25 m/s covers 0.42 m in a frame — + // enough to pass clean between cloth nodes. Growing the contact radius by + // the piece's travel catches both the lag and the tunnelling. + const speed = Math.hypot(p.vx, p.vy, p.vz); + const solid = p.r + DEBRIS_SKIN; + const reach = solid + speed * dt; + const reachSq = reach * reach; + const wPiece = 1 / p.mass; + + let jx = 0, jy = 0, jz = 0, hits = 0; + for (let n = 0; n < im.length; n++) { + const i = n * 3; + const dx = pos[i] - p.x, dy = pos[i + 1] - p.y, dz = pos[i + 2] - p.z; + const dsq = dx * dx + dy * dy + dz * dz; + if (dsq > reachSq || dsq < 1e-12) continue; + const d = Math.sqrt(dsq); + const nx = dx / d, ny = dy / d, nz = dz / d; // piece centre -> node + + // node velocity, read out of verlet + const vnx = (pos[i] - prev[i]) / dt; + const vny = (pos[i + 1] - prev[i + 1]) / dt; + const vnz = (pos[i + 2] - prev[i + 2]) / dt; + const vrel = (vnx - p.vx) * nx + (vny - p.vy) * ny + (vnz - p.vz) * nz; + if (vrel > 0) continue; // already separating — don't glue them together + + const wNode = im[n]; + const denom = wNode + wPiece; + if (denom < 1e-12) continue; + const j = (-(1 + DEBRIS_RESTITUTION) * vrel) / denom; + hits++; + + // node takes +j along the contact normal; verlet stores velocity as a + // position difference, so the impulse goes in by moving `prev` + prev[i] -= nx * j * wNode * dt; + prev[i + 1] -= ny * j * wNode * dt; + prev[i + 2] -= nz * j * wNode * dt; + + // ...and the piece takes exactly -j. This is the conservation. + jx -= nx * j; jy -= ny * j; jz -= nz * j; + + // Depenetrate free nodes by moving pos AND prev together, so pushing + // the cloth off the crate doesn't secretly inject velocity. + if (wNode > 0 && d < solid) { + const push = solid - d; + pos[i] += nx * push; prev[i] += nx * push; + pos[i + 1] += ny * push; prev[i + 1] += ny * push; + pos[i + 2] += nz * push; prev[i + 2] += nz * push; + } + } + + if (hits) { + p.vx += jx * wPiece; p.vy += jy * wPiece; p.vz += jz * wPiece; + this.events.emit('debrisHit', { + type: 'debrisHit', piece: p, nodes: hits, + impulse: Math.hypot(jx, jy, jz), t: this.t, + }); + } + } + } + _integrate(dt) { const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass; const dt2 = dt * dt; @@ -503,8 +601,43 @@ export class SailRig { if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; } } + // --- Lane D's seam (SPRINT2 decision 4) -------------------------------- + // D landed first and duck-typed these against the rig, so B conforms to D's + // spelling rather than the other way round. Thin aliases on purpose: the + // behaviour lives in repairCorner/trimCorner, these just match the call sites + // in interact.js and are what contracts.js promises. + + /** + * Re-rig corner `i` with the spare the player was carrying. The spare is the + * "$15 spare shackle" the prep phase sells, so it re-rigs at shackle grade — + * which can be an UPGRADE on a corner that blew a carabiner, and a downgrade + * on one that blew a rated shackle. That's the prototype's behaviour and it's + * a real decision about which corner you run back to. + * @param {number} i + */ + repair(i) { this.repairCorner(i, SPARE_HW); } + + /** + * Per-corner turnbuckle. @param {number} i @param {number} delta ±, clamped 0.85–1.15. + */ + trim(i, delta) { this.trimCorner(i, delta); } + + /** + * Live world position of corner `i`, as a FRESH vector — a blown corner's node + * is flying, so Lane D's prompt has to chase it rather than sit on the anchor. + * Fresh (not shared scratch) because interact.js holds the result across the + * frame and two corners are read back to back. + * @param {number} i + * @returns {THREE.Vector3|null} + */ + cornerPos(i) { + if (!this.rigged || !this.corners[i]) return null; + const n = this.cornerIdx[i] * 3; + return new THREE.Vector3(this.pos[n], this.pos[n + 1], this.pos[n + 2]); + } + /** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */ - repairCorner(index, hw = HARDWARE[1]) { + repairCorner(index, hw = SPARE_HW) { const c = this.corners[index]; if (!c || !c.broken) return false; c.broken = false; @@ -539,8 +672,10 @@ export class SailRig { * @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres * @param {object} sunDir world.sunDir — unit vector from the ground TOWARD * the sun. A hit means shaded. Defaults to overhead. + * @param {function} heightAt world.heightAt — rays start at the real ground. + * Defaults to a flat y=0, which is only right for tests. */ - coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) { + coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }, heightAt = null) { if (!this.rigged) return 0; const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1; const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len; @@ -553,7 +688,8 @@ export class SailRig { // rect is centre-and-size, so samples straddle (rect.x, rect.z) const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w; const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d; - if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++; + const oy = heightAt ? heightAt(ox, oz) : 0; + if (this._rayHitsSail(ox, oy, oz, dx, dy, dz)) hit++; } } return hit / (COLS * ROWS); diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index d37d7f1..373be0e 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -332,6 +332,146 @@ test('break and repair emit on the events Emitter', () => { return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`; }); +// --- SPRINT2 decision 4: the seam Lane D already calls --------------------- + +test('decision 4: repair(i) re-rigs a blown corner with the spare', () => { + const w = constantWind({ x: 0, y: 0, z: 20 }); + const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] }); + runStorm(r, w, 4); + r.corners[0].broken = true; + r._repin(r.t); + + // exactly Lane D's interact.js call: no hardware argument, return ignored + r.repair(0); + assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner'); + assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`); + assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again'); + runStorm(r, w, 3); + assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`); + return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`; +}); + +test('decision 4: repair(i) on an intact corner is a no-op', () => { + const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] }); + runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2); + const hw = r.corners[1].hw; + r.repair(1); // D gates on corner.broken, but the rig must not trust that + assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware'); + return 'intact corner untouched'; +}); + +test('decision 4: trim(i, delta) tightens one corner only', () => { + const r = rig(HEIGHTS_HYPAR); + r.trim(0, +0.1); + assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`); + assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour'); + for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down + assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`); + return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`; +}); + +test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => { + const w = makeStubWind({ seed: 11, stormLen: 90 }); + const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); + const anchor = r.corners[0].anchor.pos; + const p0 = r.cornerPos(0); + assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6, + 'an intact corner should report its anchor position'); + assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch'); + + // blow it, then confirm the prompt would follow the flying corner + r.corners[0].broken = true; + r._repin(r.t); + runStorm(r, w, 6); + const p1 = r.cornerPos(0); + const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z); + assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`); + assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null, + 'cornerPos on an unrigged rig should be null, not a throw'); + return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`; +}); + +// --- SPRINT2 decision 5: debris ------------------------------------------- + +const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over }); + +test('decision 5: a crate hitting the sail conserves momentum', () => { + const r = rig(HEIGHTS_FLAT); + runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing + // aimed at the belly, not a corner: a pinned corner would (correctly) dump + // momentum into the house and there'd be nothing to conserve + const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2); + const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 }); + + const clothP = () => { + let x = 0, y = 0, z = 0; + for (let n = 0; n < r.invMass.length; n++) { + if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house + const i = n * 3; + x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass; + y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass; + z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass; + } + return { x, y, z }; + }; + const total = () => { + const c = clothP(); + return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass }; + }; + + const before = total(); + r._applyDebris([p], SIM_DT); + const after = total(); + + const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z); + const scale = Math.hypot(before.x, before.y, before.z); + assert(scale > 1, 'test crate carries no momentum to conserve'); + assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`); + assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`); + return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`; +}); + +test('decision 5: a crate through the sail shoves the cloth and emits', () => { + const r = rig(HEIGHTS_FLAT); + runStorm(r, makeStubWind({ calm: true }), 4); + const hits = []; + r.events.on('debrisHit', (e) => hits.push(e)); + + const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2); + const before = r.pos[mid * 3 + 1]; + const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 }); + const v0 = p.vy; + + // Peak, not final: the crate crosses the cloth in about three frames and the + // membrane springs back well inside the run, so sampling the end measures the + // recovery rather than the punch. + const wind = makeStubWind({ calm: true }); + let peak = before; + for (let i = 0; i < 30; i++) { + r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] }); + p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT; + peak = Math.max(peak, r.pos[mid * 3 + 1]); + } + assert(hits.length > 0, 'crate passed through the cloth without a single contact'); + assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`); + assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`); + return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`; +}); + +test('decision 5: no debris and empty debris are both fine', () => { + const w = makeStubWind({ seed: 2, stormLen: 20 }); + const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR); + for (let i = 0; i < 600; i++) { + a.step(SIM_DT, w, i * SIM_DT); // Lane A's 3-arg call still works + b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] }); + } + for (let k = 0; k < 4; k++) { + assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9, + 'an empty debris list changed the sim'); + } + return 'empty and absent debris both no-op'; +}); + test('runs against the shared contracts.js stub wind', () => { // Proves the rig eats the sanctioned Wind implementation, not just my local // stub — so nothing surprises us when Lane C's weather.js drops in.