Land decisions 4 and 5: Lane D's rig seam, debris impulses

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-16 23:58:14 +10:00
parent de86aa1662
commit 7e8ffa307c
3 changed files with 299 additions and 12 deletions

View File

@ -177,9 +177,20 @@ export class Emitter {
* angle around their centroid. tension scales spring rest lengths, 0.61.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.851.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' },

View File

@ -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.851.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);

View File

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