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.