Commit Graph

14 Commits

Author SHA1 Message Date
m3ultra
b98dcad13a Sites are data: extract backyard_01.json; world.js builds from it
SPRINT10 gate 1a, carried since Sprint 8. Everything that was a module constant
— bed rect, house line, tree and post placements, shed, gnome, sun angle, fence
sides — is now data/sites/backyard_01.json, and world.js is a builder.

The extraction is byte-identical, and that's checkable rather than claimable:
E's branch-anchor tripwires pin five positions to 6 dp, a.test's quad-band
asserts pin the whole rigging geometry (>=3 quads in 18-45 m2, full coverage
still >45 m2), D's ladder field and B's balance lines all pass unchanged.
287/0/0.

It's byte-identical by construction, not by luck: the site-derived values keep
the builder's original local names, so ~500 lines of geometry that was already
correct is untouched and only its SOURCE changed.

Two things earned their place:

Anchors carry D's `work: "cloth" | "bracket"` — the MECHANISM, not the type. A
post is tensioned from a cleat at its base and a tree strop is thrown, but a
bracket bolted up a bare wall is the one you cannot fake. Keyed on mechanism
because `type` is a closed enum ('house'|'tree'|'post') that site_02's carport
doesn't fit, and needsLadder keyed on type FAILS OPEN there — the ladder
silently vanishes and you re-rig a 2.6 m bracket standing on the grass.

p4's swept position table moved INTO the JSON's `_why`, beside the coordinate it
justifies. That table is the only thing stopping someone nudging the post toward
the bed and collapsing DESIGN.md's central tension; it belongs with the number,
not in a file the number left.

validateSite() fails loud: sites are content, and a missing bed silently becomes
NaN coverage while a missing anchor list silently becomes an unriggable site.
createWorld now requires a site and says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:34:29 +10:00
m3ultra
1f25c05313 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 15:05:56 +10:00
m3ultra
19f4ab6c63 Venturi: per-site wind personality for site_02 (the corner block funnels the southerly)
A venturi is a shelter's opposite — a gap between buildings SPEEDS the wind up
when it blows along the gap's axis. Where a tree shadow depends on being downwind
of the tree, a venturi depends on the wind being ALIGNED with a fixed axis the
SITE owns, so it fires only when the storm's direction swings to match: the
corner block is calm until the southerly comes through, then it screams. That
interplay is exactly SPRINT9's brief — funnel geometry is the site's, the wind
that funnels is the storm's.

Extends the existing shelter system rather than bolting on a parallel one:
speedAt/vecAt/verticalAt all route through one localHoriz, which now folds
venturiFactor in alongside spatialFactor and shelterFactor. The downdraft rides
local speed, so a funnelled wind gets a proportionally stronger downdraft for
free — physically right.

`setVenturi(list)` on the wind + router (A's "add it to the router too" rule; the
tripwire is green). Inert on an empty list, so backyard_01 is byte-identical —
asserted. Multiplier is ALWAYS ≥ 1 (a venturi accelerates, never shelters), and
it's C1-continuous through the change, so it can't snap a corner — both asserted
(aligned wind boosts 1.5×, crosswind exactly 1.0×, max frame jump 0.50 m/s).

Data lives in site JSON (A's schema, arriving with the extraction) — this is the
physics + the setter, ready to wire. Proposed shape flagged for A in THREADS.

Also fixed a stale comment: storm_02's _gusts_comment still said "HELD at 0.12
for now" from Sprint 3 while the value has been 0.45 since Sprint 4 — a lying
comment in a repo that reads comments as canon. Now records the real history and
the pending paired-0.40 flip.

Selftest 278/0/0; node 55/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:26:24 +10:00
m3ultra
e576f5cb67 Fabric + 0.40 as one landing: the wild night has a clean $80 win
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>
2026-07-17 14:25:59 +10:00
m3ultra
121e169981 Soften Ice Night: 2.8x storm_02's hail was a wall, not a night
Measured against A's gate-1 winning line (t2+p3+p4+t2b, 4x shackle + spare,
tension 1.0, one repair) under an identical harness: the first draft's 26 s
hold gave 32 hail-seconds and landed ~39 HP below storm_02. That is not a
harder night — no amount of play recovers 39 HP when you are already flying the
best rig the shop sells. Hold 26 -> 12 s puts it at 21.1 hail-seconds (1.6x
storm_02, was 2.8x) and 13 HP below it, which excellent rigging + the repair +
the broom can answer — and night 5 arrives with four nights of banked money,
which is the campaign's own answer to escalation.

