Merge remote-tracking branch 'origin/lane/b'
# Conflicts: # THREADS.md
This commit is contained in:
commit
3f6fc27d00
78
THREADS.md
78
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
|
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
|
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.)
|
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.
|
||||||
|
|||||||
160
web/world/dev_rigging.html
Normal file
160
web/world/dev_rigging.html
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SHADES — Lane B rigging harness</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
|
||||||
|
font: 12px/1.5 ui-monospace, Menlo, monospace; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
|
||||||
|
white-space: pre; pointer-events: none; }
|
||||||
|
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
|
||||||
|
text-align: right; white-space: pre; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="c"></canvas>
|
||||||
|
<div id="dev"></div>
|
||||||
|
<div id="hint">Lane B harness — the prep phase only.
|
||||||
|
ENTER commits the rig and starts a storm. R resets to prep.</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
/**
|
||||||
|
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
|
||||||
|
*
|
||||||
|
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
|
||||||
|
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
|
||||||
|
* and it can't be exercised in index.html until Lane A wires it in step 8. This
|
||||||
|
* boots the real world.js, the real anchors and the real cloth so the thing
|
||||||
|
* being verified is the thing that ships. Retire it once index.html hosts prep.
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createWorld } from './js/world.js';
|
||||||
|
import { createCameraRig } from './js/camera.js';
|
||||||
|
import { createWind, loadStorm } from './js/weather.js';
|
||||||
|
import { SailRig, createSailView } from './js/sail.js';
|
||||||
|
import { createRiggingUI } from './js/rigging.js';
|
||||||
|
import { FIXED_DT } from './js/contracts.js';
|
||||||
|
|
||||||
|
const canvas = document.getElementById('c');
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
|
const [calmDef, wildDef] = await Promise.all([
|
||||||
|
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
|
||||||
|
]);
|
||||||
|
// main.js multiplexes these behind a private router; the harness only ever needs
|
||||||
|
// one at a time, so it just swaps the reference when the storm starts.
|
||||||
|
const calmWind = createWind(calmDef);
|
||||||
|
const wildWind = createWind(wildDef);
|
||||||
|
let wind = calmWind;
|
||||||
|
const windProxy = {
|
||||||
|
sample: (p, t, out) => wind.sample(p, t, out),
|
||||||
|
gustTelegraph: (t) => wind.gustTelegraph(t),
|
||||||
|
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
|
||||||
|
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const world = createWorld(scene, { wind: windProxy });
|
||||||
|
const cameraRig = createCameraRig(canvas);
|
||||||
|
cameraRig.setSolids(world.solids);
|
||||||
|
cameraRig.setGround(world.heightAt);
|
||||||
|
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||||
|
|
||||||
|
const rig = new SailRig({ anchors: world.anchors });
|
||||||
|
let sailView = null;
|
||||||
|
|
||||||
|
async function rigSail(anchorIds, hwChoices, tension) {
|
||||||
|
rig.attach(anchorIds, hwChoices, tension);
|
||||||
|
if (sailView) {
|
||||||
|
scene.remove(sailView);
|
||||||
|
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
|
||||||
|
}
|
||||||
|
sailView = await createSailView(rig);
|
||||||
|
scene.add(sailView);
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = '';
|
||||||
|
let msgT = 0;
|
||||||
|
const ui = await createRiggingUI({
|
||||||
|
scene, camera: cameraRig.object, domElement: canvas, world,
|
||||||
|
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
|
||||||
|
onMessage: (m) => { msg = m; msgT = 2; },
|
||||||
|
});
|
||||||
|
|
||||||
|
let phase = 'prep';
|
||||||
|
ui.setActive(true);
|
||||||
|
|
||||||
|
// harness-only handle, so a debugger (or an agent driving this page) can pick
|
||||||
|
// anchors without hunting for pixels. Never imported by the game.
|
||||||
|
window.__laneB = {
|
||||||
|
ui, rig, world, cameraRig, scene,
|
||||||
|
get phase() { return phase; },
|
||||||
|
/** rig by id, exactly as if you'd clicked it */
|
||||||
|
pick: (id) => ui.session.rig(id),
|
||||||
|
cycle: (id) => ui.session.cycleHardware(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
|
||||||
|
if (e.key.toLowerCase() === 'r') {
|
||||||
|
phase = 'prep';
|
||||||
|
wind = calmWind;
|
||||||
|
ui.setActive(true);
|
||||||
|
if (sailView) { scene.remove(sailView); sailView = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// follow-cam target: the yard centre, so prep reads as a site walk-around
|
||||||
|
const focus = new THREE.Vector3(0, 1.2, 0);
|
||||||
|
const dev = document.getElementById('dev');
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let t = 0, acc = 0;
|
||||||
|
|
||||||
|
renderer.setAnimationLoop(() => {
|
||||||
|
const raw = Math.min(0.25, clock.getDelta());
|
||||||
|
acc += raw;
|
||||||
|
while (acc >= FIXED_DT) {
|
||||||
|
t += FIXED_DT;
|
||||||
|
world.update(FIXED_DT, t);
|
||||||
|
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
|
||||||
|
acc -= FIXED_DT;
|
||||||
|
if (msgT > 0) msgT -= FIXED_DT;
|
||||||
|
}
|
||||||
|
ui.update(raw, t);
|
||||||
|
sailView?.update();
|
||||||
|
cameraRig.update(raw, focus);
|
||||||
|
|
||||||
|
const s = ui.summary;
|
||||||
|
const loads = rig.rigged
|
||||||
|
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
|
||||||
|
: '(not rigged)';
|
||||||
|
dev.textContent =
|
||||||
|
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
|
||||||
|
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
|
||||||
|
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
|
||||||
|
|
||||||
|
renderer.render(scene, cameraRig.object);
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener('resize', () => {
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
||||||
|
cameraRig.object.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
cameraRig.object.aspect = innerWidth / innerHeight;
|
||||||
|
cameraRig.object.updateProjectionMatrix();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -177,9 +177,20 @@ export class Emitter {
|
|||||||
* angle around their centroid. tension scales spring rest lengths, 0.6–1.4
|
* angle around their centroid. tension scales spring rest lengths, 0.6–1.4
|
||||||
* (low = loose and floggy, high = drum tight and shock-loaded).
|
* (low = loose and floggy, high = drum tight and shock-loaded).
|
||||||
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
|
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
|
||||||
* @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver
|
* @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.
|
* 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 {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 = {
|
export const CONTRACT = {
|
||||||
wind: { sample: 'function', gustTelegraph: 'function' },
|
wind: { sample: 'function', gustTelegraph: 'function' },
|
||||||
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: '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' },
|
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
|
||||||
interact: { register: 'function' },
|
interact: { register: 'function' },
|
||||||
camera: { object: 'object', yaw: 'number', update: 'function' },
|
camera: { object: 'object', yaw: 'number', update: 'function' },
|
||||||
|
|||||||
@ -147,11 +147,262 @@ export class RiggingSession {
|
|||||||
/**
|
/**
|
||||||
* Prep-phase picking UI.
|
* Prep-phase picking UI.
|
||||||
*
|
*
|
||||||
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
|
* Everything above is the rules; this is only the hands. It renders its own
|
||||||
* anchor markers to raycast against, none of which exist yet. RiggingSession
|
* anchor markers because the yard has none to raycast against — world.js builds
|
||||||
* above holds all the rules and is fully tested, so this stays a thin
|
* posts and trunks, not pick targets — and marker styling is prep-phase UI, so
|
||||||
* click-to-session adapter once M0 lands. See THREADS.md.
|
* 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() {
|
export async function createRiggingUI({
|
||||||
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,10 +18,17 @@
|
|||||||
* appears in createSailView(), which is imported lazily.
|
* appears in createSailView(), which is imported lazily.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as THREE from '../vendor/three.module.js';
|
||||||
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
|
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
|
||||||
|
|
||||||
export { HARDWARE };
|
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 ----------
|
// ---------- sim tunables ----------
|
||||||
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
|
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
|
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 COMP_BEND = 1 / (FABRIC_K * K_BEND);
|
||||||
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
|
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 ----------
|
// ---------- failure ----------
|
||||||
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
|
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
|
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||||
@ -114,6 +125,11 @@ export class SailRig {
|
|||||||
this._acc = 0;
|
this._acc = 0;
|
||||||
// scratch, reused every face to keep the hot loop allocation-free
|
// scratch, reused every face to keep the hot loop allocation-free
|
||||||
this._probe = { x: 0, y: 0, z: 0 };
|
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
|
* so a variable-rate render loop and a fast-forwarded selftest produce
|
||||||
* identical traces. Never reads a clock.
|
* identical traces. Never reads a clock.
|
||||||
*
|
*
|
||||||
* @param {number} dt seconds elapsed since last call
|
* @param {number} dt seconds elapsed since last call
|
||||||
* @param {object} wind { sample(pos, t) -> {x,y,z} }
|
* @param {object} wind { sample(pos, t) -> {x,y,z} }
|
||||||
* @param {number} t world time, seconds
|
* @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;
|
if (!this.rigged) return;
|
||||||
|
const pieces = debris ? (debris.pieces ?? debris) : null;
|
||||||
this._acc += dt;
|
this._acc += dt;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
|
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._acc -= SIM_DT;
|
||||||
this.t += SIM_DT;
|
this.t += SIM_DT;
|
||||||
n++;
|
n++;
|
||||||
@ -323,8 +343,9 @@ export class SailRig {
|
|||||||
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
|
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);
|
this._accumulateWind(wind, t, dt);
|
||||||
|
if (pieces && pieces.length) this._applyDebris(pieces, dt);
|
||||||
this._integrate(dt);
|
this._integrate(dt);
|
||||||
this.lambda.fill(0); // XPBD multipliers are per-substep
|
this.lambda.fill(0); // XPBD multipliers are per-substep
|
||||||
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
|
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.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
|
||||||
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 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;
|
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
|
// Relative wind, not absolute: as the cloth accelerates downwind the load
|
||||||
// bleeds off by itself. This is what stops flogging from exploding.
|
// 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) {
|
_integrate(dt) {
|
||||||
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
|
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
|
||||||
const dt2 = dt * dt;
|
const dt2 = dt * dt;
|
||||||
@ -503,8 +606,43 @@ export class SailRig {
|
|||||||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
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. */
|
/** 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];
|
const c = this.corners[index];
|
||||||
if (!c || !c.broken) return false;
|
if (!c || !c.broken) return false;
|
||||||
c.broken = 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} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
|
||||||
* @param {object} sunDir world.sunDir — unit vector from the ground TOWARD
|
* @param {object} sunDir world.sunDir — unit vector from the ground TOWARD
|
||||||
* the sun. A hit means shaded. Defaults to overhead.
|
* 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;
|
if (!this.rigged) return 0;
|
||||||
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
|
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
|
||||||
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
|
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)
|
// 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 ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
|
||||||
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
|
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);
|
return hit / (COLS * ROWS);
|
||||||
|
|||||||
@ -12,9 +12,57 @@
|
|||||||
|
|
||||||
import { SailRig } from './sail.js';
|
import { SailRig } from './sail.js';
|
||||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||||
|
import { createWindField } from './weather.core.js';
|
||||||
|
|
||||||
const SIM_DT = FIXED_DT;
|
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 ----------
|
// ---------- deterministic stub wind ----------
|
||||||
// contracts.js ships createStubWind(), and the integration test below uses it.
|
// contracts.js ships createStubWind(), and the integration test below uses it.
|
||||||
// This local one exists only because the thesis needs the wind DIRECTION swept,
|
// 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_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
|
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) => {
|
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 };
|
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`;
|
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', () => {
|
test('runs against the shared contracts.js stub wind', () => {
|
||||||
// Proves the rig eats the sanctioned Wind implementation, not just my local
|
// 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.
|
// stub — so nothing surprises us when Lane C's weather.js drops in.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user