Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 ->
hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been
winnable without sacrificing the rig. C found the line and the $10 gap;
this spends both levers together, as they only work together.
Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness.
SPRINT6 asked me to price the difference; I measured it and there is no
price at which membrane is a sane buy — it is dominated:
buys: +26% hail, but only on the PEA-hail nights (hailBlockFor(0.7,
0.3)=0.74), which are the easy ones — on storm_02 (1.3) and the
ice night (1.4) a knit already blocks 100%, because a 2 cm stone
can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the
drain vs hail's 5.0 — ~5% of the damage.
costs: double the wind load, and it PONDS.
Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual
words — "fabric choice is a forecast bet" — and the bet is real: cloth on
the windy nights, membrane when the hail is small and the wind is mild.
If A ever raises rain's weight, membrane earns a price.
Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing
consumed — without it the fabric choice would have been cosmetic, which
is the bug class this repo keeps finding.
I did NOT reproduce C's numbers, and the comment says so rather than
repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's
1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at
any combo. The line still wins on a $5 carabiner — but because 1.27 vs
1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the
corner got under its rating. The margin is TIME, not headroom, and it's
thinner than the table implies: anything that lengthens storm_02's gust
HOLDS could take it. Flagged for C in THREADS.
The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) —
now a worse bet the shop will happily sell you, rather than the wild
night's only outcome.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pondMass/pondCentroid/drainPondAt/dumpPond threw when this.water didn't
exist yet — Lane A's HUD reads pondMass() every frame, including in the
forecast/prep phases before the sail is rigged. Node tests always attach
first so they never hit it; the live game did on the first frame. Guarded
+ a pre-attach assert so it can't regress. Verified in the assembled game:
a flat quad ponds 780 kg (belly sagging below ground), dumps on break,
and the merged browser selftest is 219/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The water arc, carried since Sprint 3. Rain (Lane C's rainMmPerHour x the
exported RAIN_TIME_COMPRESSION, never hardcoded) lands on each node's
horizontal projection, runs down its steepest of 8 neighbours, and pools
where it can't get out. A flat sail's belly is a basin water flows into
and can't climb from; a hypar drains along its saddle ridge to the low
corners and off — so ponding cannot pincer §7, and measured on real yard
quads a flat rig holds 12 kg/m² vs a twisted rig's 1.7.
The 8-neighbour graph is load-bearing: a 4-way one can't follow the
saddle's diagonal ridge, so it trapped water in the gravity belly and a
hypar pooled as much as a flat sail. Steepest-GRADIENT descent (not
steepest drop) because a diagonal is √2 farther.
Gate 1, in asserts: a flat carport rig under a capped wind survives dry
(4/4) and dies wet (a corner at t=85s) — the control isolates water from
wind — and the broom saves it. Plus dump-on-break, dump-on-tension-up
(the turnbuckle counter-play), mass conservation, and a belly-tear safety
valve at 4 m of sag.
API for D and A, frozen in contracts.js: pondMass(), pondCentroid(),
drainPondAt(node, dt) -> kg-on-your-head. session.reset() for A's "play
again".
On D's tn-1.04 cliff: investigating it IS what surfaced the ponding load
regime. The 10 kN "spike" is real physics, not solver divergence — a
155 m² flat sail holding 2100 kg of water genuinely pulls ~21 kN, stays
finite, and tracks the water. So no physics-altering clamp (the
displacement clamp I tried moved the thesis 39->34%); instead an opt-in
`watchDivergence` tripwire that throws with a repro above 80 kN, live in
every selftest rig and never false-tripping, and a belly-tear that bounds
the runaway physically. Full writeup for D in THREADS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
E's recipe verbatim: grid (i,j) -> (u,v), repeat 6x6, sRGB. Without the
uv attribute three defaults every vertex to (0,0), the map samples one
texel and the membrane reads as flat colour — which looks like the
texture failing rather than like a bug, so E flagged it ahead of time.
Texture URL resolves against import.meta.url rather than the server root,
same as weather.js's STORM_DIR and the same thing the integrator's
/world/ -> relative pass was fixing. A missing texture warns and falls
back to flat colour instead of throwing: the cloth is the game, the weave
is a finish, and it must not be able to take the sail down.
Added anisotropy 4 — the sail is mostly viewed at a raking angle from
underneath, which is exactly where an unfiltered weave turns to moire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B-4. The gate used to run on my own stub wind — uniform, horizontal,
tuned by nobody — so it proved the cloth was self-consistent, not that
the game works. It now loads the shipped storm JSON and drives the cloth
through weather.core.js (pure and import-free, so the same path runs in
node and in selftest.html; weather.js's own loadStorm can't help because
its STORM_DIR is a file:// URL that node's fetch won't open).
All three halves of §7 now run on real storm_02 and the real yard:
a flat drum-tight carabiner rig cascades 4/4; a well-twisted mixed rig
holds all four; and a twisted rig with one dodgy corner blows it and
finishes 4/4 intact after a single repair — the DoD scenario, in an
assert.
Perf: Lane C added an `out` param to wind.sample specifically so sail.js
wouldn't allocate, and I wasn't passing it — 162 faces at 60 Hz is ~9.7k
throwaway Vector3s a second. Now passing a scratch vector. Stub winds
that ignore `out` still work; we read the return value.
Two tests skip rather than pass vacuously while Lane C's downdraft is
unmerged: a "nothing broke" repair scenario would go green forever and
check nothing. Both light up by themselves on merge, and the repair one
hard-fails if a downdraft IS present and still can't threaten the rig.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decision 4 — conform to Lane D's call sites rather than the reverse (D
landed first and duck-typed them): repair(i), trim(i, delta) and
cornerPos(i). repair takes no hardware argument because prep sells
exactly one kind of spare, so it re-rigs at shackle grade — an upgrade
on a blown carabiner, a downgrade on a blown rated shackle, which is the
prototype's behaviour and a real choice about which corner you run to.
cornerPos returns a fresh vector at the live node, so a blown corner's
prompt chases the flogging corner instead of sitting on a dead anchor
(measured: 13 m off). All three are contract entries now, not PROPOSED
comments, so the merge tripwire enforces the seam.
Decision 5 — sail.step() takes an optional debris and applies sphere-vs-
cloth impulses. The exchange is symmetric: every newton-second the cloth
takes out of a crate, the crate loses. Asserted, and it conserves to
0.000% on an interior hit. Pinned corners are the deliberate exception —
invMass 0 means a crate off a corner dumps its momentum into the house,
which is correct, the anchor is bolted to a wall.
Note this leaves debris.js's applyToSail dead: it guards on `sail.nodes`,
which never existed on the rig — the cloth stores Float64Arrays. So the
debris-vs-sail impulse has been silently doing nothing in the assembled
game. Decision 5 puts it on this side; flagged for Lane C in THREADS.
The contact radius is swept by the piece's travel because main.js steps
the sail before the debris (so piece positions are a frame stale) and a
0.3 m crate at 25 m/s covers 0.42 m per frame — enough to pass clean
between cloth nodes.
Also: coverageOver() rays now start at heightAt(x,z) rather than y=0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.
Contract fixes:
- anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
I had it adding sway to pos, which would have flung every
tree-anchored corner to double its coordinates).
- events is an Emitter emitting {type, corner}, not a drained array.
- coverageOver() rects are centre+size, matching world.gardenBed. It
consumes world.sunDir directly: a hit along sunDir means shaded.
- START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
rather than being redeclared here.
Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.
Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.
HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verlet cloth on a bilinear patch between 4 anchors, N=10 grid,
structural/shear/bend constraints, 5 relaxation iterations at a fixed
1/60 substep. Wind is applied per FACE so hypar twist genuinely sheds
load rather than being cosmetic.
Two deviations from PLAN3D worth flagging:
- Load is read from each constraint's XPBD Lagrange multiplier, not from
FABRIC_K * leftover-stretch. After a fixed iteration count the leftover
stretch is solver error, not fabric strain, so the naive reading came
out ~50x hot (60 kN peaks on a 5x5 m sail). The multiplier is the real
constraint impulse, which the statics assert confirms by balancing the
corner reactions against the applied wind to 8%.
- Wind uses a signed square (d*|d|) rather than clamp(d)^2, so the
leeward face is pushed too. A sail is double-sided.
The sim core deliberately does not import three.js: it runs headless
under node today, stays allocation-free in the hot loop, and replays
bit-for-bit. createSailView() pulls three in lazily for rendering.
Loads land in real newtons (~1-4 kN on a 5x5 m sail in a 34 m/s storm),
so hardware ratings are real working load limits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>