Week now: gust 11.3 / 21.4 / 21.2 / 32.3 / 28.3, hail-seconds 0 / 2.4 / 2.4 /
11.4 / 21.1. Force ramps then plateaus while the question changes underneath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:29:17 +10:00
m3ultra
40be89c238 The week's two storm variants + forecast uncertainty
Variants for A's five-night week. Each gets ONE new trick, and neither is
"the same storm but harder":

- storm_03b_earlybuster (night 3): same force as storm_03 (21 m/s gust, 13
  sustained) but the change lands at t=18 instead of t=30. You rig for a hot
  NW'er and get a southerly a third of the way in. Tests timing, not budget.
- storm_02b_icenight (night 5): LESS wind than the wild night (28 vs 32 gust,
  19 vs 20 sustained) and 2.8× the hail — 32 hail-seconds vs 11.4, held 26 s
  straight across the change, size 1.4 stones, ice on every gust ≥9. Hail is
  the score (decision 13), so this asks the one question storm_02 only asks
  for nine seconds: is the bed actually covered, and does the cover hold? A rig
  that survives storm_02 by hiding in a small quad off the bed loses here.

Week escalation now reads 11.3 / 21.4 / 21.2 / 32.3 / 28.3 m/s peak gust with
0 / 2.4 / 2.4 / 11.4 / 32.0 hail-seconds — force ramps then plateaus while the
question changes underneath it.

Forecast uncertainty (DESIGN.md partial-information canon): `forecastFor(def,
lead)` + `stormStats(def)`, pure functions on a def rather than methods on the
wind — the card holds defs, doesn't want a field, and this keeps forecasting
off the wind contract and out of A's router. Bands widen with lead and resolve
to the exact truth at lead 0; deterministic per storm, so re-reading the card
can't reroll it.

The invariant, asserted across every storm at five leads: a band ALWAYS
contains the truth. That's the line between partial information and a lie — a
player who rigs for the top of the stated range must never be ambushed.

stormStats also measures what the card was estimating: storm_02 gusts to 32.3
m/s, not the 30 that `baseCurve peak + powBase + powRamp` implied.

Selftest 253/0/0; node 49/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:24:06 +10:00
m3ultra
f1d51d6b87 Hail — the system that makes the garden score respond to the rig (decision 13)
Rain honestly walks under a sail (a droplet's terminal velocity is ~9 m/s, so a
30 m/s crosswind blows it in at ~73° off vertical), which is why a perfect rig
scored 54% garden vs 48% for no rig at all. Hail is dense: terminal ~22 m/s, and
a dense stone couples weakly to the crosswind, so even a gale leans it ≤20°.
Steep hail is blocked by overhead cloth, so the garden score becomes
rig-responsive without faking the rain physics — and it was always DESIGN.md
canon (hail shreds gardens; drainage answers rain, later).

weather.core: `hail` block in storm JSON — authored bursts (envelopes) plus one
synced to every gust at/above `withGustsAbove`, so the biggest gusts arrive WITH
ice. `hailAt(t)` (max over live bursts), `hailSize`, validator. Gust-synced
bursts key off the deterministic gust timeline and draw ZERO randomness, so
tuning hail can't re-time the storm — asserted, same guarantee as the downdraft.
storm_02 bursts on the southerly change (peak 1.0 at t=56.5, 11.4 hail-seconds);
storm_03 a mild 0.5; storm_01 none.

skyfx: `hailVelocity` (steep, ≤20° lean, terminal-velocity reasoning cited so
nobody re-opens the rain-angle argument), a second RainShadow fed the steep
vector, `gardenHailExposure(bed, t)` in the gardenExposure mold (A wires the
drain), instanced falling stones (hidden under the cloth so you SEE the sail
working), and hail audio: ground clatter that fades as the sail intercepts, plus
the cloth DRUM that rises exactly as the sail catches hail — the "my sail is
earning its money" sound.

Decision-13 gate proven: no-sail garden takes 4.4× the hail of a bed under a
good rig over a full storm_02 (bar is ≥2×), through the real gardenHailExposure
and B's SailRig. Verified live too (router-patched): stones fall visibly steeper
than the rain beside them, and a bed-covering rig cuts hail exposure roughly in
half at the burst.

Selftest 214/0/0 (was 207); node 36/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 03:14:27 +10:00
m3ultra
34712bf297 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 02:39:41 +10:00
m3ultra
1f99cd9bca Rain gets physical units for ponding; night pass on wildnight
SPRINT4 §Lane C 2/3/4.

