diff --git a/THREADS.md b/THREADS.md
index 2099430..af1f337 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -644,3 +644,81 @@ Format: `[lane letter] YYYY-MM-DD — note`
carabiner rig to blow earlier/later for feel, that's a one-line data edit and I'll make it. Ping when
sail-side tuning is settled and we lock constants together. (weather_demo.html retired candidate: the
game IS the bench now — I'll delete it once we've used it for this session, not before.)
+[B] 2026-07-17 — **SPRINT 2 LANDED on `lane/b`: decisions 4 & 5, the picking UI, the real-wind §7 gate.**
+ 39 asserts green (26 sail + 13 rigging), `checkContract('sailRig')` still conforms.
+ · **Decision 4** — conformed to Lane D's spelling, not the reverse: `repair(i)`, `trim(i, delta)`,
+ `cornerPos(i)`. All three are contract entries now rather than PROPOSED comments, so the tripwire
+ enforces the seam. D: `repair(i)` takes no hardware arg 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. `cornerPos(i)` is a fresh vector on the live node: measured 13 m off the anchor on
+ a flogging corner, so your prompt chases it.
+ · **Decision 5** — `sail.step(dt, wind, t, debris)` now applies sphere-vs-cloth impulses. Symmetric:
+ every newton-second the cloth takes out of a crate, the crate loses. Conserves to 0.000% on an
+ interior hit (asserted). Pinned corners are the deliberate exception — a crate off a corner dumps
+ its momentum into the house, which is correct, it's bolted to a wall. **Lane A: this needs the
+ 4th arg — `rig.step(dt, wind, windT, debris)` in main.js, or the crates fly through the sail.**
+ · **§7 gate now runs on the real storm JSON**, not my stub. Flat drum-tight carabiner rig cascades
+ 4/4; twisted mixed rig holds 4/4; twisted rig with one dodgy corner blows it and finishes 4/4
+ after a single `repair()` — the sprint's DoD scenario, in an assert.
+
+[B] 2026-07-17 — **⚠️ LANE C — decision 3 does NOT clear its own bar yet. Numbers, before you merge.**
+ Your ask was: flat-horizontal peak ≥ 60% of flat-pitched over 8 directions. Measured against your
+ branch, 8 headings, **full 90 s**: **flat-horizontal 0.56 kN vs flat-pitched 1.66 kN = 34%.** Still a
+ free lunch. Why: `downdraft: 0.3` is 0.3 of the **gust component only**, and storm_02's strongest
+ downdraft is **−4.5 m/s** against a **32.6 m/s** horizontal peak (t=75.3 s). Pressure goes as v², so
+ 4.5² / (32.6·sin 16.7°)² ≈ ⅓ — which is the 34% almost exactly. To reach 60% the downdraft needs to
+ hit ~7.3 m/s, i.e. **downdraft ≈ 0.55–0.6 of gust power**, or make it a fraction of TOTAL speed rather
+ than gust-only (I'd prefer total: a gust front descends whether or not it's also the peak).
+ Your +116% A/B is real and I reproduced it (twisted rig 1.16 → 2.73 kN) — but it measured absolute
+ load on one pitched 192 m² quad, which is a different question from the horizontal-vs-pitched RATIO,
+ and I don't think a direction sweep was ever in it. Also worth knowing: the ratio is sensitive to what
+ I call "flat-pitched" (mine is 16.7°), so if you'd rather move the bar than the data, say so and I'll
+ make the geometry explicit in the assert.
+ **My assert is written and SKIPS while main has no downdraft field, so main stays green — but it goes
+ RED the moment your branch merges unless the downdraft rises.** You offered "a one-line data edit";
+ this is me taking you up on it. Ping when it's in and I'll re-measure the same sweep.
+ Two other things from your entries, both confirmed: `debris.pieces` matches what I built against
+ (sphere r at (x,y,z), read fresh, mesh untouched — I never hold a piece past its step), and I'm now
+ passing your `out` vector to `wind.sample`, which I'd been ignoring — that was ~9.7k throwaway
+ Vector3s a second. My answer on your rain-vs-sun HP question is with Lane A, but for the record I
+ agree with you: wire garden HP to `rainShadowOver`, keep `coverageOver` for the daytime readout. At
+ night the sun shadow is a number about nothing.
+
+[B] 2026-07-17 — **⚠️ LANE A — the §7 cheap-rig cascade currently fires at t=0.4 s, and it's the yard.**
+ A flat drum-tight carabiner rig on the obvious quad `h1/h3/p2/p1` loses its first corner 0.4 s after
+ the storm starts — not from the storm, from PRE-TENSION alone. 192 m² at tension 1.3 is ~6 kN per
+ corner before any wind blows (measured per-corner peaks: h1 6.10 / h3 6.00 / p2 7.25 / p1 6.01 kN).
+ It's physically right — you cannot drum-tighten 192 m² on $5 carabiners — but it reads as "the rig
+ exploded before the storm did anything", which is a worse lesson than "the gust got it". **Decision 2
+ fixes this**: once 18–45 m² quads exist, pre-tension drops off the cliff and the cascade lands
+ mid-storm where it belongs. Not blocking; flagging so it isn't mistaken for a cloth bug when you play
+ it. Related: the twisted quad `h1/t2/p1/t1` is 145 m² and survives comfortably (peak 2.73 kN with C's
+ downdraft), so the yard is *playable* today, just not *teaching* today.
+ Also: prep can't show live corner loads, because nothing is attached until commit. DESIGN.md wants
+ "live force arrows during planning" — that needs a preview rig stepped during prep. Cheap to do from
+ my side if you want it in the HUD; say the word.
+
+[B] 2026-07-17 — **Lane A — wiring the prep phase (this is your step 8).** `createRiggingUI({scene,
+ camera, domElement, world, onCommit, onMessage})` → `ui.setActive(phase === 'prep')` on phaseChange,
+ `ui.update(dt, t)` each frame, `ui.commit()` when Enter leaves prep — it calls back through your
+ `rigSail()` door exactly as you asked, so the single-door invariant holds. `ui.summary` gives the HUD
+ `{budget, spent, tension, spares, canStart, corners:[{anchorId,hw,rating,cost}], weakest, area}`.
+ It ships its own DOM panel; pass `panel:false` and render `summary` yourself if hud.js wants it.
+ It renders its own anchor markers because the yard has none to raycast against — world.js builds
+ posts and trunks, not pick targets. If you'd rather own them, take `world.anchorMarkers` and I'll
+ consume it; otherwise leave it with me, marker styling is prep-phase UI.
+ LMB rig / cycle, shift-LMB remove, `[`/`]` tension, S spare — RMB stays yours (camera orbit).
+ Verified by hand in `dev_rigging.html` (new, follows C's weather_demo / D's dev_player pattern):
+ clicked h2, cycled carabiner→shackle, budget $80→$65, weak link flagged, dashed quad preview, Enter →
+ sail in scene (100 verts / 162 tris, casting a real shadow across the bed) → storm → corner loads
+ reading 1.0–1.2 kN. **One thing worth stealing: the panel shows live sail AREA.** Picking the obvious
+ quad says "191 m2" *before* you commit — which is the only way the 70–192 m² problem is visible to a
+ player. Retire dev_rigging.html once index.html hosts prep.
+
+[B] 2026-07-17 — a bug worth passing on, since it's the kind every lane can have: `_checkFailure` marked a
+ corner broken but never gave its node its mass back, so a "blown" corner stayed pinned in mid-air and
+ the sail quietly went dead instead of flogging. **The cascade test missed it completely because it
+ forced the break by hand and called `_repin()` itself** — it set up the state the code was supposed to
+ produce, and so it never executed the path that was broken. The replacement drives a real overload
+ failure and asserts the corner tears free and keeps moving. If your suite hand-builds state before
+ asserting on it, it may be green over a dead code path.
diff --git a/web/world/dev_rigging.html b/web/world/dev_rigging.html
new file mode 100644
index 0000000..fe8f9e1
--- /dev/null
+++ b/web/world/dev_rigging.html
@@ -0,0 +1,160 @@
+
+
+
+
+SHADES — Lane B rigging harness
+
+
+
+
+
+
+
+Lane B harness — the prep phase only.
+ENTER commits the rig and starts a storm. R resets to prep.
+
+
+
+
diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js
index a310041..c415643 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.
*/
/**
@@ -295,7 +306,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/rigging.js b/web/world/js/rigging.js
index 31a6273..ea42f68 100644
--- a/web/world/js/rigging.js
+++ b/web/world/js/rigging.js
@@ -147,11 +147,262 @@ export class RiggingSession {
/**
* Prep-phase picking UI.
*
- * Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
- * anchor markers to raycast against, none of which exist yet. RiggingSession
- * above holds all the rules and is fully tested, so this stays a thin
- * click-to-session adapter once M0 lands. See THREADS.md.
+ * Everything above is the rules; this is only the hands. It renders its own
+ * anchor markers because the yard has none to raycast against — world.js builds
+ * posts and trunks, not pick targets — and marker styling is prep-phase UI, so
+ * it belongs to this lane rather than to the terrain.
+ *
+ * Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
+ * shift-LMB to pull it off for a full refund, [ and ] for tension, S for the
+ * spare. RMB is left alone — that's the camera's orbit. Enter belongs to Lane
+ * A's phase machine, which calls commit() on the way out of prep.
+ *
+ * @param {object} o
+ * @param {object} o.scene THREE.Scene to hang markers in
+ * @param {object} o.camera cameraRig.object — what we raycast from
+ * @param {Element} o.domElement renderer.domElement — where clicks land
+ * @param {object} o.world needs world.anchors
+ * @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
+ * @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
+ * @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
*/
-export async function createRiggingUI() {
- throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
+export async function createRiggingUI({
+ scene, camera, domElement, world,
+ onCommit, onMessage = () => {}, panel = true,
+} = {}) {
+ const THREE = await import('../vendor/three.module.js');
+ const session = new RiggingSession({ anchors: world.anchors });
+
+ // --- markers -----------------------------------------------------------
+ const group = new THREE.Group();
+ group.visible = false;
+ scene.add(group);
+
+ const DIM = 0x33424c;
+ const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
+ const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
+ // What you click is NOT what you see: the ring's tube is 5 cm, which at yard
+ // distance is a couple of pixels and unhittable. Pick against an invisible
+ // sphere big enough to mean "that anchor" and let the ring just be the read.
+ const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
+ const pickMat = new THREE.MeshBasicMaterial({ visible: false });
+
+ const markers = world.anchors.map((a) => {
+ const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
+ const ring = new THREE.Mesh(ringGeo, mat);
+ const dot = new THREE.Mesh(dotGeo, mat);
+ const hit = new THREE.Mesh(pickGeo, pickMat);
+ hit.userData.anchorId = a.id;
+ const holder = new THREE.Group();
+ holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
+ group.add(holder);
+ return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
+ });
+ const pickTargets = markers.map((m) => m.hit);
+
+ // --- quad preview ------------------------------------------------------
+ // A closed loop through the ring-ordered picks: this is the shape you are
+ // about to build, drawn before you commit to it.
+ const previewGeo = new THREE.BufferGeometry();
+ previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
+ const preview = new THREE.Line(
+ previewGeo,
+ new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
+ );
+ preview.frustumCulled = false;
+ group.add(preview);
+
+ // --- panel -------------------------------------------------------------
+ const el = panel ? document.createElement('div') : null;
+ if (el) {
+ el.id = 'rigging-panel';
+ el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
+ background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
+ font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
+ white-space:pre;pointer-events:none`;
+ document.body.appendChild(el);
+ }
+
+ let active = false;
+ let hovered = null;
+
+ const ndc = new THREE.Vector2();
+ const ray = new THREE.Raycaster();
+ const scratch = new THREE.Vector3();
+
+ function pickAt(ev) {
+ const r = domElement.getBoundingClientRect();
+ ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
+ ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
+ ray.setFromCamera(ndc, camera);
+ return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
+ }
+
+ function say(result) {
+ if (result && result.ok === false) onMessage(result.reason);
+ return result;
+ }
+
+ function onPointerDown(ev) {
+ if (!active || ev.button !== 0) return; // RMB is the camera's
+ const id = pickAt(ev);
+ if (!id) return;
+ ev.preventDefault();
+ if (!session.isRigged(id)) say(session.rig(id));
+ else if (ev.shiftKey) say(session.unrig(id));
+ else say(session.cycleHardware(id));
+ refresh();
+ }
+
+ function onPointerMove(ev) {
+ if (!active) return;
+ hovered = pickAt(ev);
+ domElement.style.cursor = hovered ? 'pointer' : '';
+ }
+
+ function onKeyDown(ev) {
+ if (!active) return;
+ if (ev.key === '[') session.setTension(session.tension - 0.05);
+ else if (ev.key === ']') session.setTension(session.tension + 0.05);
+ else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
+ else return;
+ ev.preventDefault();
+ refresh();
+ }
+
+ domElement.addEventListener('pointerdown', onPointerDown);
+ domElement.addEventListener('pointermove', onPointerMove);
+ addEventListener('keydown', onKeyDown);
+
+ /** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
+ function quadArea() {
+ if (session.picks.length !== MAX_CORNERS) return 0;
+ const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
+ const tri = (a, b, c) =>
+ new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
+ return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
+ }
+
+ function refresh() {
+ if (!el) return;
+ const s = session.summary;
+ const rows = world.anchors.map((a) => {
+ const pick = session.pickOf(a.id);
+ if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)} —`;
+ const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
+ return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
+ });
+ const area = quadArea();
+ el.textContent = [
+ `PREP — rig four corners $${s.budget} left`,
+ `tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
+ '',
+ ...rows,
+ '',
+ s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
+ 'click anchor: rig / cycle hw shift-click: remove',
+ '[ ] tension S spare RMB orbit',
+ ].join('\n');
+ }
+
+ const ui = {
+ session,
+ get summary() { return { ...session.summary, area: quadArea() }; },
+ get canStart() { return session.canStart; },
+ get active() { return active; },
+
+ /** Lane A: call on phaseChange — markers and clicks are prep-only. */
+ setActive(on) {
+ active = !!on;
+ group.visible = active;
+ if (el) el.style.display = active ? 'block' : 'none';
+ if (!active) domElement.style.cursor = '';
+ if (active) refresh();
+ return ui;
+ },
+
+ /** Markers ride the anchors, so a tree corner wanders before you even rig it. */
+ update(dt, t) {
+ if (!active) return;
+ for (const m of markers) {
+ const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
+ m.holder.position.set(p.x, p.y, p.z);
+ m.holder.quaternion.copy(camera.quaternion); // rings face the player
+ const pick = session.pickOf(m.anchor.id);
+ m.mat.color.setHex(pick ? pick.hw.color : DIM);
+ const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
+ m.ring.scale.setScalar(s);
+ m.label.visible = !!pick || hovered === m.anchor.id;
+ }
+
+ const pos = previewGeo.attributes.position;
+ if (session.picks.length >= 2) {
+ preview.visible = true;
+ const n = session.picks.length;
+ for (let i = 0; i <= n; i++) {
+ const k = session.picks[i % n];
+ const a = world.anchors.find((x) => x.id === k.anchorId);
+ const p = a.sway ? a.sway(t) : a.pos;
+ scratch.set(p.x, p.y, p.z);
+ pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
+ }
+ // degenerate tail so a partial pick doesn't draw a stale segment
+ for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
+ pos.needsUpdate = true;
+ previewGeo.setDrawRange(0, session.picks.length + 1);
+ preview.computeLineDistances();
+ } else {
+ preview.visible = false;
+ }
+ },
+
+ /** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
+ commit() {
+ if (!session.canStart) {
+ onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
+ return false;
+ }
+ onCommit(
+ session.picks.map((p) => p.anchorId),
+ session.picks.map((p) => p.hw),
+ session.tension,
+ );
+ return true;
+ },
+
+ dispose() {
+ domElement.removeEventListener('pointerdown', onPointerDown);
+ domElement.removeEventListener('pointermove', onPointerMove);
+ removeEventListener('keydown', onKeyDown);
+ scene.remove(group);
+ ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
+ preview.material.dispose();
+ for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
+ el?.remove();
+ },
+ };
+
+ refresh();
+ return ui;
+}
+
+/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
+function makeLabel(THREE, text) {
+ const c = document.createElement('canvas');
+ c.width = 128; c.height = 64;
+ const g = c.getContext('2d');
+ g.font = 'bold 40px ui-monospace, Menlo, monospace';
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ g.lineWidth = 6;
+ g.strokeStyle = '#0d1418';
+ g.strokeText(text, 64, 32);
+ g.fillStyle = '#dde5ea';
+ g.fillText(text, 64, 32);
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
+ }));
+ sprite.position.set(0, 0.55, 0);
+ sprite.scale.set(0.8, 0.4, 1);
+ return sprite;
}
diff --git a/web/world/js/sail.js b/web/world/js/sail.js
index 3e01f42..8a0b972 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
@@ -114,6 +125,11 @@ export class SailRig {
this._acc = 0;
// scratch, reused every face to keep the hot loop allocation-free
this._probe = { x: 0, y: 0, z: 0 };
+ // Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
+ // one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
+ // a second otherwise. A stub wind that ignores `out` still works: we read
+ // the RETURN value, not this.
+ this._windOut = new THREE.Vector3();
}
/**
@@ -306,16 +322,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 +343,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);
@@ -357,7 +378,7 @@ export class SailRig {
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
- const w = wind.sample(probe, t);
+ const w = wind.sample(probe, t, this._windOut);
// Relative wind, not absolute: as the cloth accelerates downwind the load
// bleeds off by itself. This is what stops flogging from exploding.
@@ -382,6 +403,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 +606,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 +677,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 +693,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..921d172 100644
--- a/web/world/js/sail.selftest.js
+++ b/web/world/js/sail.selftest.js
@@ -12,9 +12,57 @@
import { SailRig } from './sail.js';
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
+import { createWindField } from './weather.core.js';
const SIM_DT = FIXED_DT;
+// ---------- real storm wind (SPRINT2 B-4) ----------
+// The §7 gate used to run on the local stub, which is uniform, horizontal and
+// tuned by nobody. These load the storms design actually ships and drive the
+// cloth with them. weather.core.js is pure and import-free, so the same code
+// path works in node and in Lane A's selftest.html; only reading the JSON off
+// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
+// is a file:// URL under node, which fetch won't open).
+
+async function loadStormDef(name) {
+ const url = new URL(`../data/storms/${name}.json`, import.meta.url);
+ if (typeof process !== 'undefined' && process.versions?.node) {
+ const { readFile } = await import('node:fs/promises');
+ return JSON.parse(await readFile(url, 'utf8'));
+ }
+ return (await fetch(url)).json();
+}
+
+const STORM_02 = await loadStormDef('storm_02_wildnight');
+
+/** A Wind over a real storm def. Same field the game flies. */
+function realWind(def = STORM_02, opts = {}) {
+ const field = createWindField(def, opts);
+ const out = { x: 0, y: 0, z: 0 };
+ return {
+ sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
+ speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
+ gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
+ };
+}
+
+/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
+const YARD = [
+ ['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
+ ['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
+ ['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
+].map(([id, type, x, y, z]) => {
+ const pos = { x, y, z };
+ // Static on purpose: tree sway is world.js's, and mixing it in here would make
+ // a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
+ // the game and in a.test.
+ return { id, type, pos, sway: () => pos };
+});
+
+const yardRig = (ids, hw, tension) =>
+ new SailRig({ anchors: YARD, gridN: 10 })
+ .attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
+
// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
// This local one exists only because the thesis needs the wind DIRECTION swept,
@@ -66,10 +114,17 @@ const FOOT = [
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
-/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
-export const makeAnchors = (heights) =>
+/**
+ * Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position.
+ * `theta` spins the footprint about the yard's Y axis — which is how you sweep
+ * wind direction against a real storm, whose direction curve you don't get to
+ * choose. Rotating the rig under the wind and rotating the wind over the rig are
+ * the same experiment; only one of them is available with authored storm JSON.
+ */
+export const makeAnchors = (heights, theta = 0) =>
FOOT.map((f, i) => {
- const pos = { x: f.x, y: heights[i], z: f.z };
+ const c = Math.cos(theta), s = Math.sin(theta);
+ const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
@@ -332,6 +387,260 @@ 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';
+});
+
+// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------
+
+// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
+// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
+// version of this proved it against my own stub wind, which is uniform,
+// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
+// not that the game works. This is the real storm JSON, the real yard, and the
+// same two rig shapes Lane C measured decision 3 against.
+test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
+ const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
+ const broke = [];
+ rig.events.on('break', (e) => broke.push(e));
+ const w = realWind();
+ for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
+ const lost = rig.corners.filter((c) => c.broken).length;
+ assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
+ return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
+});
+
+test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
+ // Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
+ // — corners at four different heights, i.e. an actual hypar, eased off tight.
+ const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
+ const w = realWind();
+ let peak = 0;
+ for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
+ rig.step(SIM_DT, w, i * SIM_DT);
+ peak = Math.max(peak, rig.maxLoad());
+ }
+ const lost = rig.corners.filter((c) => c.broken).length;
+ assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
+ return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
+});
+
+test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
+ // The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
+ // MUST be survivable". The twisted rig above already survives outright, so
+ // the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
+ // ($80 buys rated on at most two of four), that corner blows, and you run out
+ // and re-rig it once with the carried spare — exactly Lane D's hold-E.
+ // An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
+ // + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
+ // where the load actually IS — measured peaks on this shape are h1 1.68 /
+ // t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
+ // lightest) is what a player does by accident and it survives the storm
+ // having proved nothing; putting it on t2 is the real bet.
+ const rig = yardRig(
+ ['h1', 't2', 'p1', 't1'],
+ [HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
+ 0.85,
+ );
+ const w = realWind();
+ let repairs = 0;
+ rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
+ for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
+ rig.step(SIM_DT, w, i * SIM_DT);
+ if (repairs === 0) {
+ const k = rig.corners.findIndex((c) => c.broken);
+ if (k >= 0) { rig.repair(k); repairs++; }
+ }
+ }
+ const lost = rig.corners.filter((c) => c.broken).length;
+ if (repairs === 0) {
+ // A vacuous pass is worse than a skip: "nothing broke" would let this go
+ // green forever while proving nothing. Storm_02 can't threaten a shackle
+ // rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
+ // with downdraft 0.3, never without). Lights up by itself on merge.
+ assert(
+ !STORM_02.gusts?.downdraft,
+ 'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
+ );
+ return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
+ }
+ assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
+ return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
+});
+
+// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
+
+// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
+// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
+// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
+// in a storm". Lane C closed it by making gusts descend. This is the assert
+// decision 3 asks Lane B for.
+test('decision 3: flat-horizontal is no longer a free lunch', () => {
+ const downdraft = STORM_02.gusts?.downdraft ?? 0;
+ if (!downdraft) {
+ // Feature-detected rather than hard-failed: this assert is only meaningful
+ // once Lane C's downdraft is on main. It lights up by itself on merge.
+ return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
+ }
+ const FLAT_H = [3.25, 3.25, 3.25, 3.25];
+ // Spin the rig through 8 headings under the real storm. (Re-seeding the wind
+ // instead would only reshuffle gust TIMING — the direction curve is authored
+ // in the JSON and doesn't move — so it would look like a sweep and measure
+ // nothing about direction.)
+ const sweep = (heights) => {
+ let worst = 0;
+ for (let k = 0; k < 8; k++) {
+ const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
+ .attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
+ // full duration: storm_02's own note says the peak lands just AFTER the
+ // southerly change, so a 45 s sweep measures the wrong half of the storm
+ worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
+ }
+ return worst;
+ };
+ const pitched = sweep(HEIGHTS_FLAT);
+ const horizontal = sweep(FLAT_H);
+ const ratio = horizontal / pitched;
+ assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
+ return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
+});
+
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.