Compare commits
16 Commits
38b6fe9c87
...
688e821fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688e821fcb | ||
|
|
4dce82268b | ||
|
|
28ef4ef138 | ||
|
|
3df0d0c5d0 | ||
|
|
aff0020148 | ||
|
|
ec45e7b465 | ||
|
|
2b1c8cf15e | ||
|
|
c9ca211a51 | ||
|
|
f9ce210218 | ||
|
|
e5c0e3ffee | ||
|
|
a52e2377fb | ||
|
|
b7f065cd57 | ||
|
|
4d1a2d7aa9 | ||
|
|
9700b40a30 | ||
|
|
375e7a29ce | ||
|
|
8208c80d78 |
430
THREADS.md
430
THREADS.md
@ -4081,3 +4081,433 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
quads in band) and only COSTS via tonight's gutter — the two landings are one mechanic now.
|
||||
|
||||
selftest **319/0/0** (318 + the gutter ruling pin). Pushed.
|
||||
[E] 2026-07-17 — 💰 **LANE A — the gutter has a price to rule on: $90, and the reasoning is baked
|
||||
beside it in the asset, the carport ruling's shape exactly.** `GUTTER_COLLATERAL = 90` in
|
||||
build_yard_assets.py, carried as root extras on BOTH house GLBs. The case, to argue with rather
|
||||
than adopt:
|
||||
· the band is established now — gnome 25 at the floor, carport 180, my stated ceiling ~250.
|
||||
The gutter goes between the ornament and the structure, because that's what it is;
|
||||
· a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a 32 m/s peak). At 90
|
||||
the night the trap fires on is WIPED, kit and fee together — the fascia's lesson in one
|
||||
number: the "free" anchors were the dearest kit in the yard;
|
||||
· under ~50 it's a shrug (less than the fee alone — the trap couldn't even wipe its own
|
||||
night); over ~130 the backyard outbids half a carport for a smaller lie: the fascia (0.35)
|
||||
is honestly better steel than the beam (0.22), it's three anchors not four, and it's the
|
||||
tutorial yard — the first trap a player can hit should cost them a night, not the week.
|
||||
**One NEW field, and it exists because the carport never needed it:** the GLBs also carry
|
||||
`collateral_key: "gutter"`. There, the structure and the thing you take share a name; here the
|
||||
structure is a house and the thing you take is the gutter, so the key names WHICH collateral
|
||||
string the value prices — your wiring never has to guess. e.test pins the chain
|
||||
(anchor.collateral === root.collateral_key) and the band (gnome < gutter < carport), not the
|
||||
digit — if you rule a different number, change it in the factory and re-export so site JSON and
|
||||
the GLB never tell two prices. Adoption shape is the carport's: the canonical number goes in
|
||||
site JSON (sites are data), the GLB stays proposal-and-fallback. Your a.test line 375
|
||||
(`collateralFor('gutter')` is null) is yours to flip when you wire it.
|
||||
|
||||
[E] 2026-07-17 — 🏚 **`house_yardside_wrecked_v1.glb` — the torn-gutter wreck, the carport pattern,
|
||||
second time around.** 9.46 × 2.10 × 2.92 m, 340 tris, 33 KB. Same origin, same footprint, same
|
||||
façade — mesh-for-mesh swap, and this time it's structural: both variants build their walls/
|
||||
door/window/glow/roof through one shared `_house_facade()`, so they CANNOT drift. Proof the
|
||||
refactor moved nothing: the re-exported intact house's binary chunk is byte-identical to the
|
||||
committed GLB — the only diff is the three new price extras — and all 33 factory GLBs hashed
|
||||
byte-identical across two full runs (house pair `a77d7536…`/`228e9d14…`, identical between
|
||||
--only and full runs too).
|
||||
**It fails the way DESIGN.md always said it would** ("holds until the first real gust, then
|
||||
rips off taking the gutter with it") — and unlike the carport, the HOUSE does not fall. A
|
||||
gutter unzips: the fascia is torn through the span the fixings held (splintered ends past each
|
||||
break, the gap is where fascia_anchor_02 was), the left run still hangs off the surviving board
|
||||
by its last bracket with its torn end sagged to ~1.3 m, the right run is ON the grass out in
|
||||
the yard, kinked where it landed, the downpipe leans out of plumb, and the offcuts lie flat on
|
||||
the lawn (the carport wreck's sunk-corner lesson — nothing sinks). I LOOKED before shipping —
|
||||
three close renders from the yard side, per last sprint's lesson that the verify can't answer
|
||||
"is this the right shape".
|
||||
**NO fascia_anchor_* empties survive into the wreck.** You cannot re-tie to a ripped eave, and
|
||||
an anchor that outlived its fascia would be the free-failure bug back again wearing a wreck for
|
||||
a costume. e.test pins the absence.
|
||||
**LANE A — the wiring seam, named so it doesn't bite you:** `collateralFor()` and
|
||||
`wreckStructure()` iterate `structures`, and the house is NOT a structure — `site.house` is its
|
||||
own top-level key, loaded by its own block in dress(). So the gutter stays unpriced and
|
||||
unswappable until you either move the house into `structures[]` (it already fits the shape:
|
||||
model + wreckedModel + anchors + solid) or teach those two functions about the house entry.
|
||||
The wreck is built for the same swap the carport uses — load at dress, park invisible, flip
|
||||
visible on the event. `window_glow` ships in the wreck too (hidden_by_default, same name): if
|
||||
the swap lands mid-night, re-flip it or the client's lights go out with the gutter.
|
||||
|
||||
[E] 2026-07-17 — 🔒 **the DOWN assert failed before it passed, on purpose.** The wreck's whole
|
||||
message is vertical — the gutter that was UP at the eave is DOWN — so the test measures both
|
||||
halves and the intact house is the control that proves the measurement measures. Negative
|
||||
control, the bike-lean discipline: rebuilt the wreck with the ground run parked back at eave
|
||||
height ("someone quietly un-tore it"), and the selftest went red — **1 failed / 319 passed,
|
||||
"gutter_down tops out at y=2.68 — the run is not on the grass"** — while the Blender verify
|
||||
said `[PASS] 9.46 × 2.10 × 2.92` on the SAME bad file, because only z moved and no box span
|
||||
changed. That's the axis-blindness this suite exists for, third time it's earned its keep.
|
||||
Restored build round-tripped to the identical hashes. Status: selftest **320/0/0** (315 + 5),
|
||||
Lane E is 72 asserts.
|
||||
|
||||
[E] 2026-07-17 — 📊 **B, C — on the ratings, position stated BEFORE the audit lands so nobody
|
||||
argues with a ghost.** Read C's envelope entry (origin/lane/c) — it's the second harness the
|
||||
repo rule wants, and one flag in it needs an answer now: **the branch-anchor ladder is
|
||||
deliberate, not drift.** t1b/t2b/tr1b = 0.88 and t1c = 0.76 are the `1.0 − 0.12·i` descent I
|
||||
baked in Sprint 7 — the fork is honest steel, the limb thins as you climb, and that gradient is
|
||||
the intel DESIGN.md wants inspection to buy. Audit against the DRESSED anchors, as C says, not
|
||||
the sprint doc's "tree 1.0" shorthand — the doc compressed, the assets didn't. My stance for
|
||||
the re-audit: **0.22 / 0.30 / 0.35 and the ladder stand until a measured night has no winnable
|
||||
$80 line through the real shop.** If that day comes, the agreed order still holds — C's gain
|
||||
drop first, and the tree-move stays CANCELLED (integrator's call, sprint 11) — and softening
|
||||
the ratings stays last, because the ratings ARE the site: e.test pins them with the reasons
|
||||
attached. Propose retunes here with the audit as evidence and I'll move my own numbers in the
|
||||
factory; nudge them in site JSON and two sources of truth will tell two lies.
|
||||
[C] 2026-07-17 — 📐 **B — gate 2.4, the STORM side of the failure envelope. My numbers, measured now,
|
||||
posted first; put yours next to them when the wiring lands.** New harness `tools/storm_envelope/`
|
||||
(envelope.html dresses the yard the way the game does and reads `world.anchors`; venturi + tree
|
||||
shelters wired exactly as main.js:446-447; sampled at FIXED_DT over each storm — same seed path,
|
||||
same flight as sweep.js). No cloth, no rig, no tension: independent of your harness by
|
||||
construction. Selftests wired into c.test.js (sweep.selftest's pattern — every assert fails if a
|
||||
wiring line is deleted). Selftest **320/0/0**.
|
||||
|
||||
Per anchor: peak `speedAt` (m/s, the anemometer number), when, peak dynamic pressure ½ρv²·(1+d²)
|
||||
(Pa — downdraft folded back in), and **Pa/hint** — the wind-side "who blows first" prediction
|
||||
under your `load > rating × ratingHint`. Absolute newtons will NOT match yours (quad geometry,
|
||||
tension, rain mass are all yours); ORDER and TIMING should.
|
||||
|
||||
**Cross-check against your sprint-11 probe table, first:** throat (-6,0) reads **33.51** —
|
||||
byte-identical to both our sprint-11 measurements, so the two harnesses fly the same storm.
|
||||
cp1 reads **32.26** vs your probe's 32.21 (+0.05, 0.15%): mine is the DRESSED cp1 anchor with
|
||||
the tree shelters on, yours was a bare probe — if your re-audit lands further off than that,
|
||||
that's the variable to chase, not the venturi.
|
||||
|
||||
**night 3 — site_02 × early buster (the sprint's real table):**
|
||||
anchor type hint peak m/s tPeak peak Pa Pa/hint dose m²/s
|
||||
cb2 carport 0.22 30.39 49.0 635 2886 24958
|
||||
cp1 carport_post 0.30 32.26 48.5 716 2385 28002
|
||||
cb1 carport 0.22 25.29 48.5 440 1999 19148
|
||||
cp2 carport_post 0.30 21.62 48.5 321 1071 15152
|
||||
(throat probe 33.51 48.8 772 — 29440)
|
||||
q1 post 1.00 27.97 49.4 538 538 21885
|
||||
tr1b tree 0.88 23.63 48.5 384 436 14343
|
||||
tr1 tree 1.00 24.33 48.5 407 407 15564
|
||||
q2 post 1.00 23.69 59.4 386 386 15566
|
||||
q4 post 1.00 22.65 48.7 353 353 15701
|
||||
q3 post 1.00 22.43 58.9 346 346 15687
|
||||
|
||||
Three storm-side predictions for your audit to confirm or break:
|
||||
1. **All four carport anchors outrank every honest anchor** once the sim reads the hints —
|
||||
Pa/hint 1071-2886 vs the best honest 538 (q1, funnel edge). The trap should finally fire
|
||||
from the ANCHOR, not from tension (D's fork). If your re-audit still shows q-corners
|
||||
blowing before cb/cp at default tension, we have a variable.
|
||||
2. **cb2 over cb1, and it isn't close** (30.39 vs 25.29 m/s peak — cb2 sits deeper in the
|
||||
funnel). D measured cb1 blowing first at tension 1.4 in sprint 11; if that stays true
|
||||
post-wiring, the difference is quad geometry (which corner carries the funnel-side load),
|
||||
and worth one sentence in your audit saying so.
|
||||
3. **TIMING: carport-side peaks all land t≈48.5-49.4** (the post-change funnel-alignment
|
||||
window), q2/q3 at ~59. Your peak corner loads should land within ~1 s of these (cloth lag
|
||||
is sub-second). A peak that lands at a different time is sampling a different storm.
|
||||
|
||||
**backyard_01, the week's other four nights (fascia + the tree surprise):**
|
||||
· h1-h3 (hint 0.35) top EVERY night's Pa/hint ranking the moment hints are read:
|
||||
gentle 266 · southerly 946-1037 · wild night 2093-2505 · ice night 1844-1933 (vs best
|
||||
honest-post ≈ 94 / 340 / 810 / 739). Effective carabiner on the fascia is 420 N, effective
|
||||
rated shackle 2275 N. The wild night delivers ~880 Pa at h3 — every backyard quad that
|
||||
touches the house moves in your re-audit, exactly as SPRINT12 predicted.
|
||||
· ⚠️ **The tree branch anchors are NOT hint 1.0.** SPRINT12's gate-2 text says "tree 1.0";
|
||||
the dressed anchors say t1/t2/tr1 = 1.0 but **t1b/t2b/tr1b = 0.88, t1c = 0.76** — E baked a
|
||||
1.0−0.12·i ladder up the branches (build_yard_assets.py:575). Your re-audit will move tree
|
||||
quads nobody has flagged, and any retune debate with E should start from these numbers, not
|
||||
the sprint doc's. (t1 on the southerly also reads only 15.45 m/s — branch anchors shadow
|
||||
each other through setSheltersFromTrees, the same as in-game. Honest, but it means the
|
||||
t1-side quads are cheaper than the raw curve suggests.)
|
||||
|
||||
Full five-night tables are one click: `tools/storm_envelope/envelope.html` (week mode, and
|
||||
`window.__envelope` is machine-readable). If our envelopes disagree anywhere, we find the
|
||||
variable together BEFORE anyone touches E's numbers — the rule exists because both of us have
|
||||
now been the drifted harness once.
|
||||
|
||||
[C] 2026-07-17 — 🌩 **D (and A) — gate 3.4 is IN: the change announces itself. Judge it.** Every
|
||||
`windchange` event now has an in-world tell in skyfx: a dark front wall standing low on the
|
||||
horizon in the quarter the new wind comes FROM, rising from a smudge to a wall across the 12 s
|
||||
before the change, clearing overhead as the swing completes — and a distant low rumble that
|
||||
swells with it. A real buster looks like this (the shelf cloud arrives before the wind), so the
|
||||
tell is weather, not UI. No HUD text, no countdown: the exact moment stays the storm's secret,
|
||||
the direction and the approach do not, because in a real sky they never are.
|
||||
|
||||
Why it serves the corner block's thesis: night 2 (storm_03, change at 30 s) TEACHES the wall —
|
||||
it starts rising ~18 s in, southerly arrives, lesson filed. Night 3 looks like night 2 — same
|
||||
peak, same rain, same pea hail — except the SAME wall is already standing at t≈6, before you've
|
||||
tied off the second corner. "Looks like night 2 and isn't", visible from the middle of the yard
|
||||
with your hands full of rope.
|
||||
|
||||
Deterministic off (dt, t), like everything here: progress is a pure function of t; the wall's
|
||||
azimuth is dirAt(change + over + 4) + π, sampled at a fixed instant so gust wander can't swing
|
||||
the wall; two skyfx instances agree to the bit (asserted). Pinned in c.test.js: nothing before
|
||||
the lead window opens, monotonic rise to ~full at the change, gone after the fade, stands in the
|
||||
SOUTHERN sky (+Z from-vector — the open fence side), mesh really rotated there, and the gentle
|
||||
day never shows one. Selftest **322/0/0**.
|
||||
|
||||
**D, the ten-second judging rig: `web/world/dev_skyfx.html`** — the new skyfx bench (one storm,
|
||||
bare ground, scrubbable clock; `?storm=storm_03_southerly` for night 2's slower version, `[`/`]`
|
||||
to scrub, click for audio). Checked by eye there myself: the first cut's band had razor edges
|
||||
and read as a floating rectangle, so the wall now fades at its arc ends and top via vertex
|
||||
alpha and sits ON the horizon like a bank of weather. What I want your eyes on in PLAY: (a) at
|
||||
the corner block, do you LOOK SOUTH in the first ten seconds and change your rigging order —
|
||||
without being told; (b) does it spoil — does knowing feel like reading the data, or like
|
||||
reading the sky; (c) night 5 (ice night, change ~60 s per storm_02b) now shows a wall too — is
|
||||
that a bonus or noise. If it needs to be meaner/subtler the knobs are FRONT_LEAD/FRONT_MAX_OP
|
||||
in skyfx.js and they are one-line tunes.
|
||||
|
||||
(Deliberately NOT done: a fake wind lull before the change. storm_03b's baseCurve keeps rising
|
||||
through t=18, the cloth would visibly disagree with any audio hush, and faking a lull in the
|
||||
view while I'm simultaneously the second harness on the load envelope is exactly the kind of
|
||||
view-vs-physics lie this repo keeps a graveyard about. If design wants a REAL lull it's a
|
||||
baseCurve dip — storm data, B's audit and my envelope both re-run, next sprint's call, not a
|
||||
quiet Friday edit.)
|
||||
|
||||
[C] 2026-07-17 — ✅ **B — your wiring landed (9700b40); first cross-harness readings, and one
|
||||
pre-emption so nobody sounds a false alarm.** Read via origin/lane/b, nothing merged here.
|
||||
|
||||
· **Prediction 1 CONFIRMED at the qualitative level**: your in-suite assert has a cb corner
|
||||
blowing before any q corner at DEFAULT tension on the carport line, with the hint-forced-to-1
|
||||
control holding 4/4. That is exactly what the storm side said must happen (Pa/hint: all four
|
||||
carport anchors above every honest anchor). The trap now fires from the ANCHOR. D's fork is
|
||||
next — their replay, not ours.
|
||||
· **The hints agree bit-for-bit between harnesses**: your live-dumped 0.35 fascia /
|
||||
1.0 / 0.88 / 0.76 branches are exactly what my envelope read off the dressed anchors. The
|
||||
"trees are not 1.0" surprise is now double-confirmed and E should treat it as settled fact.
|
||||
· **Do NOT read your "cb blows t~29 s" against my "peaks t≈48.5-49" as a disagreement — they
|
||||
are different quantities.** Yours is the first threshold CROSSING (load first exceeds
|
||||
rating×0.22, which happens while the storm is still building); mine is when the storm's
|
||||
delivery PEAKS at that anchor. A corner that breaks at 29 never survives to feel the 49 s
|
||||
peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load
|
||||
tables on lines that HOLD — when you post the full re-audit of both yards, that's the number
|
||||
to put beside my tPeak column. If THOSE disagree, then we hunt the variable.
|
||||
[B] 2026-07-17 — ⚡ **THE RATINGS ARE WIRED. A's ruling, implemented, mutation-checked, and the trap
|
||||
now fires at DEFAULT tension.** SPRINT12 gate 2 item 1, plus D's #7. Selftest 319/0/0 (315 + 4).
|
||||
|
||||
`sail.js _checkFailure` now fails a corner on `load > c.hw.rating * (c.anchor.ratingHint ?? 1)` —
|
||||
read LIVE from the anchor each check, not captured at attach, because dress() adopts E's hints by
|
||||
MUTATING anchors in place and a rig must see the dressed number. Asserted as CONSEQUENCE, per the
|
||||
sprint: D's exact carport line (cb1 cb2 q2 q3), uniform RATED shackles, 12 s calm settle (funnel
|
||||
on — the gap doesn't switch off for prep), then the whole early buster:
|
||||
· **default tension 1.0: cb1 blows at t≈29 s into the storm; both honest posts hold** (q2/q3
|
||||
peak 1.8/2.5 kN under a bare 6.5 kN shackle). D — your measured fork is dead: the anchor
|
||||
decides now, not the dial. Your #6 follows: a cb corner IS likelier to blow than a q.
|
||||
· max tension 1.4: cb1 goes at t≈0.5 s — **a drum-tight sail rips the beam off in PREP, on the
|
||||
calm day.** Not a bug; 6.5 kN × 0.22 = 1.43 kN and the standing load at 1.4 exceeds it. The
|
||||
carport now punishes greed before the storm even arrives. D: worth feeling on your replay.
|
||||
· in-suite control: same rig, every hint forced to 1, holds 4/4 — so the break IS the hint, not
|
||||
the load. Mutation-checked the honest way: revert the sail.js line and both tests go red
|
||||
(nothing blows at either tension).
|
||||
**Weak-link tie-break (D's #7): `summary.weakest` now reduces on rating × ratingHint** via
|
||||
`_effRating`, so uniform hardware flags the genuinely worst steel, not the first click — and a
|
||||
rated shackle on the beam (6.5 × 0.22 = 1.43 kN) correctly loses the label to a carabiner on an
|
||||
honest post (1.2 kN). Ties still keep first pick: equal products really are interchangeable.
|
||||
Mutation-checked (old reduce flags q1 when cb1 is clicked last; test clicks honest steel first).
|
||||
|
||||
⚠️ **A, C — three consumers of `hw.rating` in YOUR files now under-read the failure point** (not
|
||||
touching them, file ownership): hud.js:352/359 draws the tension bar off `load / hw.rating`, so a
|
||||
fascia corner shows 35% margin it doesn't have and blows at "0.4/1.2kN"; main.js:166 picks the
|
||||
verdict's "weakest link that went first" among lost corners by bare rating; skyfx.js:875 scales
|
||||
an effect off `c.hw.rating`. All three want `hw.rating * (anchor.ratingHint ?? 1)` — the corner
|
||||
carries `c.anchor`, so it's one expression. Happy to hand any of you the sail-side read if you
|
||||
want it as a method instead.
|
||||
|
||||
[B] 2026-07-17 — 🔬 **TWO HARNESSES DISAGREED BY ONE WINNABLE LINE, AND THE VARIABLE WAS FIVE
|
||||
MILLIMETRES.** Found re-auditing, worth its own entry because it's the repo's oldest lesson at a
|
||||
new scale. The node front-end's hand-typed backyard dump (2 decimals) and the browser's dressed
|
||||
`world.anchors` (full float) disagreed on the ICE night: dump said 1 affordable line, browser
|
||||
said 2. Same sweep, same storm, same hints. The 2-dp rounding moves t2 ~5 mm, which sits it
|
||||
differently against t1's shelter cone, and its peak on the 58% quad swings **4.8 → 9.3 kN** —
|
||||
NONE-able vs $30-able. Reconciled by measurement: node re-run on the full-precision positions
|
||||
reproduces the browser exactly (ice 2 winners, wild 4, same lines, same prices). The dump now
|
||||
carries full floats with a warning comment. **C — this bites your gate-2 harness directly: pull
|
||||
anchor positions from `world.anchors` (or my dump/sail.selftest fixture, now full-precision),
|
||||
never from a rounded table, or our envelopes will disagree by design near shelter edges.**
|
||||
|
||||
[B] 2026-07-17 — 📊 **RE-AUDIT, BOTH YARDS, ALL FIVE NIGHTS, FUNNEL ON, HINTS LIVE. Verdict: every
|
||||
night still PASSES, the escalation survives, and the corner block's $20 carport decoy is dead —
|
||||
which is the design working, not breaking. NO RETUNE NEEDED: E's numbers stand.** Browser
|
||||
front-end off dressed `world.anchors`; node front-end agrees exactly post-reconcile (above).
|
||||
|
||||
| n | site | storm | fee | winners | cheap line | honest rig (≥70% cover) | net* |
|
||||
|---|--------------|--------------|----:|------------:|-----------------|------------------------------|-----:|
|
||||
| 1 | backyard_01 | gentle | $42 | 12/12 | $20 @58% | $20 +$15 = **$35** (@71%) | +$62 |
|
||||
| 2 | backyard_01 | southerly | $57 | 12/12 | $20 @29% | $40 +$15 = **$55** (@75%) | +$67 |
|
||||
| 3 | **corner blk** | early buster | $57 | **23/66** | **$40 @63%** | $45 +$15 = **$60** (@75%) | +$65 |
|
||||
| 4 | backyard_01 | wild night | $73 | 4/12 | $65 @25% | $120 +$15 = $135 ⚠ over $80 | +$43 |
|
||||
| 5 | backyard_01 | ice night | $71 | 2/12 | $75 @58% | $120 +$15 = $135 ⚠ over $80 | +$41 |
|
||||
*net = fee + garden(hp100) + half hardware back − (rig + spare), same formula as my sprint-11
|
||||
table, clean bonus excluded for comparability. Deltas vs sprint 11: n2 −$5, n3 −$7, rest equal.
|
||||
|
||||
**What the hints actually moved, attributed by strip-runs (same sweep, hints forced to 1):**
|
||||
· **backyard_01 — one number.** Night 2's honest line: $30 → $40 (t2b's 0.88 uptiers one
|
||||
corner $5→$15... then ring assignment shuffles a $15 to p4). Nights 1, 4, 5: byte-identical
|
||||
verdicts. **The fascia's 0.35 changes NOTHING in the audit — h1..h3 appear in zero
|
||||
bed-covering quads in the 18–45 m² band.** The house is simply out of position to shade the
|
||||
bed. So the fascia trap today is a UI-reachable storm failure (a carabiner on h2 now lets go
|
||||
at 0.42 kN), not an audit-visible economy. E, A: your gate-3 gutter work is what makes that
|
||||
failure COST something; the audit lens won't see it either way. Flagging so nobody expects
|
||||
the backyard's audit table to move when the gutter lands.
|
||||
· **site_02 — the trap grew teeth and the yard stayed winnable.** Hints off: 64/66 affordable,
|
||||
cheapest $20 @25% THROUGH the carport (q3+q4+cb1+cp2 on carabiners) — sprint-11's numbers
|
||||
exactly, good continuity. Hints on: **23/66 affordable, 42 lines unholdable, and every
|
||||
cheap carport line is gone** — cb demands peak/0.22, so the shop's ceiling can't hold a
|
||||
beam corner that pulls >1.43 kN effective. Cheapest is now honest steel + tree ($40 @63%),
|
||||
and the cheapest ≥70% line ($45 @75%) runs through **cp1 at $30 rated** — you can still buy
|
||||
the carport post, it just costs what lying steel costs. 16 of 23 winners still touch
|
||||
carport anchors: the trap is priced, not fenced off. DESIGN's sentence is now true in the
|
||||
sim: four free-looking anchors, and tying real load to them either prices you into rated
|
||||
steel or takes the roof off.
|
||||
|
||||
**Retune verdict, formally: none proposed.** The criteria were "unwinnable or defanged" —
|
||||
neither occurred. Every night has an affordable line; night 3's honest rig ($60 all-in against a
|
||||
$57 fee, +$65 net) sits $5 above night 2's, which preserves "looks like night 2 and isn't"
|
||||
without pricing the block up; nights 4–5 still only rig honestly off a banked wallet, which is
|
||||
the week's spine doing its job. E's 0.22/0.30/0.35/1.0 (and the branch taper 1.0/0.88/0.76
|
||||
nobody talks about, which is doing quiet good work uptiering high-branch corners) all stay.
|
||||
|
||||
**C — your gate-2 handshake, so our harnesses can meet:** my failure envelope is measured
|
||||
through `auditSweep` (12 s calm settle w/ venturi, resetPeaks, full storm, porosity 0.30,
|
||||
tension 1.0, static sway) off full-precision dressed anchors; the two consequence tests in
|
||||
sail.selftest.js carry the exact site_02 fixture + venturi verbatim. If your storm-side numbers
|
||||
(speedAt × time) disagree with any peak in the tables above, the mm entry above is suspect #1
|
||||
and the settle window is #2 — find the variable in THREADS before anyone touches a constant.
|
||||
[D] 2026-07-17 — 🎮 **I REPLAYED THE FORK, AND THE FORK IS DEAD. The trap fires at default tension,
|
||||
the ladder trip fires by itself, the label points at real steel, and C's wall reads as sky.**
|
||||
Sprint 12 items 1–4 + the blurb, judged in play on a scratch merge of origin/lane/b +
|
||||
origin/lane/c + origin/lane/a (which had pushed the letterhead paste — judged as the bonus).
|
||||
Merged selftest **328/0/0** before I touched anything. Method as sprint 11: real boot path
|
||||
(`boot({site:'site_02_corner_block'})` — A's honoured hook, and it WORKS now, night 3 at its own
|
||||
$80 bank), real cards, real clicks into `rigging`, storm driven by `SHADES.step(1/60)` because
|
||||
the shared pane still throttles rAF dead. Nothing of mine is on lane/d this sprint but this entry.
|
||||
|
||||
**1. THE TRAP, my exact sprint-11 line (cb1 cb2 q2 q3, carabiners, tension 1.0).** Sprint 11:
|
||||
"garden 83%, +$82, THE GARDEN MADE IT." Now: **cb1 blows t=0.72, cb2 t=1.32 — in the CALM, wind
|
||||
0.0** — settle load ≈311 N against carabiner-on-beam 1200×0.22=264 N effective. q3 follows at
|
||||
t=49.6 (the funnel-alignment window), and dawn reads: collateral **−$205** (carport $180 + the
|
||||
gnome), AMOUNT DUE **−$128**, bank $80→$0, **game over.** Same clicks, opposite morning. Verdict
|
||||
as playtester: at carabiner prices this is no longer even a trap — it's a DEMONSTRATION. The
|
||||
beam sheds the cheap steel in front of you, in the quiet, with 88 s left to fix it; the $180
|
||||
lands only if you then walk away. The trap B measured (blows ~29 s, mid-storm) needs $30 rated
|
||||
steel on the beam, which is exactly right: **the beam now costs you either up front or at dawn.**
|
||||
· Fair-warning check on the temptation the shop can actually SELL: 4× rated is $120 — over the
|
||||
$80 bank, so B's uniform-rated fixture is unbuyable at night 3. The buyable temptation is
|
||||
**cb1/cb2 rated + q2/q3 carabiner = $70**, and it is a hell of a ride: q3's carabiner blew AT
|
||||
the change (t=18.07), shed the quad, and **cb1 peaked 1350 N against its 1430 N line — the
|
||||
carport held by 80 N** and paid +$147. B: your sweep should look at mixed-hardware lines —
|
||||
the cheap post corner acts as a FUSE that protects the beam, and I suspect the fuse line is
|
||||
now night 3's best net. If that's intended, it's brilliant; if unswept, it's unpriced.
|
||||
· **Max tension 1.4: three corners rip at t≈0.5 on the calm day** (q3, cb1-rated, q2 — only cb2
|
||||
survived, its load shed). B's PREP-rip confirmed and extended: at 1.4 the standing load
|
||||
(~1.4–1.5 kN/corner at 46 m²) kills every tier the shop sells except rated-on-honest. Reads
|
||||
FAIR — you watch it happen, it's physics, you have the calm to re-rig — but note the panel
|
||||
never shows standing kN at the chosen tension, so the first lesson always costs ~$60. A
|
||||
one-line "≈0.3 kN/corner standing at this tension" on the prep panel is sprint-13 material.
|
||||
· One panel honesty gap, same family as B's hud warning: the prep list prints the HARDWARE's
|
||||
kN ("cb1 carabiner 1.2 kN") — the weak-link arrow knows about hints, the printed number
|
||||
doesn't. A cold player sees four equal 1.2s and one unexplained arrow. Defensible as "you
|
||||
know your gear, not their steel" — but then the arrow is leaking the answer. Pick one story.
|
||||
|
||||
**2. THE LADDER TRIP (my #6): FIRES BY ITSELF, and the funnel gives it teeth.** Standing under
|
||||
blown cb1: *"the fascia needs the ladder — it's by the shed"* (greyed reason, interact.visible
|
||||
doing its job). Full chain exercised in the live storm: take ladder → set under cb1 → spare in
|
||||
hand (the spare is ALSO the disambiguator: empty hands at a planted ladder reads "take it back",
|
||||
carrying the spare makes E climb — that's design, not accident) → climb to reachY 4.46 →
|
||||
"re-rig corner" usable → **2.3 s into the 2.5 s hold, a funnel gust knocked me off the ladder
|
||||
(t≈36) and the spare went with me.** Bracket work at height, in the throat, timed against the
|
||||
change: the site's stated thesis, now playable. Two notes: ladder.js's label says "fascia" on a
|
||||
carport bracket — my file, my wording, one-line fix next sprint (or grab it, B/A, if you're in
|
||||
there); and where does a knockdown-dropped spare GO? It left my hands and I couldn't find it —
|
||||
if it evaporates, that's $15 vanishing without an invoice line.
|
||||
|
||||
**3. THE WEAK-LINK LABEL (my #7): HONEST IN EVERY ORDER I THREW AT IT.** q-first uniform → cb1;
|
||||
cb-first uniform → cb1; mixed rated-cb + carabiner-q clicked q-first → **q2** (1200 N effective
|
||||
beats 1430 — the label correctly ignores the scary type and points at the cheap steel on the
|
||||
honest post); tree line → **tr1b** (0.88 branch taper read live). B, the fix is real in the UI,
|
||||
not just the suite. Ties on the cb pair resolve by ring order not click order — both are 264 N,
|
||||
either is honest, no complaint.
|
||||
|
||||
**4. C'S FRONT WALL: it reads as WEATHER, and the bench is a good judging rig.** dev_skyfx.html
|
||||
face-on (the bench aims you south already — don't touch the arrows, I turned 180° and spent five
|
||||
minutes judging your prop slab; the razor-edged black rectangle in my first three screenshots
|
||||
was scenery): t=8 a smudge you'd miss with rope in your hands — correct; t=15 (0.86) a smoky
|
||||
bank with soft arc-ends and top, flat base ON the horizon — a shelf cloud, not a UI element.
|
||||
Night 2 teaches it (18→30 s), night 3 stands the same wall at 6→18: same vocabulary, earlier
|
||||
clock — "looks like night 2 and isn't", visible mid-yard. In play at t=13 in-game + the ticker's
|
||||
"that's the change already" it LANDS. Answers to your three: (a) I looked south and it changed
|
||||
my STANCE, not my rigging — by the time the wall rises you're committed; its actionable window
|
||||
is repairs, bracing, where you stand, and on night 3 that's the window that matters (see the
|
||||
knockdown above). It cannot re-order the rig, and it shouldn't — the card already prints
|
||||
"southerly change at 18s"; the sky dramatizes what the paper said, which is reading the SKY,
|
||||
not the data. Not spoiled. (b) covered. (c) ice night: the wall is buried under hail and an
|
||||
already-black sky — neither bonus nor noise, just true. Keep it. And the missing lull did NOT
|
||||
bother the cloth-watcher: the cloth stayed loaded through the change in every run, and a hush
|
||||
the cloth contradicts would have been the repo's favourite kind of lie. Right call.
|
||||
|
||||
**5. THE ECONOMY: meaner-but-fair, leaning fair.** Cheap carport lines are dead as billed
|
||||
(instant, visible, $205 if ignored), but the yard is decidable at $80: honest+tree $40, fuse
|
||||
line $70, cp1-rated line $45 (B's audit). The beam's three tiers are three different lessons —
|
||||
$5 dies at settle, $15 dies at the peak, $30 holds if a fuse pops first. That's a shop teaching,
|
||||
not gouging. Week sampled by mouse+step nights 1→3: n1 gentle banked $116 (my own greed-quad of
|
||||
156 m² snapped h2 at settle — the fascia's 0.35 bites the backyard now, and it was MY fault and
|
||||
readable), n2 southerly +$131 clean, bank $212 into night 3.
|
||||
· ⚠️ **B, one cross-harness disagreement to chase, mine vs your night-2 row:** your table says
|
||||
cheap $20 @29%, honest ≥70% needs $40. I rigged **p1p2p3p4, 40 m², 4× carabiner, $20 flat**
|
||||
and dawn paid **garden 39/45 (87% of bonus), clean $20, +$131**. Either the audit's cover%
|
||||
and the sim's bed-HP measure different things (my bet — bonus is hail-through-time, not
|
||||
geometric cover), or the flat posts quad is missing from your sweep's band. Two harnesses,
|
||||
one number, find the variable — because if $20 honestly clears night 2 in-sim, the "$30→$40"
|
||||
story is the dump's, not the game's. Receipts: my run, this entry; your table, your entry.
|
||||
· Also for B, tiny: `rig.t` never resets between nights — my night-2 break event carried
|
||||
t=696.5 (cumulative week seconds). Only bites anyone reading event.t as storm time.
|
||||
|
||||
**6. THE BLURB, on the rendered card: honest now.** HARD YARDS masthead → "the Hendersons / 14
|
||||
Kurrajong St — the backyard" → "You've worked this one for years. You know where everything is,
|
||||
which is the only advantage you get." The familiarity is the contractor's, not the owner's, and
|
||||
it sits under a client's name without lying. The old "Your own place" would have been three
|
||||
lines below the bill-to block calling the client's yard yours. A's paste judged in play, too:
|
||||
job sheet and invoice share letterhead + docket (JOB 1043 / INV 1043), the ABN reads 555-style,
|
||||
the struck-through clean $20 on the dead-bonus invoice is the best teaching line in the game,
|
||||
AMOUNT DUE −$128 in red concluded my own funeral correctly, and the card scrolls at 800×450
|
||||
(place-items:safe center verified by scrolling to RIG IT). Bonus judged: **ship it.**
|
||||
|
||||
Gate-2 line for the integrator: **D confirms the trap fires at default tension · ladder trip
|
||||
fires by itself · weak-link honest in every order · C's tell telegraphs without spoiling.**
|
||||
New sprint-13 candidates from this pass, cheapest first: ladder.js "fascia" wording on bracket
|
||||
anchors (mine) · standing-kN line on the prep panel (A/B) · panel kN column vs hint story (A+B
|
||||
ruling) · knockdown-dropped spare's whereabouts (B/D) · B sweeps mixed-hardware fuse lines.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 12 INTEGRATION (main).** All five lanes merged, THREADS-only conflicts,
|
||||
selftest **335/0/0** — again the exact per-lane sum (315 + A 4 + B 4 + C 7 + E 5). Every gate
|
||||
delivered except the one only John can: gate 1 is still his.
|
||||
|
||||
**The sprint in one line: the trap is real now, and the paperwork looks like a business.**
|
||||
B wired the ratings and re-audited both yards (verdict: no retune — E's numbers stand); C
|
||||
second-harnessed the envelope and gave the change its wall; A pasted the kit, adopted HARD
|
||||
YARDS, wired the bike, ruled the gutter at E's $90 and declined the bike's $60 with a tripwire;
|
||||
E priced the gutter and built its wreck with a real negative control; D replayed everything and
|
||||
filed the verdict that matters: fork dead, trap fires at default tension, ladder thesis playable,
|
||||
wall telegraphs without spoiling, paperwork ships.
|
||||
|
||||
**One integration edit (the leadFor precedent):** skyfx.js's creak audio read bare `hw.rating` —
|
||||
B's flag, C's file, C had already wrapped. It now reads rating × ratingHint, in-file comment says
|
||||
why: a creak keyed on the shackle alone goes quiet exactly where the steel is worst.
|
||||
|
||||
**Verified live at merge, not just green:** `collateralFor('gutter')` returns {cost:90} on the
|
||||
backyard and stays null for the carport there (both correct); E's wreck GLB + A's wiring met for
|
||||
the first time here and the plumbing holds; the bike stands at world (−7, 9.69) against the south
|
||||
fence; the card reads HARD YARDS / JOB SHEET. Zero console errors.
|
||||
|
||||
**Carried to SPRINT13's pool, none blocking (mostly D's replay finds):**
|
||||
· prep shows no standing-kN, so max-tension's prep-rip lesson always costs ~$60 blind — a
|
||||
readout is the obvious fix and it's A/B seam work;
|
||||
· mixed-hardware "fuse" lines — cheap post corners protecting the beam is either emergent
|
||||
depth or a hole; B to sweep (D's +$147 line is the exhibit);
|
||||
· cover% (audit) vs bed-HP (sim) read differently on the same $20 rig — find-the-variable
|
||||
posted B↔D, no retune till it's found;
|
||||
· `rig.t` never resets between nights (break.t=696.5 in D's log) — B's file;
|
||||
· ladder label says "fascia" on a carport bracket (D's file); the dropped spare's whereabouts
|
||||
are unknown; C's bench aims you south (noted in the bench).
|
||||
· Doc erratum: SPRINT12.md said "tree 1.0" — the branch ladder is 1.0/0.88/0.76, deliberate
|
||||
Sprint-7 intel (C flagged twice, B confirmed).
|
||||
|
||||
**Gate 4 is OPEN: Sprint 13's branch — reputation/warranty vs site_03 vs heatwave — waits on
|
||||
John's three sentences, and the game is the best argument for playing it that it has ever been.**
|
||||
|
||||
28
docs/license-request-cortiz.md
Normal file
28
docs/license-request-cortiz.md
Normal file
@ -0,0 +1,28 @@
|
||||
# License request — cortiz2894/stylized-components
|
||||
|
||||
Post at: https://github.com/cortiz2894/stylized-components/issues/new
|
||||
(or email cortiz2894@gmail.com)
|
||||
|
||||
**Title:** Add a LICENSE file so the "free code" is formally free
|
||||
|
||||
---
|
||||
|
||||
Hey Christian — the WaterFloor breakdown is fantastic, and your video
|
||||
describes this as a free resource to drop into our own projects. The repo
|
||||
doesn't currently have a LICENSE file though, which under GitHub's terms
|
||||
means it defaults to all-rights-reserved — so cautious folks (and any
|
||||
company) legally can't use it despite your clear intent.
|
||||
|
||||
Would you consider adding one? MIT is the usual pick for "use it anywhere,
|
||||
keep my copyright line" (it's what three.js itself uses). GitHub can add it
|
||||
in two clicks: Add file → choose the MIT template.
|
||||
|
||||
Same goes for your other free-resource repos (flow-shield-effect,
|
||||
hologram-particles, charged-blast-vfx, showcase-images, mouse-effects…) —
|
||||
they all say "free open-source resource" in the description, and none has a
|
||||
LICENSE file, so none of them is formally open source yet. One MIT file per
|
||||
repo and the whole collection becomes actually usable by the community you
|
||||
built it for.
|
||||
|
||||
Thanks for sharing the breakdowns — the F1−SmoothF1 Voronoi write-up
|
||||
especially is gold.
|
||||
@ -133,6 +133,31 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside_wrecked",
|
||||
"dims": [
|
||||
9.4552,
|
||||
2.1021,
|
||||
2.9172
|
||||
],
|
||||
"tris": 340,
|
||||
"nodes": [
|
||||
"debris_fascia",
|
||||
"door",
|
||||
"downpipe_loose",
|
||||
"fascia_torn",
|
||||
"gutter_down",
|
||||
"gutter_torn",
|
||||
"house_yardside_wrecked",
|
||||
"roof",
|
||||
"wall",
|
||||
"window",
|
||||
"window_glow",
|
||||
"window_light_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carport_01",
|
||||
"dims": [
|
||||
@ -468,42 +493,5 @@
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
"debris": []
|
||||
}
|
||||
@ -684,10 +684,16 @@ def build_gate(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_house_yardside(name):
|
||||
"""Rear façade only — no interior. The fascia is the point: DESIGN.md says
|
||||
it holds until the first real gust, then leaves with the gutter."""
|
||||
root = add_empty(name)
|
||||
def _house_facade(name, root):
|
||||
"""The rear façade shared VERBATIM by the intact house and its torn-gutter
|
||||
wreck — wall ring, door, window, night glow, roof. Split out in Sprint 12
|
||||
because a house does not fall down when a sail rips its eave line off (the
|
||||
carport folds; brick veneer shrugs), so the two variants have to be the SAME
|
||||
building or the aftermath swap reads as the house moving. One set of numbers,
|
||||
two eave lines.
|
||||
|
||||
Returns the measurements and materials the eave line is built from, because
|
||||
the eave is exactly the part that differs between the two callers."""
|
||||
wall_m = get_material("Mat_Render", PAL["render_wall"], 0.9)
|
||||
brick_m = get_material("Mat_Brick", PAL["brick"], 0.9)
|
||||
trim = get_material("Mat_Timber", PAL["timber_dark"], 0.8)
|
||||
@ -754,12 +760,47 @@ def build_house_yardside(name):
|
||||
root, size=0.2)
|
||||
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
|
||||
|
||||
# Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y).
|
||||
# The roof and its eave. The eave overhangs 0.55 into the yard (-Y).
|
||||
eave_y = -0.55
|
||||
fascia_z = H + 0.10
|
||||
join_group([add_box(f"{name}_roof", (W + 0.2, D + 0.75, 0.10),
|
||||
(0, (eave_y + D / 2) / 2, H + 0.05), roof_m,
|
||||
rot=(math.radians(-6), 0, 0))], "roof", root)
|
||||
return dict(W=W, H=H, D=D, eave_y=eave_y, fascia_z=fascia_z,
|
||||
trim=trim, gutter_m=gutter_m)
|
||||
|
||||
|
||||
# What it costs to take the client's gutter — and the fascia it hangs off —
|
||||
# with you. Same deal as CARPORT_COLLATERAL below, second time around: Lane A
|
||||
# owns this number, it's a design call, not an art one. But the fascia anchors
|
||||
# have said collateral:"gutter" since Sprint 6 and NOBODY has ever priced one —
|
||||
# collateralFor('gutter') returns null, so backyard_01's house is a free
|
||||
# failure: tie 25 m² to a 0.35 fascia, rip it off, pay for a shackle. My
|
||||
# proposal and the reasoning, to argue with rather than adopt:
|
||||
# · the band is established now: gnome 25 at the floor, carport 180, my
|
||||
# stated ceiling ~250. The gutter goes between the ornament and the
|
||||
# structure, because that is what it is;
|
||||
# · a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a
|
||||
# 32 m/s peak). At 90 the gutter costs slightly more than EITHER — the
|
||||
# night it fires on is wiped, kit and fee together. That is the fascia's
|
||||
# lesson in one number: the "free" anchors were the dearest kit in the yard;
|
||||
# · under ~50 it's a shrug — less than the fee alone, so the trap couldn't
|
||||
# even wipe its own night;
|
||||
# · over ~130 the backyard outbids half a carport for a smaller lie: the
|
||||
# fascia (0.35) is honestly better steel than the beam (0.22), it's three
|
||||
# anchors not four, and it's the tutorial yard — the first trap a player
|
||||
# can hit should cost them a night, not the week.
|
||||
GUTTER_COLLATERAL = 90
|
||||
|
||||
|
||||
def build_house_yardside(name):
|
||||
"""Rear façade only — no interior. The fascia is the point: DESIGN.md says
|
||||
it holds until the first real gust, then leaves with the gutter."""
|
||||
root = add_empty(name)
|
||||
f = _house_facade(name, root)
|
||||
W, H, eave_y, fascia_z = f["W"], f["H"], f["eave_y"], f["fascia_z"]
|
||||
trim, gutter_m = f["trim"], f["gutter_m"]
|
||||
|
||||
fascia = add_box("fascia", (W + 0.2, 0.035, 0.20), (0, eave_y, fascia_z),
|
||||
trim, parent=root)
|
||||
gutter = join_group([
|
||||
@ -780,6 +821,109 @@ def build_house_yardside(name):
|
||||
e["collateral"] = "gutter"
|
||||
stamp(root, name, "structure")
|
||||
root["facade_width"] = W
|
||||
# The proposal rides in the asset, exactly like the carport's did (and A
|
||||
# adopted that one into site JSON, where the canonical number now lives —
|
||||
# sites are data; this is the fallback and the argument). collateral_key is
|
||||
# NEW and explicit because the carport never needed it: there, the priced
|
||||
# thing and the structure share a name. Here the structure is "house" and
|
||||
# the thing you take is the GUTTER — the key names which collateral string
|
||||
# this value prices, so Lane A's wiring never has to guess.
|
||||
root["collateral_key"] = "gutter"
|
||||
root["collateral_value"] = GUTTER_COLLATERAL
|
||||
root["collateral_label"] = "the gutter"
|
||||
return root
|
||||
|
||||
|
||||
def build_house_yardside_wrecked(name):
|
||||
"""The house after the fascia let go (SPRINT12 §gate 3.3) — DESIGN.md's line
|
||||
made geometry: "holds until the first real gust, then rips off taking the
|
||||
gutter with it".
|
||||
|
||||
Same origin, same footprint, same façade — _house_facade, shared verbatim —
|
||||
because a house does not fall down when a sail takes its eave line; only
|
||||
what the anchors betrayed changes. The fascia is torn through the span the
|
||||
fixings held, and the gutter it carried is in two states at once: the left
|
||||
run still hangs off the surviving board by its last bracket, torn end sagged
|
||||
toward the grass, and the right run is ON the grass out in the yard, kinked
|
||||
where it landed, with the downpipe leaning out of plumb after it. A gutter
|
||||
doesn't shatter — it unzips, springs, and lands where the wind was going.
|
||||
|
||||
NO fascia_anchor_* empties survive into this file. You cannot re-tie to a
|
||||
ripped eave, and an anchor that outlived its fascia would be the free-
|
||||
failure bug back again wearing a wreck for a costume. e.test.js pins that,
|
||||
and that the gutter is DOWN here while its intact twin's is UP at the eave.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
f = _house_facade(name, root)
|
||||
W, eave_y, fascia_z = f["W"], f["eave_y"], f["fascia_z"]
|
||||
trim, gutter_m = f["trim"], f["gutter_m"]
|
||||
|
||||
# The fascia, minus what the sail took: a 3.4 m board survives on the left,
|
||||
# a stub clings to the far right corner, and both torn ends splinter past
|
||||
# the break. The gap in between is where fascia_anchor_02 used to be.
|
||||
fascia = [
|
||||
add_box(f"{name}_fascia_left", (3.4, 0.035, 0.20),
|
||||
(-2.9, eave_y, fascia_z), trim),
|
||||
add_box(f"{name}_fascia_stub", (0.6, 0.035, 0.20),
|
||||
(4.3, eave_y, fascia_z), trim),
|
||||
]
|
||||
for i, (bx, ang) in enumerate(((-1.05, 14), (3.85, -18))):
|
||||
fascia.append(add_box(f"{name}_fascia_splinter_{i}", (0.38, 0.028, 0.09),
|
||||
(bx, eave_y, fascia_z + 0.02), trim,
|
||||
rot=(0, math.radians(ang + rng.uniform(-3, 3)), 0)))
|
||||
join_group(fascia, "fascia_torn", root)
|
||||
|
||||
# The left run: still bracketed at the healthy end, torn free at the break,
|
||||
# hanging diagonally with the freed end sprung out over the grass.
|
||||
join_group([add_tube_between(
|
||||
f"{name}_gutter_hang",
|
||||
(-4.4, eave_y - 0.05, fascia_z - 0.12),
|
||||
(-0.55, eave_y - 0.34, 1.28), 0.055, gutter_m, verts=8)],
|
||||
"gutter_torn", root)
|
||||
|
||||
# The right run went where the wind was going: out over the yard, down on
|
||||
# the grass, kinked where it hit. Flat ON the grass — the carport wreck's
|
||||
# first-pass lesson (a corner sunk to z=-0.41) is why nothing here sinks.
|
||||
kx = 2.4 + rng.uniform(-0.3, 0.3)
|
||||
join_group([
|
||||
add_tube_between(f"{name}_gutter_ground_a",
|
||||
(0.35, -1.35, 0.055), (kx, -1.72, 0.055),
|
||||
0.055, gutter_m, verts=8),
|
||||
add_tube_between(f"{name}_gutter_ground_b",
|
||||
(kx, -1.72, 0.055), (4.85, -1.48, 0.055),
|
||||
0.055, gutter_m, verts=8),
|
||||
], "gutter_down", root)
|
||||
|
||||
# The downpipe keeps its bottom strap and loses the top one with the run —
|
||||
# so it leans out of plumb into the yard. Cheapest possible "this eave is
|
||||
# finished" tell: it reads from across the lawn at night.
|
||||
join_group([add_tube_between(
|
||||
f"{name}_downpipe_loose",
|
||||
(W / 2 - 0.25, eave_y - 0.05, 0.02),
|
||||
(W / 2 - 0.12, eave_y - 0.62, 2.58), 0.04, gutter_m, verts=8)],
|
||||
"downpipe_loose", root)
|
||||
|
||||
# What a ripped fascia leaves on the lawn: offcuts scattered near where the
|
||||
# run came down, every board flat on the grass.
|
||||
debris = []
|
||||
for i, (dx, dy, ln, ang) in enumerate(((1.35, -1.15, 0.85, 22),
|
||||
(3.25, -1.58, 0.55, -34),
|
||||
(2.15, -0.92, 0.34, 63))):
|
||||
debris.append(add_box(f"{name}_fascia_bit_{i}", (ln, 0.20, 0.035),
|
||||
(dx + rng.uniform(-0.08, 0.08),
|
||||
dy + rng.uniform(-0.08, 0.08), 0.0175), trim,
|
||||
rot=(0, 0, math.radians(ang + rng.uniform(-6, 6)))))
|
||||
join_group(debris, "debris_fascia", root)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "house_yardside"
|
||||
root["facade_width"] = W
|
||||
# Same price as the state it used to be, so scoring can read either —
|
||||
# the carport wreck's rule, verbatim.
|
||||
root["collateral_key"] = "gutter"
|
||||
root["collateral_value"] = GUTTER_COLLATERAL
|
||||
root["collateral_label"] = "the gutter"
|
||||
return root
|
||||
|
||||
|
||||
@ -2277,6 +2421,15 @@ ASSETS = [
|
||||
nodes=["wall", "door", "window", "roof", "fascia", "gutter",
|
||||
"window_glow", "window_light_anchor",
|
||||
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
|
||||
# The torn-gutter aftermath (SPRINT12 §gate 3.3). Same height as the intact
|
||||
# house — a house doesn't fall, only its eave line does — but DEEPER (y),
|
||||
# because the right gutter run and the fascia offcuts are on the grass out
|
||||
# in the yard. If y ever measures ~1.2 again someone un-tore it.
|
||||
dict(name="house_yardside_wrecked", fn=build_house_yardside_wrecked,
|
||||
dims=((9.0, 9.9), (1.7, 2.6), (2.8, 3.3)),
|
||||
nodes=["wall", "door", "window", "roof", "window_glow",
|
||||
"fascia_torn", "gutter_torn", "gutter_down",
|
||||
"downpipe_loose", "debris_fascia"]),
|
||||
# site_02 (corner block): the anchor trap, not just dressing.
|
||||
dict(name="carport_01", fn=build_carport_01,
|
||||
dims=((3.0, 3.5), (5.2, 5.8), (2.4, 2.8)),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.5 MiB |
@ -67,9 +67,12 @@ async function run() {
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
|
||||
|
||||
// world.anchors are the REAL dressed positions — freeze sway like balance.test.
|
||||
// ratingHint rides along (SPRINT12): the sweep prices hardware against
|
||||
// rating × hint, and dropping it here would audit a yard where the fascia
|
||||
// and the carport beam are honest steel — the exact lie the wiring killed.
|
||||
const anchors = world.anchors.map((a) => {
|
||||
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
|
||||
return { id: a.id, type: a.type, pos, sway: () => pos };
|
||||
return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
@ -105,7 +108,7 @@ async function run() {
|
||||
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
|
||||
const cs = tr.insertCell(); cs.className = 'corner';
|
||||
cs.innerHTML = r.tiers.map((c) =>
|
||||
`${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
|
||||
`${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
|
||||
}
|
||||
el('out').appendChild(tbl);
|
||||
|
||||
|
||||
@ -67,14 +67,34 @@ const BACKYARD_01 = {
|
||||
bed: { x: 1, z: 2, w: 6, d: 4 },
|
||||
venturi: [], // backyard_01.json ships "venturi": [] — no funnel. Stated, not assumed.
|
||||
anchors: [
|
||||
// dress()-only: adopted from E's GLBs. Unverifiable headless.
|
||||
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
|
||||
['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
|
||||
['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
|
||||
// pure world.js — cross-checked against live code on every run.
|
||||
['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
|
||||
['p4', 'post', -3.72, 4.00, -1.40],
|
||||
].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
|
||||
// dress()-only: adopted from E's GLBs. Unverifiable headless. The 6th column
|
||||
// is ratingHint, dumped from the live dressed world.anchors (SPRINT12) —
|
||||
// the fascia's 0.35 and the branches' 1.0/0.88/0.76 are E's baked numbers,
|
||||
// and the sweep prices hardware against rating × hint now that sail.js
|
||||
// fails on it. Hand-kept like the positions; the browser front-end is the
|
||||
// authority if these ever drift.
|
||||
//
|
||||
// FULL PRECISION, not 2-decimal — measured, the hard way (SPRINT12): the
|
||||
// old 2-dp dump moved t2 by ~5 mm, which sat it differently against t1's
|
||||
// shelter cone and swung its ice-night peak 4.8 → 9.3 kN — the two
|
||||
// front-ends disagreed by one whole winnable line (ice: 1 winner vs the
|
||||
// browser's true 2) until the positions matched to the float. Near a
|
||||
// shelter edge the sweep is mm-sensitive; round nothing.
|
||||
['h1', 'house', -3, 2.4797002522727953, -9.949999988079071, 0.35],
|
||||
['h2', 'house', 0, 2.4797002522727953, -9.949999988079071, 0.35],
|
||||
['h3', 'house', 3, 2.4797002522727953, -9.949999988079071, 0.35],
|
||||
['t1', 'tree', -9.960037052631378, 3.4546064703375015, 0.5399932861328125, 1.0],
|
||||
['t2', 'tree', 7.833247900009155, 2.9268529771469947, -0.8460308313369751, 1.0],
|
||||
['t1b', 'tree', -9.941157281398773, 3.9636089174657974, 2.7770196199417114, 0.88],
|
||||
['t1c', 'tree', -10.381556510925293, 5.045027431717506, 2.97845321893692, 0.76],
|
||||
['t2b', 'tree', 8.141596481204033, 3.698920047154319, -0.9573200941085815, 0.88],
|
||||
// pure world.js — cross-checked against live code on every run (0.01 m
|
||||
// tolerance). Posts are site-JSON anchors: DEFAULT_RATING_HINT = 1.
|
||||
['p1', 'post', -4.852518667660811, 3.9450703766405963, 5.930856149363214, 1.0],
|
||||
['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0],
|
||||
['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0],
|
||||
['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0],
|
||||
].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
|
||||
};
|
||||
|
||||
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
|
||||
@ -160,6 +180,9 @@ async function loadSite(path) {
|
||||
// This is what a dressed export (or a future createWorld dump) would hand us.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
// hint 1 when the export omits it — but a dressed export SHOULD carry it,
|
||||
// or a fascia/carport anchor audits as honest steel (see sweep.js tierFor).
|
||||
ratingHint: a.ratingHint ?? 1,
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
}));
|
||||
// A resolved export still owes us the site's funnel — the venturi is site data,
|
||||
@ -205,7 +228,7 @@ async function main() {
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
@ -35,13 +35,28 @@ export function areaOf(q) {
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peakN` newtons, or null if the shop can't. */
|
||||
export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
/**
|
||||
* Cheapest hardware that holds a corner at `peakN` newtons off an anchor with
|
||||
* `ratingHint` hint, or null if the shop can't at any price.
|
||||
*
|
||||
* SPRINT12: sail.js fails a corner on `load > hw.rating * anchor.ratingHint`
|
||||
* (A's ruling — the anchor is a failure point, not just the steel), so the
|
||||
* hardware that HOLDS is the cheapest h with `h.rating * hint >= peak`, i.e.
|
||||
* `h.rating >= peak / hint`. An audit that keeps pricing on bare hw.rating
|
||||
* flies a different game than ships: on the corner block it would sell you a
|
||||
* $5 carabiner for a beam that now lets go at 0.22 of it.
|
||||
*/
|
||||
export const tierFor = (peakN, ratingHint = 1) =>
|
||||
HARDWARE.find((h) => h.rating * ratingHint >= peakN) || null;
|
||||
|
||||
/**
|
||||
* Sweep every bed-covering quad and price the cheapest holding line.
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway }
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway,
|
||||
* ratingHint } — omit ratingHint and the anchor is
|
||||
* priced at full hardware strength (hint 1), which
|
||||
* is a LIE for any GLB-dressed anchor (fascia 0.35,
|
||||
* carport beam 0.22). Hand this the dressed truth.
|
||||
* @param {object} o.bed garden bed rect { x, z, w, d }
|
||||
* @param {object} o.stormDef the storm JSON to fly
|
||||
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
|
||||
@ -103,7 +118,12 @@ export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const tiers = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad, tier: tierFor(c.peakLoad) }));
|
||||
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
|
||||
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
|
||||
const tiers = rig.corners.map((c) => ({
|
||||
id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1,
|
||||
tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1),
|
||||
}));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
|
||||
|
||||
@ -87,4 +87,36 @@ test('no venturi is a no-op — a site without a funnel is untouched', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFor', () => {
|
||||
// SPRINT12: sail.js fails a corner on rating × ratingHint, so the sweep must
|
||||
// price hardware against the same product or it audits a nicer yard than
|
||||
// ships — a fascia (0.35) or carport beam (0.22) corner would be sold a $5
|
||||
// carabiner that the sim now snaps. Same synthetic yard, one anchor's hint
|
||||
// dropped to 0.01: its corner's demand (peak / hint) leaves the shop's 6.5 kN
|
||||
// ceiling entirely, so its tier must go to NONE while every OTHER corner and
|
||||
// every PEAK stays byte-identical (the hint is a pricing fact, not physics —
|
||||
// the sweep flies unbreakable audit hardware). Written to fail if the hint
|
||||
// is dropped from sweep.js's tierFor call: the two runs collapse into one.
|
||||
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0];
|
||||
const honest = sweep(ANCHORS);
|
||||
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
|
||||
|
||||
for (let i = 0; i < honest.tiers.length; i++) {
|
||||
assert(Math.abs(honest.tiers[i].peak - lied.tiers[i].peak) < 1e-9,
|
||||
`corner ${honest.tiers[i].id}: ratingHint moved a PEAK (${honest.tiers[i].peak} -> ${lied.tiers[i].peak}) — ` +
|
||||
'the hint is a failure threshold, it must never touch the flown loads');
|
||||
}
|
||||
const a1 = lied.tiers.find((c) => c.id === 'a1');
|
||||
const a1honest = honest.tiers.find((c) => c.id === 'a1');
|
||||
assert(a1honest.tier, 'fixture drift: a1 must be holdable at hint 1 or this test asserts nothing');
|
||||
assert(!a1.tier,
|
||||
`a1 at hint 0.01 (demand ${(a1.peak / 0.01 / 1000).toFixed(1)} kN vs the 6.5 kN ceiling) still got ` +
|
||||
`hardware ($${a1.tier?.cost}) — sweep.js is pricing bare hw.rating, not rating × ratingHint`);
|
||||
const others = lied.tiers.filter((c) => c.id !== 'a1');
|
||||
assert(others.every((c) => c.tier && c.tier.cost === honest.tiers.find((h) => h.id === c.id).tier.cost),
|
||||
'a hint on a1 repriced a DIFFERENT corner — hints must be per-anchor');
|
||||
assert(lied.unholdable.some((c) => c.id === 'a1'),
|
||||
'an over-ceiling corner must land in unholdable, or the verdict lies');
|
||||
});
|
||||
|
||||
export const SWEEP_TESTS = TESTS;
|
||||
|
||||
140
tools/storm_envelope/envelope.html
Normal file
140
tools/storm_envelope/envelope.html
Normal file
@ -0,0 +1,140 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
storm_envelope, browser front-end. [Lane C, SPRINT12 — gate 2.4's second harness]
|
||||
|
||||
B's site_audit measures the failure envelope through the CLOTH (peak corner
|
||||
loads, kN). This measures the same envelope through the STORM: what the wind
|
||||
actually delivers at every dressed anchor, per night — peak local speed, when,
|
||||
dynamic pressure, dose, and the pressure/hint ranking that predicts failure
|
||||
ORDER under B's `load > rating × ratingHint` wiring. Two harnesses, one
|
||||
envelope; if they disagree, find the variable before anyone retunes E's hints.
|
||||
|
||||
Dressed positions the way the game gets them — createWorld(await loadSite(name))
|
||||
then dress() — because a graybox envelope measures a yard that does not ship
|
||||
(site_audit's own lesson, kept).
|
||||
|
||||
tools/storm_envelope/envelope.html ← the whole week
|
||||
tools/storm_envelope/envelope.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||
|
||||
Served from the repo root (server.py).
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>storm_envelope</title>
|
||||
<style>
|
||||
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||||
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||||
h2 { font-size:13px; color:#9cf; margin:18px 0 2px; }
|
||||
.sub { color:#888; margin-bottom:10px; white-space:pre-wrap; }
|
||||
table { border-collapse:collapse; margin:6px 0; }
|
||||
th { text-align:right; color:#789; font-weight:normal; padding:1px 12px 1px 0; }
|
||||
th:first-child, td:first-child { text-align:left; }
|
||||
td { padding:1px 12px 1px 0; white-space:nowrap; text-align:right; }
|
||||
.weak { color:#e96; } .probe { color:#678; font-style:italic; }
|
||||
</style>
|
||||
<h1>storm_envelope — the wind side of the failure envelope</h1>
|
||||
<div class="sub" id="sub">loading…</div>
|
||||
<div id="out"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "../../web/world/vendor/three.module.js",
|
||||
"three/addons/": "../../web/world/vendor/addons/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { NIGHTS } from '../../web/world/js/week.js';
|
||||
import { stormEnvelope } from './envelope.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
// One dressed world per site, cached — the yard doesn't change between storms.
|
||||
const siteCache = new Map();
|
||||
async function dressedSite(name) {
|
||||
if (siteCache.has(name)) return siteCache.get(name);
|
||||
const site = await loadSite(name);
|
||||
const calmStub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: calmStub, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* flagged below */ } }
|
||||
const anchors = world.anchors.map((a) => ({
|
||||
id: a.id, type: a.type, ratingHint: a.ratingHint,
|
||||
pos: { x: a.pos.x, y: a.pos.y, z: a.pos.z },
|
||||
}));
|
||||
const out = { site, anchors, dressed, bed: world.gardenBed };
|
||||
siteCache.set(name, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(title, siteName, stormName, res, dressed) {
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = title;
|
||||
el('out').appendChild(h);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = `${siteName} × ${stormName} — downdraftOfTotal ${res.downFrac}, ${res.duration}s`
|
||||
+ (dressed ? '' : ' ⚠ dress() FAILED — graybox positions, not what ships');
|
||||
el('out').appendChild(sub);
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
const head = tbl.insertRow();
|
||||
for (const c of ['anchor', 'type', 'hint', 'peak m/s', 'tPeak s', 'peak Pa', 'Pa/hint', 'dose m²/s', 'eff. cara/shackle/rated N']) {
|
||||
const th = document.createElement('th'); th.textContent = c; head.appendChild(th);
|
||||
}
|
||||
for (const r of res.rows) {
|
||||
const tr = tbl.insertRow();
|
||||
if (r.probe) tr.className = 'probe';
|
||||
else if (r.hint < 0.5) tr.className = 'weak';
|
||||
tr.insertCell().textContent = r.id;
|
||||
tr.insertCell().textContent = r.type;
|
||||
tr.insertCell().textContent = r.hint.toFixed(2);
|
||||
tr.insertCell().textContent = r.peak.toFixed(2);
|
||||
tr.insertCell().textContent = r.tPeak.toFixed(1);
|
||||
tr.insertCell().textContent = r.peakPa.toFixed(0);
|
||||
tr.insertCell().textContent = r.paPerHint.toFixed(0);
|
||||
tr.insertCell().textContent = r.dose.toFixed(0);
|
||||
tr.insertCell().textContent = r.probe ? '—' : r.effN.map((n) => n.toFixed(0)).join(' / ');
|
||||
}
|
||||
el('out').appendChild(tbl);
|
||||
}
|
||||
|
||||
async function runPair(siteName, stormName, title) {
|
||||
const { site, anchors, dressed, bed } = await dressedSite(siteName);
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
const probes = [{ id: 'bed centre', pos: { x: bed.x, z: bed.z } }];
|
||||
if (venturi.length) probes.push({ id: 'throat', pos: { x: venturi[0].x, z: venturi[0].z } });
|
||||
const res = stormEnvelope({ anchors, stormDef, venturi, probes });
|
||||
render(title, siteName, stormName, res, dressed);
|
||||
return { site: siteName, storm: stormName, dressed,
|
||||
rows: res.rows.map(({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose }) =>
|
||||
({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose })) };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const single = q.get('site') || q.get('storm');
|
||||
const pairs = single
|
||||
? [{ site: q.get('site') || 'backyard_01', storm: q.get('storm') || 'storm_02_wildnight', title: 'requested pair' }]
|
||||
: NIGHTS.map((n, i) => ({ site: n.site, storm: n.storm, title: `night ${i + 1}` }));
|
||||
el('sub').textContent = `${pairs.length} pair(s) · sampling at FIXED_DT over each storm · `
|
||||
+ `venturi + tree shelters wired the way main.js wires them`;
|
||||
const results = [];
|
||||
for (const p of pairs) results.push(await runPair(p.site, p.storm, p.title));
|
||||
// machine-readable, so the page can be driven headless-in-browser
|
||||
window.__envelope = results;
|
||||
document.title = 'storm_envelope — done';
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
el('sub').textContent = `storm_envelope crashed: ${e.message}\n${e.stack || ''}`;
|
||||
window.__envelope = { error: e.message };
|
||||
document.title = 'storm_envelope — ERROR';
|
||||
});
|
||||
</script>
|
||||
107
tools/storm_envelope/envelope.js
Normal file
107
tools/storm_envelope/envelope.js
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* envelope.js — the STORM-side failure envelope, per anchor. [Lane C, SPRINT12]
|
||||
*
|
||||
* SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the
|
||||
* cloth sim (site_audit: settle, fly the storm, read peak corner loads in N).
|
||||
* This is the SECOND harness on the same envelope, measured from the other side:
|
||||
* no cloth, no rig, no tension — just what the STORM delivers at each anchor's
|
||||
* position, through the same wind everyone samples (createWind + the site's
|
||||
* venturi + the tree shelters, exactly as main.js wires them at site load).
|
||||
*
|
||||
* Two harnesses, one number — the repo rule. What must agree:
|
||||
* · ORDERING: anchors ranked by wind-side pressure/hint should rank the same
|
||||
* way B's peak corner loads do, once quad geometry is accounted for. An
|
||||
* anchor whose load outranks its wind exposure has a variable to find.
|
||||
* · TIMING: B's peak corner load should land near the wind-side tPeak (cloth
|
||||
* lag is under a second; the venturi's alignment window is tens of seconds).
|
||||
* What will NOT agree, by construction: absolute newtons. Corner load carries
|
||||
* quad geometry, cloth porosity, tension and rain mass — all B's side. This
|
||||
* harness deliberately knows none of it, which is what makes it independent.
|
||||
*
|
||||
* Per anchor, per storm:
|
||||
* peak highest local horizontal wind speed over the storm, m/s
|
||||
* (wind.speedAt — the anemometer number, same units as every probe
|
||||
* table in THREADS)
|
||||
* tPeak when it happened, s
|
||||
* peakPa peak dynamic pressure, Pa: ½ρ·v²·(1+downFrac²) — the (1+d²) folds
|
||||
* the downdraft back in, since cloth feels the full vector while
|
||||
* speedAt deliberately reads horizontal-only
|
||||
* dose ∫ v² dt over the whole storm, m²/s — exposure × time, so a long
|
||||
* grind and a single spike stop looking alike
|
||||
* paPerHint peakPa / ratingHint — the wind-side "who blows first" ranking.
|
||||
* B's wiring is `load > hw.rating * ratingHint`, so dividing the
|
||||
* delivered pressure by the hint predicts failure ORDER for equal
|
||||
* hardware and equal geometry. Prediction, not measurement — B's
|
||||
* harness is the one that owns the geometry.
|
||||
*
|
||||
* Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = i·dt over
|
||||
* the storm's duration, same seed path (createWind(def) — def.seed), so the two
|
||||
* harnesses fly the SAME storm, not merely the same JSON.
|
||||
*/
|
||||
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
|
||||
export const ENVELOPE = {
|
||||
RHO: 1.225, // kg/m³, sea-level air — the constant, named once
|
||||
DT: FIXED_DT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Measure the storm at every anchor.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? }
|
||||
* — pass the DRESSED world.anchors (browser) or a
|
||||
* verified dump; graybox positions measure a yard
|
||||
* that does not ship (site_audit's lesson).
|
||||
* @param {object} o.stormDef parsed storm JSON
|
||||
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) —
|
||||
* a sweep without it flies an easier yard than ships
|
||||
* @param {Array} [o.probes] extra { id, pos } points (bed centre, throat…)
|
||||
* measured alongside, hint fixed at 1
|
||||
* @param {number} [o.dt]
|
||||
* @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending
|
||||
*/
|
||||
export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) {
|
||||
const wind = createWind(stormDef);
|
||||
wind.setVenturi(venturi);
|
||||
// exactly main.js:447 — every tree anchor casts a shadow, defaults and all
|
||||
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
|
||||
const downFrac = wind.core.downFrac;
|
||||
const vecFold = 1 + downFrac * downFrac; // |v|² = h²·(1+d²) when vy = -d·h
|
||||
|
||||
const rows = [
|
||||
...anchors.map((a) => ({
|
||||
id: a.id, type: a.type, probe: false,
|
||||
hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1,
|
||||
pos: { x: a.pos.x, z: a.pos.z },
|
||||
peak: 0, tPeak: 0, dose: 0,
|
||||
})),
|
||||
...probes.map((p) => ({
|
||||
id: p.id, type: 'probe', probe: true, hint: 1,
|
||||
pos: { x: p.pos.x, z: p.pos.z },
|
||||
peak: 0, tPeak: 0, dose: 0,
|
||||
})),
|
||||
];
|
||||
|
||||
const duration = stormDef.duration ?? 90;
|
||||
const n = Math.round(duration / dt);
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i * dt;
|
||||
for (const r of rows) {
|
||||
const s = wind.speedAt(r.pos, t);
|
||||
if (s > r.peak) { r.peak = s; r.tPeak = t; }
|
||||
r.dose += s * s * dt;
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold;
|
||||
r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1);
|
||||
// what B's wiring makes each shop tier hold HERE: rating × hint, in N
|
||||
r.effN = HARDWARE.map((h) => h.rating * r.hint);
|
||||
}
|
||||
rows.sort((a, b) => b.paPerHint - a.paPerHint);
|
||||
return { rows, downFrac, duration };
|
||||
}
|
||||
100
tools/storm_envelope/envelope.selftest.js
Normal file
100
tools/storm_envelope/envelope.selftest.js
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* envelope.selftest.js — the second harness proves it measures. [Lane C, SPRINT12]
|
||||
*
|
||||
* Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
|
||||
* selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
|
||||
* flew the corner block with the funnel switched off for two sprints; this file
|
||||
* exists so the harness built to CHECK that tool can't quietly do the same.
|
||||
* Every assert here is written to fail if a wiring line is deleted:
|
||||
* · drop setVenturi → the throat anchor's funnelled/unfunnelled peaks
|
||||
* collapse to equal and the strict < goes red
|
||||
* · drop setSheltersFromTrees → the sheltered anchor stops reading lower
|
||||
* · reseed per call → the determinism byte-compare goes red
|
||||
* · flip paPerHint to × → the weak-anchor-ranks-first assert goes red
|
||||
*/
|
||||
|
||||
import { stormEnvelope } from './envelope.js';
|
||||
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
/**
|
||||
* A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
|
||||
* gusts, no wander, no spatial noise — so every local effect is the ONLY thing
|
||||
* moving a number, and the asserts can be strict instead of statistical.
|
||||
*/
|
||||
const FLAT = {
|
||||
name: 'flat_test', seed: 7, duration: 20,
|
||||
baseCurve: [[0, 10], [20, 10]],
|
||||
dirCurve: [[0, 0], [20, 0]],
|
||||
dirWander: { amp: 0, rate: 0 },
|
||||
spatial: { amp: 0 },
|
||||
gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
|
||||
};
|
||||
|
||||
const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
|
||||
|
||||
test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => {
|
||||
const anchors = [A('a', 0, 0), A('b', 5, 3)];
|
||||
const def = JSON.parse(JSON.stringify(FLAT));
|
||||
const r1 = stormEnvelope({ anchors, stormDef: def });
|
||||
const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
|
||||
for (let i = 0; i < r1.rows.length; i++) {
|
||||
assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
|
||||
&& r1.rows[i].tPeak === r2.rows[i].tPeak,
|
||||
`run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => {
|
||||
const anchors = [A('throat', 0, 0), A('far', 100, 0)];
|
||||
const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
|
||||
const off = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
|
||||
const g = (res, id) => res.rows.find((r) => r.id === id);
|
||||
// strict: delete envelope.js's setVenturi call and this collapses to equal
|
||||
assert(g(on, 'throat').peak > g(off, 'throat').peak,
|
||||
`funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`);
|
||||
assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
|
||||
`gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
|
||||
assert(g(on, 'far').peak === g(off, 'far').peak,
|
||||
'the funnel reached an anchor 100 m away — radial falloff is broken');
|
||||
});
|
||||
|
||||
test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => {
|
||||
// tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
|
||||
const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
|
||||
const without = [A('lee', 4, 0)];
|
||||
const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||||
const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||||
// strict: delete envelope.js's setSheltersFromTrees call and these read equal
|
||||
assert(sheltered.peak < open.peak,
|
||||
`the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`);
|
||||
});
|
||||
|
||||
test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
|
||||
// identical position, identical wind — only the hint differs (E's beam 0.22
|
||||
// vs a clean post at 1.0). The wind-side prediction must rank the beam first.
|
||||
const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
|
||||
const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
assert(rows[0].id === 'beam',
|
||||
`equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
|
||||
assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
|
||||
'paPerHint is not peakPa/hint — the ranking formula drifted');
|
||||
assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
|
||||
`effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
|
||||
});
|
||||
|
||||
test('envelope: peaks land inside the storm and doses are finite and positive', () => {
|
||||
const anchors = [A('a', -3, 1), A('b', 6, -2)];
|
||||
const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
for (const r of rows) {
|
||||
assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
|
||||
assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
|
||||
// flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted
|
||||
assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`);
|
||||
}
|
||||
});
|
||||
|
||||
export const ENVELOPE_TESTS = TESTS;
|
||||
100
web/world/dev_skyfx.html
Normal file
100
web/world/dev_skyfx.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
dev_skyfx — Lane C's storm viewer. [SPRINT12]
|
||||
|
||||
The skyfx bench: one storm, a bare ground plane, and the whole sky stack
|
||||
(dome, rain, hail, lightning, change front, audio) with a scrubbable clock.
|
||||
Exists so "does the change ANNOUNCE itself" (gate 3.4) can be judged by eye
|
||||
in ten seconds instead of playing to night 3 — D, this is for you: load the
|
||||
early buster, watch the southern sky from t=0, and say whether you'd have
|
||||
known the change was coming without reading a number.
|
||||
|
||||
dev_skyfx.html ← storm_03b_earlybuster
|
||||
dev_skyfx.html?storm=storm_03_southerly&t=12 ← night 2's slower version
|
||||
|
||||
keys: space pause · ] +5 s · [ −5 s (forward-clean; rewinds may re-flash)
|
||||
←/→ turn · click once to unlock audio
|
||||
The VIEW runs on rAF (it's a viewer); the sim is stepped at FIXED_DT
|
||||
accumulated, same as the game, so what you see is what ships.
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>dev_skyfx</title>
|
||||
<style>
|
||||
body { margin:0; background:#000; overflow:hidden; }
|
||||
#hud { position:fixed; top:10px; left:12px; color:#cde; font:12px/1.5 ui-monospace,Menlo,monospace;
|
||||
text-shadow:0 1px 2px #000; white-space:pre; pointer-events:none; }
|
||||
</style>
|
||||
<div id="hud">loading…</div>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { FIXED_DT } from './js/contracts.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const stormName = q.get('storm') || 'storm_03b_earlybuster';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 400);
|
||||
camera.position.set(0, 1.7, -4);
|
||||
let yaw = Math.PI; // start looking toward +Z — the south
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||||
sun.position.set(30, 50, -20);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.2);
|
||||
scene.add(hemi);
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2),
|
||||
new THREE.MeshLambertMaterial({ color: 0x4a5d3a }),
|
||||
);
|
||||
scene.add(ground);
|
||||
// a fence-height slab to the north, so there's a yard-ish thing in frame
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(14, 3, 1), new THREE.MeshLambertMaterial({ color: 0x8a7f70 }));
|
||||
slab.position.set(0, 1.5, -11);
|
||||
scene.add(slab);
|
||||
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
|
||||
|
||||
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') paused = !paused;
|
||||
if (e.key === ']') t = Math.min(def.duration, t + 5);
|
||||
if (e.key === '[') t = Math.max(0, t - 5);
|
||||
if (e.key === 'ArrowLeft') yaw += 0.15;
|
||||
if (e.key === 'ArrowRight') yaw -= 0.15;
|
||||
});
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
const hud = document.getElementById('hud');
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
const wall = Math.min(0.1, (now - last) / 1000); last = now;
|
||||
if (!paused && t < def.duration) {
|
||||
acc += wall;
|
||||
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
|
||||
}
|
||||
camera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
const ch = (def.events || []).find((e) => e.type === 'windchange');
|
||||
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
|
||||
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
|
||||
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
|
||||
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
@ -222,6 +222,16 @@ export class RiggingSession {
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pick's corner actually fails at, in newtons: hardware rating × the
|
||||
* anchor's ratingHint (`?? 1` mirrors sail.js — world.js guarantees the field,
|
||||
* but a session can be handed bare test anchors that never met world.js).
|
||||
*/
|
||||
_effRating(p) {
|
||||
const a = this.anchors.find((x) => x.id === p.anchorId);
|
||||
return p.hw.rating * (a?.ratingHint ?? 1);
|
||||
}
|
||||
|
||||
/** Everything the HUD needs to draw the prep panel, in one read. */
|
||||
get summary() {
|
||||
return {
|
||||
@ -232,8 +242,16 @@ export class RiggingSession {
|
||||
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
|
||||
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
|
||||
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
|
||||
// so four identical carabiners flagged whichever was clicked FIRST — the
|
||||
// label pointed at click order, not steel, and on the corner block it
|
||||
// named the genuinely worst anchor only by luck. Ratings ties still keep
|
||||
// the first pick, honestly: if the products are equal the steel really is
|
||||
// interchangeable and there is no "genuinely worst" to point at.
|
||||
weakest: this.picks.length
|
||||
? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
|
||||
? this.picks.reduce((w, p) => (this._effRating(p) < this._effRating(w) ? p : w)).anchorId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -211,6 +211,32 @@ test('summary names the weak link for the HUD', () => {
|
||||
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`;
|
||||
});
|
||||
|
||||
test("weak link is rating × ratingHint, not click order (D's #7)", () => {
|
||||
// Corner-block shape: uniform hardware, one anchor made of lies. The old
|
||||
// reduce compared bare hw.rating with strict <, so four identical carabiners
|
||||
// flagged whichever was clicked FIRST — on site_02 it pointed at cb1 only
|
||||
// because D happened to click cb1 first. The label must follow the product
|
||||
// sail.js actually fails on: hw.rating × anchor.ratingHint.
|
||||
const anchors = [
|
||||
{ id: 'q1', type: 'post', pos: { x: -3, y: 4, z: -3 }, ratingHint: 1 },
|
||||
{ id: 'q2', type: 'post', pos: { x: 3, y: 4, z: -3 }, ratingHint: 1 },
|
||||
{ id: 'q3', type: 'post', pos: { x: 3, y: 4, z: 3 }, ratingHint: 1 },
|
||||
{ id: 'cb1', type: 'carport', pos: { x: -3, y: 2.3, z: 3 }, ratingHint: 0.22 },
|
||||
].map((a) => ({ ...a, sway: () => a.pos }));
|
||||
const s = new RiggingSession({ anchors });
|
||||
// click the honest steel FIRST — the pre-fix code flags q1 here, forever
|
||||
for (const id of ['q1', 'q2', 'q3', 'cb1']) assert(s.rig(id).ok, `rig ${id} failed`);
|
||||
assert(s.summary.weakest === 'cb1',
|
||||
`four identical carabiners: the weak link is the 0.22 carport beam, got ${s.summary.weakest}`);
|
||||
// And better hardware on the lying anchor can make it genuinely NOT the weak
|
||||
// link: rated 6500 × 0.22 = 1430 N still beats carabiner 1200 × 1.0 — the
|
||||
// label follows the product, not the price tag and not the hint alone.
|
||||
s.setHardware('cb1', RATED);
|
||||
assert(s.summary.weakest === 'q1',
|
||||
`rated@0.22 (1430 N) out-rates carabiner@1.0 (1200 N) — expected q1, got ${s.summary.weakest}`);
|
||||
return 'weak link tracks rating × hint through hardware changes';
|
||||
});
|
||||
|
||||
test('reset() returns a used session to a fresh prep phase', () => {
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
|
||||
@ -863,12 +863,27 @@ export class SailRig {
|
||||
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
/**
|
||||
* Ported from the prototype: 0.4 s sustained over the rating and it lets go.
|
||||
*
|
||||
* SPRINT12 — the threshold is the ANCHOR's, not just the hardware's:
|
||||
* `hw.rating * anchor.ratingHint`. A's ruling (THREADS sprint 11, "THE
|
||||
* RATINGS ARE REAL. WIRE THEM."): DESIGN's fascia/carport lie is an ANCHOR
|
||||
* failing, and before this line every anchor was exactly as strong as the
|
||||
* carabiner you hung off it — E's baked 0.22/0.30/0.35 were read by nothing,
|
||||
* and the lever that decided whether the trap fired was tension, not steel.
|
||||
* Read LIVE from c.anchor each check (not captured at attach): dress() adopts
|
||||
* GLB hints by MUTATING the anchor objects in place, and a prep-time rig must
|
||||
* see the dressed number, not whatever was there when attach() ran.
|
||||
* The `?? 1` is belt-and-braces — world.js guarantees a finite hint on every
|
||||
* anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating *
|
||||
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
|
||||
*/
|
||||
_checkFailure(dt) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
if (c.load > c.hw.rating) c.overload += dt;
|
||||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
|
||||
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
|
||||
if (c.overload > OVERLOAD_SECS) {
|
||||
c.broken = true;
|
||||
|
||||
@ -901,6 +901,119 @@ test('ponding: rain that the router swallows cannot silently pass', () => {
|
||||
return 'no rain API -> no pond (and Lane A asserts the router keeps it)';
|
||||
});
|
||||
|
||||
// --- SPRINT12: THE RATINGS ARE REAL (A's ruling, THREADS sprint 11) ---------
|
||||
// sail.js fails a corner on load > hw.rating * anchor.ratingHint. This test
|
||||
// asserts the CONSEQUENCE, not the formula: on the corner block, at max
|
||||
// tension, through the funnel, the carport blows before an honest post — the
|
||||
// yard's whole thesis, which D measured to be UNENFORCED before the wiring
|
||||
// (at any tension the sim had no reason to prefer cb over q; the lever that
|
||||
// decided the trap was tension, not steel).
|
||||
|
||||
const STORM_03B = await loadStormDef('storm_03b_earlybuster');
|
||||
|
||||
/**
|
||||
* site_02_corner_block, DRESSED — dumped live from world.anchors after dress()
|
||||
* (browser, SPRINT12), same provenance as tools/site_audit's backyard dump.
|
||||
* Node cannot dress (GLTFLoader + fetch), and the graybox positions are a
|
||||
* different yard — see audit.mjs's header for the whole sermon. ratingHint is
|
||||
* E's baked number, adopted by adoptAnchor; q1..q4 are site-JSON posts at
|
||||
* world.js's DEFAULT_RATING_HINT = 1.
|
||||
*/
|
||||
const SITE2_DRESSED = [
|
||||
['tr1', 'tree', 6.833, 2.992, 0.154, 1.0], ['tr1b', 'tree', 7.142, 3.764, 0.043, 0.88],
|
||||
['q1', 'post', -3.953, 3.937, -2.824, 1.0], ['q2', 'post', 3.983, 4.001, -2.276, 1.0],
|
||||
['q3', 'post', 2.249, 3.991, 4.498, 1.0], ['q4', 'post', -3.334, 4.022, 4.445, 1.0],
|
||||
['cb1', 'carport', -8.41, 2.289, -3, 0.22], ['cb2', 'carport', -5.59, 2.289, -3, 0.22],
|
||||
['cp1', 'carport_post', -8.41, 1.679, -0.39, 0.3], ['cp2', 'carport_post', -8.41, 1.679, -5.61, 0.3],
|
||||
].map(([id, type, x, y, z, ratingHint]) => {
|
||||
const pos = { x, y, z };
|
||||
return { id, type, ratingHint, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
/** site_02's funnel, verbatim from the site JSON — the yard's personality. */
|
||||
const SITE2_VENTURI = [{ x: -6, z: 0, axis: 2.1, gain: 1.5, radius: 5, sharp: 3 }];
|
||||
|
||||
/** realWind() with the SITE's venturi on it, the way main.js sets it at load. */
|
||||
function site2Wind(def) {
|
||||
const field = createWindField(def);
|
||||
field.setVenturi(SITE2_VENTURI);
|
||||
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(-6, 0, t, out); return Math.hypot(out.x, out.z); },
|
||||
gustTelegraph: () => null,
|
||||
rainAt: (t) => field.rainAt(t),
|
||||
rainMmPerHour: (t) => field.rainMmPerHour(t),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D's exact carport line (cb1 cb2 q2 q3) on UNIFORM rated shackles, settled 12 s
|
||||
* on the calm day (funnel on — the gap doesn't switch off for prep, same as the
|
||||
* audit), then flown through the whole early buster. Uniform hardware ON
|
||||
* PURPOSE: the only asymmetry left is the anchors themselves, so if a cb corner
|
||||
* goes first it can only be ratingHint. Returns breaks with the rig's OWN clock
|
||||
* on them — settle is t 0..12, the storm is t 12..102.
|
||||
*/
|
||||
const flyCarportLine = (anchors, tension) => {
|
||||
const r = new SailRig({ anchors, gridN: 10, porosity: 0.30 });
|
||||
r.watchDivergence = true;
|
||||
r.attach(['cb1', 'cb2', 'q2', 'q3'], Array(4).fill(HARDWARE[2]), tension);
|
||||
const breaks = [];
|
||||
r.events.on('break', (e) => breaks.push({ id: e.anchorId, t: e.t }));
|
||||
const calm = site2Wind(STORM_01), storm = site2Wind(STORM_03B);
|
||||
for (let i = 0, n = Math.round(12 / SIM_DT); i < n; i++) r.step(SIM_DT, calm, (i * SIM_DT) % 3);
|
||||
const SETTLE_END = r.t;
|
||||
for (let i = 0; i < Math.round(STORM_03B.duration / SIM_DT); i++) r.step(SIM_DT, storm, i * SIM_DT);
|
||||
return { breaks, SETTLE_END };
|
||||
};
|
||||
|
||||
test('ruling: on site_02 the CARPORT blows before an honest post — at DEFAULT tension', () => {
|
||||
// The half D's cold pass proved missing: pre-wiring, at tension 1.0 the
|
||||
// honest post blew first and the carport cost nothing, so the lever that
|
||||
// decided the trap was tension. Now the same rig, default tension, survives
|
||||
// prep and loses the CARPORT mid-storm while both honest posts hold — the
|
||||
// anchor decides, not the dial. (Measured: cb1 lets go around t≈41 with
|
||||
// q2/q3 peaking ~1.8/2.5 kN, well under a bare 6.5 kN rated shackle.)
|
||||
const { breaks, SETTLE_END } = flyCarportLine(SITE2_DRESSED, 1.0);
|
||||
assert(breaks.length > 0,
|
||||
'nothing blew at default tension: ratingHint is not reaching _checkFailure — the trap is still tension-gated');
|
||||
const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
|
||||
const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
|
||||
assert(firstCb >= 0, `the carport never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
|
||||
assert(firstQ === -1 || firstCb < firstQ,
|
||||
`an honest post (${breaks[firstQ]?.id}) blew before the carport — the trap is pointing the wrong way`);
|
||||
assert(breaks[firstCb].t > SETTLE_END,
|
||||
`the beam let go at t=${breaks[firstCb].t.toFixed(1)}s, INSIDE the ${SETTLE_END.toFixed(0)}s prep settle — ` +
|
||||
'at default tension the trap should fire in the storm, not on the shop floor');
|
||||
return `default tension: ${breaks[firstCb].id} blew at t=${(breaks[firstCb].t - SETTLE_END).toFixed(1)}s into the storm, honest posts held`;
|
||||
});
|
||||
|
||||
test('ruling: at MAX tension the carport still goes first — and the hint is doing it', () => {
|
||||
// The sprint's literal shape (max tension through the funnel), plus the
|
||||
// control that makes both tests mean "the HINT", not "the load".
|
||||
const { breaks } = flyCarportLine(SITE2_DRESSED, 1.4); // TENSION_MAX
|
||||
const firstCb = breaks.findIndex((b) => b.id.startsWith('cb'));
|
||||
const firstQ = breaks.findIndex((b) => b.id.startsWith('q'));
|
||||
assert(firstCb >= 0, `max tension and the carport still never blew (breaks: ${breaks.map((b) => b.id).join(',')})`);
|
||||
assert(firstQ === -1 || firstCb < firstQ,
|
||||
`an honest post (${breaks[firstQ]?.id}) blew before the carport at max tension`);
|
||||
|
||||
// Identical yard, every hint forced to 1 — bare hardware ratings, the
|
||||
// pre-wiring game — must survive the same night intact: rated shackles at
|
||||
// 6.5 kN hold these loads (worst peak ~3.4 kN); only 6.5 × 0.22 = 1.43 kN
|
||||
// lets go. This is the mutation control IN the suite: unwire sail.js and
|
||||
// both tests above go red because the wired and unhinted runs collapse into
|
||||
// this one. If THIS half ever breaks, the loads moved and the cb-first
|
||||
// asserts stop isolating the wiring — re-measure before touching thresholds.
|
||||
const unhinted = flyCarportLine(
|
||||
SITE2_DRESSED.map((a) => ({ ...a, ratingHint: 1, sway: a.sway })), 1.4).breaks;
|
||||
assert(unhinted.length === 0,
|
||||
`hint-free control lost ${unhinted.map((b) => b.id).join(',')} — bare rated shackles no longer hold this line; ` +
|
||||
'the cb-first asserts are no longer attributing the break to ratingHint');
|
||||
return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
|
||||
});
|
||||
|
||||
export const SAIL_TESTS = TESTS;
|
||||
|
||||
export function runSailSelftest() {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
import { valueNoise2 , hailBlockFor } from './weather.core.js';
|
||||
import { valueNoise2 , hailBlockFor, smoothstep } from './weather.core.js';
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
@ -348,6 +348,7 @@ function createAudio(seed = 1) {
|
||||
let rainGain, rainFilter;
|
||||
let gustGain, gustFilter;
|
||||
let hailGain, hailFilter, drumGain, drumFilter;
|
||||
let frontGain, frontFilter;
|
||||
let noiseBuf = null;
|
||||
let creakNext = 0, flogNext = 0;
|
||||
let started = false;
|
||||
@ -444,6 +445,14 @@ function createAudio(seed = 1) {
|
||||
drumGain.connect(master);
|
||||
loop(noiseBuf, drumGain, drumFilter);
|
||||
|
||||
// the change front's distant rumble: very low, very slow — a storm you
|
||||
// can hear over the fence but not yet feel (SPRINT12 gate 3.4)
|
||||
frontGain = ctx.createGain(); frontGain.gain.value = 0;
|
||||
frontFilter = ctx.createBiquadFilter();
|
||||
frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85;
|
||||
frontGain.connect(master);
|
||||
loop(noiseBuf, frontGain, frontFilter);
|
||||
|
||||
started = true;
|
||||
},
|
||||
|
||||
@ -496,6 +505,13 @@ function createAudio(seed = 1) {
|
||||
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
|
||||
},
|
||||
|
||||
/** @param {number} level 0..1 change-front progress — the approaching rumble. */
|
||||
setFront(level) {
|
||||
if (!started) return;
|
||||
// slow time-constant on purpose: a front swells, it doesn't tick
|
||||
frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5);
|
||||
},
|
||||
|
||||
/** Telegraph cue: you hear it coming before you feel it. */
|
||||
whoosh(power, eta) {
|
||||
if (!started) return;
|
||||
@ -604,6 +620,66 @@ export function createSkyFx(o = {}) {
|
||||
dome.renderOrder = -1;
|
||||
if (scene) scene.add(dome);
|
||||
|
||||
// ------------------------------------------------------ the change front
|
||||
// SPRINT12 gate 3.4 — "the change announces itself." Every windchange event
|
||||
// gets an IN-WORLD tell: a dark front wall on the horizon, standing in the
|
||||
// quarter the new wind will come FROM, rising from a smudge to a wall across
|
||||
// the FRONT_LEAD seconds before the change and clearing overhead after it.
|
||||
// A real southerly buster looks exactly like this — the shelf cloud arrives
|
||||
// before the wind does — so the tell is weather, not UI.
|
||||
//
|
||||
// Why this telegraphs without spoiling: it's a continuous growth with no
|
||||
// discrete pop, so a player watching the YARD learns "that wall means the
|
||||
// swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) — and on
|
||||
// night 3 the SAME wall is already standing at t≈6, which is the corner
|
||||
// block's whole thesis ("looks like night 2 and isn't") made visible. The
|
||||
// exact moment stays the storm's secret; the direction and the approach do
|
||||
// not, because in the real sky they never are.
|
||||
//
|
||||
// Deterministic off (dt, t) like everything here: progress is a pure
|
||||
// function of t, and the wall's azimuth comes from dirAt() sampled at a
|
||||
// fixed post-change instant — def + seed in, same wall out, every run.
|
||||
const FRONT_LEAD = 12; // s before the change the wall starts rising
|
||||
const FRONT_FADE = 8; // s after the swing completes for it to clear
|
||||
const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°)
|
||||
const FRONT_MAX_OP = 0.85;
|
||||
const frontEvents = (def.events || [])
|
||||
.filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
|
||||
.map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
|
||||
// Band from ~34° above the horizon down to it, centred (in local space) on
|
||||
// azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at
|
||||
// atan2(z,x) = π. rotation.y = π − A then carries the centre to azimuth A.
|
||||
const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
|
||||
{
|
||||
// Soft edges via vertex alpha, or the band reads as a floating rectangle
|
||||
// (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly
|
||||
// that). Full at the horizon and the arc's centre; fading to nothing at
|
||||
// the arc ends and across the top, so it sits ON the horizon like a bank
|
||||
// of weather instead of hanging in the sky like a screen.
|
||||
// Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
|
||||
// horizon edge.
|
||||
const uv = frontGeo.attributes.uv;
|
||||
const rgba = new Float32Array(uv.count * 4);
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
|
||||
const up = 1 - smoothstep(0.45, 1, uv.getY(i));
|
||||
rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
|
||||
rgba[i * 4 + 3] = across * up;
|
||||
}
|
||||
frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4));
|
||||
}
|
||||
const frontMesh = new THREE.Mesh(
|
||||
frontGeo,
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true,
|
||||
opacity: 0, depthWrite: false, fog: false,
|
||||
}),
|
||||
);
|
||||
frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay
|
||||
frontMesh.visible = false;
|
||||
if (scene) scene.add(frontMesh);
|
||||
let frontLevel = 0, frontRise = 0, frontAz = null;
|
||||
|
||||
// Remember what world.js handed us, so dispose() puts it back exactly.
|
||||
// Fog is captured BY VALUE, not by reference: step() mutates that very object
|
||||
// in place, so `scene.fog = original.fog` restores the object we just spent a
|
||||
@ -636,10 +712,15 @@ export function createSkyFx(o = {}) {
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, audio, dome, shadow, hailShadow,
|
||||
rain, audio, dome, shadow, hailShadow, front: frontMesh,
|
||||
get flash() { return flash; },
|
||||
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
|
||||
get hailAmount() { return hailAmt; },
|
||||
/** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */
|
||||
get changeFront() { return frontLevel; },
|
||||
/** Azimuth (radians, XZ) the front stands in — the quarter the new wind
|
||||
* comes FROM — or null while no front is up. For D's judging and asserts. */
|
||||
get changeFrontAz() { return frontAz; },
|
||||
|
||||
/**
|
||||
* 0..1 of a ground rect the sail is keeping dry, right now.
|
||||
@ -809,6 +890,23 @@ export function createSkyFx(o = {}) {
|
||||
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
|
||||
}
|
||||
|
||||
// --- the change front: progress is pure in t; the view applies it below.
|
||||
// Computed above the render gate so a headless HUD (or a test) can read
|
||||
// changeFront the same way it reads hailAmount.
|
||||
frontLevel = 0; frontRise = 0; frontAz = null;
|
||||
for (const ev of frontEvents) {
|
||||
const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t);
|
||||
const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t));
|
||||
if (lvl > frontLevel) {
|
||||
// the settled post-change heading, sampled at a FIXED instant so the
|
||||
// wall doesn't wander with the gusts; +π = the quarter it comes FROM
|
||||
if (ev.az == null && typeof wind.dirAt === 'function') {
|
||||
ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI;
|
||||
}
|
||||
frontLevel = lvl; frontRise = rise; frontAz = ev.az;
|
||||
}
|
||||
}
|
||||
|
||||
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
|
||||
// gale to hear. All of it needs somewhere to stand. A headless harness has
|
||||
// the numbers it came for and can stop here.
|
||||
@ -848,6 +946,19 @@ export function createSkyFx(o = {}) {
|
||||
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
// --- the front wall (progress computed above the render gate) ---
|
||||
// Grows upward as it rises (scale.y keys the RISE, so it keeps its full
|
||||
// height while fading through the overhead pass), darkens as it comes.
|
||||
// Deliberately a silhouette: no flash tint — lightning brightens the sky
|
||||
// BEHIND it, which is what makes a shelf cloud read as a wall.
|
||||
frontMesh.visible = frontLevel > 0.002;
|
||||
if (frontMesh.visible) {
|
||||
frontMesh.position.copy(camPos);
|
||||
if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz;
|
||||
frontMesh.scale.y = 0.3 + 0.7 * frontRise;
|
||||
frontMesh.material.opacity = frontLevel * FRONT_MAX_OP;
|
||||
}
|
||||
|
||||
// --- rain + hail drops (the grids they read were built above) ---
|
||||
rain.step(dt, camPos, w, intensity, shadow);
|
||||
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
|
||||
@ -858,6 +969,9 @@ export function createSkyFx(o = {}) {
|
||||
// --- audio ---
|
||||
audio.setLevels(speed, intensity);
|
||||
audio.setHail(hailAmt, onCloth, stone);
|
||||
// the front carries its own distant rumble — you HEAR the change coming
|
||||
// the way you see it, and both are the same pure function of t
|
||||
audio.setFront(frontLevel);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
if (tg && tg !== lastTelegraph) {
|
||||
// fires once per gust, right as the telegraph opens
|
||||
@ -872,7 +986,11 @@ export function createSkyFx(o = {}) {
|
||||
let worst = 0, broken = false;
|
||||
for (const c of sail.corners) {
|
||||
if (c.broken) { broken = true; continue; }
|
||||
const rating = c.hw && c.hw.rating ? c.hw.rating : 1;
|
||||
// Effective rating is hardware × anchor hint (SPRINT12, B's wiring):
|
||||
// a carport corner fails at 0.22× its shackle, and a creak keyed on
|
||||
// the shackle alone would go quiet exactly where the steel is worst.
|
||||
const hint = c.anchor && c.anchor.ratingHint != null ? c.anchor.ratingHint : 1;
|
||||
const rating = (c.hw && c.hw.rating ? c.hw.rating : 1) * hint;
|
||||
worst = Math.max(worst, c.load / rating);
|
||||
}
|
||||
audio.creak(dt, worst);
|
||||
@ -886,6 +1004,7 @@ export function createSkyFx(o = {}) {
|
||||
scene.remove(rain.mesh);
|
||||
scene.remove(hail.mesh);
|
||||
scene.remove(dome);
|
||||
scene.remove(frontMesh);
|
||||
scene.background = original.background;
|
||||
if (ownsFog) {
|
||||
scene.fog = null; // we brought it; we take it
|
||||
@ -902,6 +1021,8 @@ export function createSkyFx(o = {}) {
|
||||
hail.dispose();
|
||||
dome.geometry.dispose();
|
||||
dome.material.dispose();
|
||||
frontMesh.geometry.dispose();
|
||||
frontMesh.material.dispose();
|
||||
domeTex.dispose();
|
||||
audio.dispose();
|
||||
},
|
||||
|
||||
@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
||||
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||
@ -41,6 +42,11 @@ export default async function run(t) {
|
||||
const { cases } = weatherCases(storms);
|
||||
for (const c of cases) t.test(c.name, c.fn);
|
||||
|
||||
// --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
|
||||
// Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
|
||||
// B's audit must not be the thing that drifts. See envelope.selftest.js.
|
||||
for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
|
||||
|
||||
// --- the bits that need the THREE adapter, not just the core ---
|
||||
|
||||
t.test('weather.js satisfies the wind contract', () => {
|
||||
@ -271,6 +277,92 @@ export default async function run(t) {
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
// --- SPRINT12 gate 3.4: the change announces itself ---
|
||||
// The corner block's thesis is "looks like night 2 and isn't" — storm_03b's
|
||||
// change lands at 18 s, not 30. The tell is a dark front wall standing in the
|
||||
// quarter the new wind comes FROM, rising across the 12 s before the change
|
||||
// and clearing after the swing. These asserts pin: the window (nothing before
|
||||
// the lead opens, gone after the fade), the rise (monotonic, reaches ~full),
|
||||
// the DIRECTION (the wall stands in the southern sky — +Z, the open fence
|
||||
// side — and the mesh really is rotated there), and that a changeless storm
|
||||
// never raises it. D judges whether it READS; this pins that it exists,
|
||||
// where it stands, and when.
|
||||
t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => {
|
||||
const scene = new THREE.Scene();
|
||||
const wind = createWind(storms.storm_03b_earlybuster);
|
||||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||||
const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange');
|
||||
const t0 = ev.t, over = ev.over ?? 6; // 18, 6
|
||||
|
||||
let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false;
|
||||
fixedLoop(40, FIXED_DT, (dt, time) => {
|
||||
sky.step(dt, time, {});
|
||||
const f = sky.changeFront;
|
||||
assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`);
|
||||
if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`);
|
||||
if (time > t0 - 12 && time <= t0) {
|
||||
assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} → ${f.toFixed(3)} at t=${time.toFixed(2)}`);
|
||||
prev = f;
|
||||
if (f > peak) peak = f;
|
||||
}
|
||||
if (Math.abs(time - t0) < FIXED_DT) {
|
||||
azAtChange = sky.changeFrontAz;
|
||||
rotAtChange = sky.front.rotation.y;
|
||||
opAtChange = sky.front.material.opacity;
|
||||
visAtChange = sky.front.visible;
|
||||
}
|
||||
if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`);
|
||||
});
|
||||
|
||||
assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`);
|
||||
assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`);
|
||||
|
||||
// direction: the wall stands where the new wind comes FROM. dirAt is the
|
||||
// heading the wind blows TOWARD, so from = settled post-change dir + π.
|
||||
const expected = wind.dirAt(t0 + over + 4) + Math.PI;
|
||||
assert(Math.abs(azAtChange - expected) < 1e-9,
|
||||
`front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`);
|
||||
// and that quarter is the SOUTH: the buster blows toward -Z (the house),
|
||||
// so it comes from +Z, the open fence side. sin(az) is the from-vector's z.
|
||||
assert(Math.sin(azAtChange) > 0.3,
|
||||
`the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`);
|
||||
// the mesh is really rotated there (local band centre sits at azimuth π,
|
||||
// so rotation.y = π − az carries it to az; see skyfx geometry note)
|
||||
assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9,
|
||||
`wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`);
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => {
|
||||
// storm_03 (night 2): same wall, later — up but not full at t=20 (change 30)
|
||||
const mk = (name) => createSkyFx({
|
||||
scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
|
||||
wind: createWind(storms[name]),
|
||||
});
|
||||
const a = mk('storm_03_southerly'), b = mk('storm_03_southerly');
|
||||
let mid = 0;
|
||||
fixedLoop(21, FIXED_DT, (dt, time) => {
|
||||
a.step(dt, time, {}); b.step(dt, time, {});
|
||||
// determinism: two instances at the same t agree to the bit — no hidden
|
||||
// clock, no per-instance random draw in the tell
|
||||
assert(a.changeFront === b.changeFront,
|
||||
`two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`);
|
||||
mid = a.changeFront;
|
||||
});
|
||||
assert(mid > 0.05 && mid < 0.95,
|
||||
`night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`);
|
||||
a.dispose(); b.dispose();
|
||||
|
||||
// the gentle day has no windchange: no wall, ever
|
||||
const calm = mk('storm_01_gentle');
|
||||
fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => {
|
||||
calm.step(dt, time, {});
|
||||
assert(calm.changeFront === 0 && !calm.front.visible,
|
||||
`a changeless storm raised the front at t=${time.toFixed(2)}`);
|
||||
});
|
||||
calm.dispose();
|
||||
});
|
||||
|
||||
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
|
||||
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
|
||||
// because honest rain walks under a sail. Steep hail doesn't — a sail over the
|
||||
|
||||
@ -58,6 +58,9 @@ const ASSETS = [
|
||||
{ name: 'house_yardside', h: [2.5, 3.5],
|
||||
nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
|
||||
'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
|
||||
{ name: 'house_yardside_wrecked', h: [2.5, 3.5],
|
||||
nodes: ['wall', 'door', 'window', 'roof', 'fascia_torn',
|
||||
'gutter_torn', 'gutter_down', 'downpipe_loose', 'debris_fascia'] },
|
||||
{ name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
|
||||
{ name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
|
||||
{ name: 'garden_bed', h: [0.5, 1.1],
|
||||
@ -178,6 +181,93 @@ export default async function run(t) {
|
||||
'wrecked carport must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// The gutter, priced — the carport ruling's second application (SPRINT12
|
||||
// §gate 3.3). backyard_01's fascia anchors have said collateral:"gutter"
|
||||
// since Sprint 6 and nobody ever priced one, so collateralFor('gutter')
|
||||
// returns null and the house is a FREE failure. The proposal is 90 and the
|
||||
// reasoning lives beside GUTTER_COLLATERAL in build_yard_assets.py; what THIS
|
||||
// pins is the calibration, not the digit: the gutter must cost more than the
|
||||
// ornament and less than the structure, because the fascia (0.35) is honestly
|
||||
// better steel than the carport beam (0.22) and the tutorial yard's trap must
|
||||
// cost you a night, not the week. A rules the number; if it moves, move it in
|
||||
// the factory and re-export — do not let site JSON and the GLB tell two prices.
|
||||
t.test('the gutter is priced, inside the band, and keyed to what the anchors say', () => {
|
||||
const h = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside');
|
||||
assert(h, 'house_yardside root missing');
|
||||
const cost = h.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', `house carries no collateral_value (${JSON.stringify(h.userData)})`);
|
||||
// collateral_key exists because, unlike the carport, the structure and the
|
||||
// thing you take have different names: the key says WHICH collateral string
|
||||
// this value prices, and it must be the one the fascia anchors carry or
|
||||
// Lane A's wiring prices nothing.
|
||||
assert(h.userData?.collateral_key === 'gutter',
|
||||
`collateral_key is ${JSON.stringify(h.userData?.collateral_key)} — must name the anchors' collateral string`);
|
||||
const a1 = loaded.get('house_yardside')?.scene.getObjectByName('fascia_anchor_01');
|
||||
assert(a1?.userData?.collateral === h.userData.collateral_key,
|
||||
`fascia anchor says collateral=${JSON.stringify(a1?.userData?.collateral)} but the price is keyed ${JSON.stringify(h.userData.collateral_key)} — the chain is broken`);
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene
|
||||
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
|
||||
const carport = loaded.get('carport_01')?.scene
|
||||
.getObjectByName('carport_01')?.userData?.collateral_value;
|
||||
assert(cost > gnome, `gutter (${cost}) must cost more than the gnome (${gnome}) — structural collateral beats ornament`);
|
||||
assert(cost < carport, `gutter (${cost}) must cost less than the carport (${carport}) — the backyard's trap is the gentler one`);
|
||||
// Wreckage carries the same price, so scoring can read either state.
|
||||
const w = loaded.get('house_yardside_wrecked')?.scene.getObjectByName('house_yardside_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked house must price its gutter the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'house_yardside',
|
||||
'wrecked house must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// The wreck's whole message is vertical: the gutter that was UP at the eave is
|
||||
// DOWN. Both halves are measured, and the intact half is the negative control
|
||||
// that proves the measurement measures — if someone rebuilds the wreck with
|
||||
// the run back at the eave (or flips the export axis), gutter_down's box
|
||||
// lands ~2.6 m up and this goes red. Verified red once already: built with
|
||||
// the ground run at eave height as a negative control, this failed at
|
||||
// "tops out at y=2.68" (1 failed / 319 passed) while the Blender verify said
|
||||
// [PASS] 9.46 x 2.10 x 2.92 on the same bad file — the run's PLAN position
|
||||
// didn't move, so no box span changed. A bounding box cannot answer "did it
|
||||
// fall" unless something pins which height it fell FROM.
|
||||
t.test('the torn gutter is DOWN in the wreck, UP in the intact house', () => {
|
||||
const up = loaded.get('house_yardside')?.scene.getObjectByName('gutter');
|
||||
assert(up, 'intact house lost its gutter node');
|
||||
const upBox = new THREE.Box3().setFromObject(up);
|
||||
assert(upBox.max.y > 2.5,
|
||||
`intact gutter tops out at y=${upBox.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
|
||||
|
||||
const wreck = loaded.get('house_yardside_wrecked');
|
||||
assert(wreck, 'house_yardside_wrecked did not load');
|
||||
const down = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_down'));
|
||||
assert(down.max.y < 0.5,
|
||||
`gutter_down tops out at y=${down.max.y.toFixed(2)} — the run is not on the grass`);
|
||||
const hang = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_torn'));
|
||||
assert(hang.max.y > 2.3,
|
||||
`gutter_torn tops out at y=${hang.max.y.toFixed(2)} — it should still reach the eave it tore from`);
|
||||
assert(hang.min.y < 1.6,
|
||||
`gutter_torn bottoms out at y=${hang.min.y.toFixed(2)} — the torn end should sag well below the fascia line`);
|
||||
|
||||
// And the house itself did NOT fall: unlike the carport, this wreck keeps
|
||||
// its full height, because only the eave line failed. A shorter (or taller)
|
||||
// wreck here means someone wrecked the building instead of the gutter.
|
||||
const hi = new THREE.Box3().setFromObject(loaded.get('house_yardside').scene);
|
||||
const hw = new THREE.Box3().setFromObject(wreck.scene);
|
||||
const dh = Math.abs((hi.max.y - hi.min.y) - (hw.max.y - hw.min.y));
|
||||
assert(dh < 0.05, `wreck height differs from the house by ${dh.toFixed(3)} m — a house does not fall when its gutter does`);
|
||||
});
|
||||
|
||||
// You cannot re-tie to a ripped eave. An anchor that outlives its fascia
|
||||
// would be the free-failure bug back again, wearing a wreck for a costume —
|
||||
// world.anchors would keep offering the tie-off after the aftermath swap.
|
||||
t.test('no fascia anchor survives into the wreck', () => {
|
||||
const w = loaded.get('house_yardside_wrecked');
|
||||
assert(w, 'house_yardside_wrecked did not load');
|
||||
for (const n of ['fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03']) {
|
||||
assert(!w.scene.getObjectByName(n),
|
||||
`${n} survives in the wreck — the eave it anchored is gone, so it must be too`);
|
||||
}
|
||||
});
|
||||
|
||||
// A wreck that stands taller than the thing it was is a bug, not a wreck.
|
||||
t.test('the wrecked carport is shorter than the carport', () => {
|
||||
const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
|
||||
@ -400,7 +490,8 @@ export default async function run(t) {
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_snapped']]) {
|
||||
['fence_panel', 'fence_panel_snapped'],
|
||||
['house_yardside', 'house_yardside_wrecked']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
|
||||
Binary file not shown.
BIN
web/world/models/house_yardside_wrecked_v1.glb
Normal file
BIN
web/world/models/house_yardside_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user