PONDING DATA (decision 10). rainAt() was a dimensionless 0..1 — fine for drop
count and opacity, useless for water mass. Lane B would have had to invent the
mm/hr scale, which is exactly the "default-off code tuned by a constant I
invented" they rightly reverted. So the scale is storm data now:
rain.peakMmPerHour (validated 0..300; a rain curve without one is a hard error,
since ponding would silently use the default instead of what the author meant),
plus wind.rainMmPerHour(t) and rainDepthMm(t0,t1). RAIN_TIME_COMPRESSION=40 is
exported from weather.core so B applies it cloth-side rather than either of us
hardcoding 40 twice: how hard it rains is mine, how much water a sail holds is
theirs.

Calibrated to B's own arithmetic, and the numbers land on it:
  storm_02  80 mm/hr severe  -> 50.9 mm = 3.12 kN/corner   (B predicted 3.1)
  storm_03  30 mm/hr moderate -> 8.3 mm = 0.51 kN/corner   (teases a carabiner)
  storm_01   8 mm/hr shower   -> 0.9 mm = 0.06 kN/corner   (harmless, as designed)
against a storm_02 wind load of 0.2-1.1 kN. A flat rig should drown in the wild
night; a hypar pools nothing and won't notice. Asserted with B's arithmetic so
the storms are provably fit for their water before their cloth lands.

DECISION 7 helper for Lane A: sky.gardenExposure(bed, t) = rainAt x (1-shadow),
the whole drain term in one call. Note it moves on its own — the rain shadow
follows the wind, so the southerly change walks the dry patch off the bed and
the drain climbs with no corner having failed.

NIGHT PASS. sky.night is now the author's call (was: inferred from a darkness
threshold), and darkening scene.background did nothing anyway — the cloud dome
covers it at 0.85 opacity, so an overcast-grey texture was what you actually saw.
The dome now tints AND crushes (a lerp alone lands #717273: it runs in linear
space and the texture is baked near-white). Stops at 0.78 because the yard has no
lights and a storm you can't see is a black screen. Lightning now lights the
cloud it's inside (#3e3e3f -> #d4ddf2), and fires on the biggest gusts via
sky.lightningGustPow, not just the three authored strikes — driven off the
telegraph so the flash lands with the gust that earned it.

Also: c.test's storm list was hardcoded to two storms while the node runner globs
the directory, so storm_03 was untested in the browser half. Its own ponding
assert caught it.

Selftest 195/0/0 (was 184). Verified live: night reads as night, flash lights the
cloud, exposure responds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:28:09 +10:00
m3ultra
7c699e776d Decision 11: the 60% bar passes. I was wrong; bump the storm to 0.45
Re-measured on A's dressed yard. The bar clears at every value tested —
67% at the landed 0.12, 84% at 0.40, 85% at 0.45 — and the twisted rig
holds 4/4 at all three. storm_02 downdraftOfTotal 0.12 -> 0.45 per
decision 11. Both physics gates now close on the same storm JSON.

My "mathematically unachievable" claim was wrong, and the algebra says
exactly why: ratio = (f / (sin p + cos p·f))^2 depends on the REFERENCE
PITCH far more than on the downdraft. My synthetic reference was pitched
16.7 deg, where the bar genuinely is unreachable (asymptote 109%, needs
f=0.86). The yard is pitched 4.8 deg — house fascia 2.60 m to posts
3.95 m over ~16 m — where 0.45 gives 72% on paper and 85% measured. The
equation was right and the rig was fiction: I was measuring against a
sail the game cannot build. The integrator's "measured beats modelled"
call was correct, and C's 0.45 was right on the geometry that ships.

The assert now derives its reference from the yard's real pitch and says
so, with the pitch table in a comment, so the next person to touch this
can see in ten seconds why the number moves.

Also re-pointed §7's twisted rig off the 145 m2 quad decision 2 retired
onto a real 23 m2 one ('t1,p1,p2,p3', most twisted in the 18-45 band).
The repair leg's dodgy carabiner moves to p2: measured peaks are t1 2.43
/ p2 2.35 / p3 0.82 / p1 0.60 kN, and it had been sitting on p1 — the
lightest corner — so it rode out the storm and the leg skipped. It now
blows and one repair finishes 4/4. Removed its stale skip guard, which
still tested the old gusts.downdraft key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:21:27 +10:00
m3ultra
576422e1f0 Downdraft as fraction of total wind (decision 8) + storm_03
The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7
twisted-survives gate together: the downdraft peaked at the gust peak, where
the horizontal peaked too, so a flat sail never reached 60% of a pitched one's
load without a spike so violent it also broke the twisted rig. The integrator
measured the pincer (0.58 -> 48% and still breaks twisted).

Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust
power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof
steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power
12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike.
It rides the local speed, so a tree's wind shadow shelters from falling air too.
speedAt() stays horizontal (a wind meter doesn't read falling air).

Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name
rather than silently re-meaning it. The vertical now carries NO rng draws at
all, so the determinism guarantee (tuning can't re-time gusts) is structural,
not just separate-stream.

Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep +
§7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the
bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit —
the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a
corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old
gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). Bump to 0.45 is a one-number
joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7.

storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21
m/s, sustained 13, one moderate southerly change. Auto-swept by the suite.

Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal
ratio is exactly 0.12 in-game, rain occlusion still covers the bed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:48:30 +10:00
m3ultra
135511fb05 Add vertical gusts, freeze debris.pieces, fix fog restore
SPRINT2 decisions 3 and 5, plus Lane A's fog nit.

Decision 3 — gusts now descend. Cloth pressure goes with dot(wind, normal); a
flat panel's normal points at the sky, so in a perfectly horizontal wind the
dot is ~0 and "lie it flat and ignore the storm" was the cheapest winning rig.
A gust front is descending air, not just faster air. Per-gust downdraft
fraction in storm JSON (storm_02 0.3, storm_01 0.18, default 0.25, validated
0..1), each gust varying 0.6-1.4x. Peak downdraft in storm_02 is 4.4 m/s, 17%
of the horizontal. Lane B: the cloth-side assert is yours.

The vertical draws from its OWN rng stream, and there's an assert pinning
that: pulling it from the main stream would shift every subsequent (t0, pow)
and silently re-time storms Lane A has already hand-verified. Their carabiner
still blows at t=45.4 and cascades at t=56.

speedAt() stays horizontal — an anemometer doesn't read falling air, and a
wind meter that spikes because a gust is descending reads as a bug.

Decision 5 — debris.pieces frozen and documented in contracts.js as the seam
Lane B reads in sail.step(), with the sphere/SI/mutated-in-place semantics
spelled out and an assert tying the live shape to the contract table.

Fog: dispose() captured scene.fog by reference and step() mutates that object
in place, so restoring it restored nothing (Lane A caught it). Now captured by
value, and fog we created ourselves is removed rather than left behind.

Selftest 130/0/0 (was 121); Lane C 28 asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:58:31 +10:00
m3ultra
383471d0f5 Add debris, skyfx and the Lane C bench; align to landed contracts
debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that
spikes a corner is the one that launches the neighbour's bin. Ground bounce
via world.heightAt (contracts.js documents it as ours), sphere-vs-player
knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it
lights up when Lane B exposes nodes and stays silent until then.

skyfx.js: instanced rain that wraps around the camera rather than respawning,
storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers
(wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst
corner, flog when one blows). It modulates Lane A's lights and hands them back
on dispose() rather than owning them.

weather_demo.html: graybox bench to drive all three before M0 — mock sail,
storm scrub, 4x, throw-a-crate.

Aligned to contracts.js now that it has landed: relative three imports (there
is no importmap), contracts' rng() instead of a local copy, and storm paths
resolved off import.meta.url — server.py serves the repo root, so an absolute
/world/... would have 404'd at integration.

storm_02: fix the southerly change. contracts.js puts north at -Z and the wind
vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due
south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW
buster off the open side of the yard, into the house.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:21 +10:00
m3ultra
84f647a90c Add wind field, storm timelines and weather selftest
Implements the contracts.js wind surface (PLAN3D §4): sample(pos,t) and
gustTelegraph(t), plus storm defs as data.

The prototype scheduled gusts by integrating (wind.gustT += dt). We can't:
sample(pos,t) is called by sail/player/debris/rain at arbitrary t, so gusts
are precomputed into a timeline from a seeded PRNG at load and read from t.
Envelope shape is a faithful port — telegraph 1.5s / ramp 0.8s / hold 1.7s /
fade 1.0s.

Maths lives in weather.core.js with zero imports (no THREE, no DOM, no
Date.now), so the determinism rule is structural and the suite runs in node
without waiting on Lane A's M0.

storm_01_gentle peaks at 11 m/s; storm_02_wildnight sustains 20 m/s and gusts
to 32 (BOM 'destructive'), with the southerly change landing just before the
worst of it — the corners that were slack all storm are the ones that cop it.

15 asserts green: telegraph lead >=1.2s, wind continuity in time and space,
storm JSON validator (incl. 14 deliberately-broken defs it must reject),
determinism, sample-order independence, tree wind shadow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:12 +10:00