Compare commits

...

17 Commits

Author SHA1 Message Date
type-two
688e821fcb Sprint 12 integration: merge all lanes (335/0/0), skyfx effective-rating creak, [I] entry 2026-07-17 21:17:45 +10:00
type-two
4dce82268b Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
28ef4ef138 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
3df0d0c5d0 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
aff0020148 Merge remote-tracking branch 'origin/lane/e'
# Conflicts:
#	THREADS.md
2026-07-17 21:13:57 +10:00
type-two
ec45e7b465 THREADS: Sprint 12 replay — trap fires at default tension, ladder trip live, weak-link honest, C's wall judged; fuse-line and night-2 cover discrepancies flagged 2026-07-17 21:12:33 +10:00
type-two
38b6fe9c87 Rule the gutter $90 (E's proposal adopted); teach collateralFor/wreckStructure the house; action B's effective-rating flag
Gate 3.3: collateralFor('gutter') returns {90, 'the gutter'} — canonical in
backyard_01.json beside the reasoning, GLB extras as fallback, per E's
collateralKey chain. House wreck wired on the carport's swap contract
(no-op until lane/e's GLB merges; window_glow state copied). site_02 stays
null: no house, no gutter. hud.js bars + main.js verdict now read
rating × ratingHint, matching sail.js's failure line (B's flag).
selftest 319/0/0.
2026-07-17 20:45:49 +10:00
type-two
807de80969 Wire the kid's bike: site key + loader, gnome pattern; decline the $60
Gate 3.2: bike_kid_01_v1 on backyard_01's south fence, rotYDeg 180 so the
baked -Z lean lands ON the fence — bars measured to the rail (0.286 m
reach, z 9.69 -> grip at 9.98), tyres 0.31 m clear, between posts. Unpriced
by ruling: the sim can't knock a bike over, and billing an unseen event is
the lie the invoice kills; the windward-fence pose is the honest one.
a.test pins lean direction, standoff band, and the ruling. 318/0/0.
2026-07-17 20:34:47 +10:00
type-two
3d89ce8fe8 Paste E's letterhead/invoice kit into hud.js; rule HARD YARDS + sequential ABN
Gate 3.1: the CUT block from tools/jobsheet verbatim — letterhead, billto,
brief, sched, .void, AMOUNT DUE, terms. Docket numbers shared across the
pair (JOB/INV 1040+night); week.js nights carry an addr. Cards scroll when
taller than the window (safe center + overflow). Both rulings adopted and
pinned by DOM asserts; selftest 317/0/0.
2026-07-17 20:27:18 +10:00
type-two
c9ca211a51 THREADS: first cross-harness readings vs B's wiring — prediction 1 confirmed, hints bit-identical, timing false-alarm pre-empted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:26:56 +10:00
type-two
f9ce210218 THREADS: gate 3.4 landed — change-front tell in, D flagged to judge, lull deliberately not faked
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:25:25 +10:00
type-two
e5c0e3ffee skyfx: the change announces itself — a front wall in the sky (gate 3.4)
Every windchange event gets an in-world tell: a dark bank standing on the
horizon in the quarter the new wind comes FROM, rising across the 12 s
before the change and clearing after the swing, plus a low distant rumble
layer in the audio. Soft-edged via vertex alpha (the hard band read as a
floating rectangle on the bench). Deterministic off (dt, t): progress is a
pure function of t, azimuth comes from dirAt at a fixed post-change
instant. Night 2 teaches the tell at ~18 s; night 3 shows the same wall at
t=6, which is 'looks like night 2 and isn't' made visible.

dev_skyfx.html: the skyfx bench — one storm, bare ground, scrubbable
clock, so the tell can be judged by eye without playing to night 3.
Two new asserts in c.test.js (window, monotonic rise, direction, mesh
orientation, changeless storm stays clear, bit-exact determinism).
Selftest 322/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:24:51 +10:00
type-two
a52e2377fb THREADS: gutter price proposal ($90) for A's ruling, wreck contract + wiring seam, negative-control receipt, ratings position for B/C's audit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:24:17 +10:00
type-two
b7f065cd57 e.test: pin the gutter price band, the DOWN/UP swap truth, and anchor absence in the wreck
Five new asserts (Lane E 67 -> 72; selftest 315 -> 320):
- price exists, sits between gnome and carport, keyed to the anchors'
  collateral string, wreck carries the same bill and names its twin
- the torn gutter is DOWN in the wreck and UP in the intact house — negative
  control run: rebuilt with the ground run at eave height, selftest went red
  ('gutter_down tops out at y=2.68', 1 failed / 319 passed) while the Blender
  verify PASSed the same bad file (plan position unmoved, no box span changed);
  restored build round-tripped to identical hashes
- wreck height equals house height (a house does not fall when its gutter does)
- no fascia_anchor_* survives into the wreck
- house pair added to the shared ground-plane test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:21:40 +10:00
type-two
4d1a2d7aa9 Gutter priced and wrecked: GUTTER_COLLATERAL=90 baked with reasoning; torn-gutter house wreck, carport pattern
The fascia anchors have said collateral:"gutter" since Sprint 6 and nobody
priced one — collateralFor('gutter') returns null, backyard_01's house is a
free failure. Proposal rides in the asset like the carport's did: 90, between
the gnome (25) and the carport (180), with the band reasoning next to the
constant. A rules the number.

house_yardside_wrecked_v1: same origin/footprint/facade (shared _house_facade
helper — intact house BIN chunk byte-identical to the committed GLB, only the
three new price extras differ), fascia torn through the anchored span, left
run hanging by its last bracket, right run kinked ON the grass, downpipe out
of plumb, offcuts flat on the lawn. No fascia_anchor_* empties survive — you
cannot re-tie to a ripped eave.

collateral_key is new and explicit: unlike the carport, the structure and the
thing you take have different names, so the key names which collateral string
the value prices.

All 33 GLBs byte-identical across two full factory runs; the house pair also
byte-identical between --only and full runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:21:24 +10:00
type-two
375e7a29ce THREADS: storm-side failure envelope posted (gate 2.4) — week measured, predictions for B's re-audit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:12:11 +10:00
type-two
8208c80d78 storm_envelope: the wind side of the failure envelope, per anchor (gate 2.4)
Second harness on B's post-ratingHint audit: measures what the STORM
delivers at every dressed anchor — peak speedAt, tPeak, dynamic pressure
(downdraft folded back in), dose, and Pa/hint (the wind-side failure-order
prediction under load > rating x ratingHint). No cloth, no rig, no tension:
independent by construction.

Browser front-end dresses the yard the way the game does (createWorld +
dress) and wires venturi + tree shelters exactly as main.js:446-447 does.
envelope.selftest.js wired into c.test.js, every assert written to fail if
a wiring line is deleted (sweep.selftest's pattern). Selftest 320/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:10:17 +10:00
20 changed files with 1934 additions and 93 deletions

View File

@ -3967,6 +3967,318 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
gone: the week is playable end to end.** §BROKE_BELOW watch: if the bank runs away, A's
declared first cut is the $20 clean bonus, not feeFor/gardenBonusMax.
[A] 2026-07-17 — 📄 **E's letterhead/invoice kit is PASTED (SPRINT12 gate 3.1), and both rulings are made.**
The CUT block went into hud.js verbatim — CSS and markup shape both — replacing my Sprint 11
placeholder rules that were holding the seat for exactly this. `.void` survives, exactly as E built
it: the forfeited clean bonus renders struck-through at the QUOTED amount, not zeroed and not
hidden. Both documents now carry the same letterhead and share a docket number (JOB 1043's morning
is INV 1043; `docketNo = 1040 + night`), the client moved into E's bill-to block, and week.js grew
an `addr` per night ("14 Kurrajong St — the backyard" / "2 Bight Rd — the corner block") because a
letterhead bills somebody somewhere. AMOUNT DUE landed with it: pay spent, 20px, red when
negative — the invoice finally has a conclusion, and the bank line under it stays because "what the
night was worth" and "where that leaves you" are different facts. Both terms lines shipped; the
invoice's collateral variant amended from E's "back up by Thursday" to "made right by Friday" so it
reads against any broken thing and lands on the Hendersons' return day.
**RULING — the masthead is HARD YARDS, adopted as proposed.** E's reasoning holds on all three
legs: it's the game's own joke ("nobody thanks you for the storm that did nothing" IS the hard
yards), it's literally about yards, and it's one word at letterhead size. Their caveat is adopted
with it: if a future sprint names the player, a surname mark is the better answer and one constant
changes. Not renaming the outfit in a juice pass either.
**RULING — the ABN stays sequential, and this is now pinned by an assert.** Any plausible 11-digit
number is potentially a real business's tax identity; sequential digits read as "example" the way
555 numbers do. Deliberate, not lazy — a.test now FAILS if someone "fixes" it realistic.
Two things the paste taught, both logged as warnings elsewhere and both ignored by me anyway:
· **The card CSS is a template literal and I put backticks in a comment** ("engines that don't
know `safe`"). node --check PASSED — the stray pair re-paired with a later literal into
accidentally-valid JS — and the browser's import of hud.js died on "Unexpected identifier
'safe'", which took boot down with it. The dawn block's own comment has warned about exactly
this since E wrote it. Caught by the browser, not the linter; `node -e "import(...)"` is the
honest check and --check is not.
· **E's kit made the tall cards taller than a short window** — at 800×450 the RIG IT button was
below the fold of an unscrollable overlay, a soft-lock with good typography. #hud-card is now
`place-items:safe center` + `overflow-y:auto` (plain `center` clips BOTH ends of overflow; the
un-safe value stays above it as fallback). Seen in the pane, invisible to every assert.
Verified by LOOKING, per the standing rule: job sheet night 1 and the carport invoice both rendered
in the browser against E's four preview PNGs — letterhead, bill-to, struck $20, red $130 due,
terms lines, all there. Plus two new a.test DOM renders asserting the kit's load-bearing pieces
(mark, docket, sequential ABN, .void at quoted amount WITH line-through actually drawing, due
arithmetic, neg class) — because both of last sprint's card bugs were invisible to a suite that
never rendered a card. hud.dispose() also stops leaking the dawn div. selftest **317/0/0**
(315 + the two card renders).
[A] 2026-07-17 — 🚲 **The bike is WIRED (SPRINT12 gate 3.2), and the $60 is ruled: DECLINED, for E's own
reason.** site-JSON key + loader line, the gnome pattern exactly — `bike` is a named top-level key
in backyard_01, world.js loads it in the same Promise.all and drops it at `heightAt`. Not in
solids, not billed.
**Placement is measured, not reasoned** (the numbers live in the site JSON's `_why`): I parsed the
GLB in node before placing anything — the bars' grip reaches 0.286 m past the tyre-contact line
toward the baked lean, so at rotYDeg 180 (local Z → global +Z, per E's exporter note, read FIRST
as instructed) and z = 9.69, the grip lands on the south fence rail's inner face at 9.98 and the
tyres stand 0.31 m clear. x = 7 sits between fence posts so bars meet rail, not post. Verified by
LOOKING: walked the player over in the browser — it reads as a kid's bike leaning on the palings,
step-through diagonal doing the work E said it would. New a.test pins the placement side of E's
axis trap: rotYDeg must point the baked lean at the fence (dot > 0.95) and the standoff must stay
in the bars-touch band — drag it mid-yard or flip the rotation and the suite reddens with the
right sentence.
**RULING — no collateralValue, and an assert enforces the reasoning.** E's framing decides it:
"a bike that ignores that gust is a worse lie than no bike." Priced, the lie gets WORSE — the sim
cannot knock a bike over, so the $60 would bill for an event the player never sees, which is the
exact species of unearned consequence the invoice exists to kill (the carport bills because the
roof visibly leaves). The placement is the mitigation: leaning on the WINDWARD fence is the one
pose a southerly can't falsify — the gust pins it harder into the palings, so standing still is
physically honest in every storm the backyard hosts. The tripwire assert fails if anyone prices
the bike without building the fall, and says so: build the fall first, then bring the $60 back —
it's the right number (under the gnome-to-carport span, and the one loss the client's kid would
notice).
selftest **318/0/0** (317 + the placement pin). Landing pushed.
[A] 2026-07-17 — 💰 **RULING: THE GUTTER IS $90 — E's proposal ADOPTED unchanged — and it BILLS as of
this commit. `collateralFor('gutter')` stops returning null (SPRINT12 gate 3.3 closed on A's
side).** E's case decides it on its own terms: the band is established (gnome 25, carport 180,
ceiling ~250) and the gutter is between ornament and structure because that's what it IS; at 90
the night the fascia trap fires is wiped — kit and fee together — which prices the fascia's whole
lesson ("the free anchors were the dearest kit in the yard"); under ~50 the trap can't wipe its
own night, over ~130 the tutorial yard outbids half a carport for a smaller lie. The full
reasoning is in backyard_01.json's `_collateral`, beside the number, the carport pattern exactly.
**The wiring seam E named, closed by TEACHING, not restructuring.** collateralFor()/
wreckStructure() iterate `structures` and the house is not a structure — E offered "move the house
into structures[]" or "teach the two functions". Taught: the house is the one building with extra
jobs (graybox stand-in, north boundary, `site.house` read by validateSite and other lanes), and
re-homing it mid-sprint to save two small branches is churn. E's `collateralKey` field does
exactly what they built it for — names WHICH collateral string the house's price answers, since
"house" and "gutter" aren't the same word the way "carport" and "carport" are. Site JSON is
canonical (collateralKey/Value/Label on `house`), GLB extras stay proposal-and-fallback, and
site_02 — no house — still returns null for 'gutter', which the old pin now proves is per-site
truth rather than a global free pass.
· `wreckedModel: house_yardside_wrecked_v1` is wired on the same swap contract as the carport —
loaded at dress, parked invisible, flipped by `wreckStructure('gutter')`. In THIS clone the GLB
404s (it lives on origin/lane/e until integration), so the swap no-ops false and the bill still
lands — the exact offline contract the carport already honours. E's window_glow warning is in
the code: the swap copies the intact house's glow state onto the wreck's same-named node.
· scoreRun needed NOTHING: its generic loop (anchor.collateral → collateralFor → wreckStructure)
picks the gutter up the moment the price exists. The mechanism was always one price short.
🔧 **B's under-read flag, actioned same-day in my two files** (skyfx.js is C's): hud.js's corner
bars and label now draw off the EFFECTIVE rating (`hw.rating × (anchor.ratingHint ?? 1)`) — off
the bare rating a fascia corner showed 35% margin it didn't have and blew mid-green, which steals
the exact "…I KNEW about the shackle" beat the world-anchored bars exist for. main.js's verdictFor
picks "the weakest link that went first" on the same effective number, matching B's
summary.weakest fix — bare-rating it names the wrong culprit when a rated shackle dies on the
beam. One expression, B's exact shape, credited.
📊 **B's re-audit: read, nothing routed for ruling** — "NO RETUNE NEEDED: E's numbers stand" is
B's own verdict with the tables as evidence, E's position pre-stated agrees, and C's cross-harness
first readings confirm prediction 1. The system worked without me, which is the system working.
Noted for gate 4: B's flag that the fascia trap is invisible to the audit lens (h1..h3 cover no
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.00.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).
@ -4058,3 +4370,144 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
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 14 + 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.41.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.**

View 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 F1SmoothF1 Voronoi write-up
especially is gold.

View File

@ -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": []
}

View File

@ -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

View 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>

View 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: ½ρ··(1+downFrac²) the (1+) folds
* the downdraft back in, since cloth feels the full vector while
* speedAt deliberately reads horizontal-only
* dose dt over the whole storm, /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 };
}

View 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;

View File

@ -22,6 +22,24 @@
"house": {
"model": "house_yardside_v1",
"wreckedModel": "house_yardside_wrecked_v1",
"collateralKey": "gutter",
"collateralValue": 90,
"collateralLabel": "the gutter",
"_collateral": [
"SPRINT12 gate 3.3 - A's ruling: $90, E's proposal ADOPTED unchanged. The band is established",
"(gnome 25, carport 180, ceiling ~250) and the gutter goes between ornament and structure",
"because that's what it is. At 90 the night the fascia trap fires is WIPED - kit (~80) and fee",
"(~76 on the wild night) together - which is the fascia's whole lesson priced: the 'free'",
"anchors were the dearest kit in the yard. Under ~50 it couldn't wipe its own night; over ~130",
"the tutorial yard outbids half a carport for a smaller lie (fascia 0.35 is honestly better",
"steel than the beam's 0.22). First trap a player can hit costs them a night, not the week.",
"The house is NOT in structures[] - it keeps its own loader block (graybox, north boundary,",
"bed shading) - so collateralKey names which collateral string this price answers, per E's",
"chain: fascia anchors carry collateral 'gutter', this prices it, the wreck swaps on it.",
"Canonical number lives HERE (sites are data); the GLB's baked extras are E's proposal and",
"the fallback. Rule a different number -> change the factory too, or two sources tell two lies."
],
"x": 0, "z": -10.5,
"anchors": [
{ "id": "h1", "node": "fascia_anchor_01", "type": "house", "work": "bracket" },
@ -87,6 +105,24 @@
"gnome": { "model": "garden_gnome_01_v1", "x": 4.3, "z": 4.4, "rotYDeg": -126, "collateralValue": 25 },
"bike": {
"model": "bike_kid_01_v1", "x": -7, "z": 9.69, "rotYDeg": 180,
"_why": [
"The Hendersons' kid's bike, against the south fence. THE LEAN IS BAKED INTO THE GLB and it",
"leans toward local -Z (E's THREADS note; e.test pins it) - a bike doesn't stand up on its own.",
"rotYDeg 180 turns that lean into global +Z, INTO the fence. Any placement that doesn't point",
"the lean at something to lean ON reads as a physics bug and isn't one.",
"z is MEASURED, not reasoned: the bars' grip reaches 0.286 m past the origin (the tyre contact",
"line) toward the lean, and the fence rails sit at z = 10 +/- 0.02, so 9.69 + 0.286 = 9.98 =",
"the rail's inner face - bars touch the fence, tyres stand 0.31 m clear. x = -7 sits between",
"fence posts (-7.5 and -5), so the grip meets rail, not post.",
"NO collateralValue, BY RULING (THREADS [A] sprint 12): the sim cannot knock a bike over, and",
"billing $60 for an event the player never sees is the kind of lie the invoice exists to kill.",
"Leaning on the windward fence is the one pose a southerly can't falsify - the gust pins it",
"harder. Price it the day it can fall."
]
},
"fence": {
"sides": ["south", "west", "east"]
},

100
web/world/dev_skyfx.html Normal file
View 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>

View File

@ -20,6 +20,33 @@ import { forecastLines, leadFor } from './weather.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
/**
* SPRINT12 gate 3 the letterhead, and the two rulings E left with it
* (tools/jobsheet/index.html; full reasoning in THREADS [A]).
*
* RULING 1 the masthead is HARD YARDS, adopted as proposed. It's the joke the
* game already tells ("nobody thanks you for the storm that did nothing") said
* as a business name, it's literally about yards, and it's one mark at
* letterhead size. E's own caveat stands with it: the day the player gets a
* name, a surname mark is the better answer, and this constant is the only
* thing that changes.
*
* RULING 2 the ABN stays sequential, exactly as E shipped it. Any plausible
* 11-digit number is potentially a REAL business's tax identity, and we are not
* printing a stranger's ABN on a game card. Sequential digits read as "example"
* the way 555 phone numbers do deliberate, not lazy. DO NOT "fix" this into
* something realistic; that is the bug, not the polish.
*/
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
const ABN = 'ABN 12 345 678 901';
/**
* Docket numbers. 1040 + night: a running count off a small outfit's pad, and
* the job sheet and its invoice share the number JOB 1043's morning is
* INV 1043, which is how the two documents answer each other line for line.
*/
const docketNo = (night) => 1040 + night;
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
@ -49,11 +76,19 @@ const CSS = `
color:#93a6b2; opacity:.85; white-space:nowrap; }
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
/* SPRINT12: E's letterhead + brief + conditions made the tall cards taller
than a short window, and a RIG IT button below the fold with no scroll is a
soft-lock with good typography. 'safe center' keeps the top reachable when
the card overflows (plain 'center' clips BOTH ends); the value above is the
fallback for engines that don't know 'safe'. Seen, not reasoned: the button
was gone at 800x450. (And no backticks in this comment this whole CSS
block is a JS template literal, as the dawn block already warns.) */
place-items:safe center; overflow-y:auto;
background:#060a0dc4; backdrop-filter:blur(3px);
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; }
padding:22px 26px; margin:20px 0; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
@ -84,22 +119,126 @@ const CSS = `
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }
/* SPRINT11 the job sheet and the invoice. STRUCTURE ONLY, deliberately: the
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and
everything below is the plainest thing that reads correctly so E can restyle
it without unpicking layout. The class names are the contract .letterhead,
.brief, .jobsheet, .tomorrow and E owns what they look like. */
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d;
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; }
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; }
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a;
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; }
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .jobsheet .row b { color:#a8f0b8; }
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .jobsheet .row.total b { color:#fff; }
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */
/* SPRINT12 E's letterhead/invoice kit, pasted from tools/jobsheet/index.html
(between the CUT markers, verbatim). Sprint 11's placeholder rules for
.letterhead/.brief are gone E designed against those class names and this
is the styling they were holding the seat for. E's design commentary kept:
the reasoning is why the rules look like this. */
/* --- the letterhead ----------------------------------------------------
One business, two documents. This is the whole trick of the sprint: the
job sheet and the invoice carry the SAME letterhead, so they read as a
pair of papers off the same outfit's printer rather than as two game
screens. The player's business is the constant; the client changes every
night. That's the framing DESIGN.md opens with "you run a small
landscaping outfit" finally showing up on the glass.
Sits above the existing .card content, inside the same .card box. */
#hud-card .letterhead {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
border-bottom: 2px solid #2f3f4a;
}
#hud-card .letterhead .mark {
font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
white-space: nowrap;
}
/* The trade line does the work a logo would. Lowercase and spaced out so it
reads as printed rule, not as a heading competing with the mark. */
#hud-card .letterhead .trade {
font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
}
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
#hud-card .letterhead .docket .kind {
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
}
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
/* --- bill-to / job-for block -------------------------------------------
Deliberately the same component on both documents. On the job sheet it's
who you're working for; on the invoice it's who's paying. Same box, and
the fact that it doesn't move between the two is the joke landing. */
#hud-card .billto { margin: 0 0 12px; }
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }
/* --- the brief ---------------------------------------------------------
The client's own words. Italic and rule-marked because it is the one bit
of text on the card that isn't the game talking to the player — it's a
person who wants their seedlings alive. It should feel quoted, and it
should be the thing the player's eye lands on after the letterhead,
because it is the only place the stakes are ever said out loud. */
#hud-card .brief {
margin: 0 0 14px; padding: 9px 0 9px 12px;
border-left: 2px solid #3f5561;
color: #c3d0d9; font-style: italic; line-height: 1.6;
}
#hud-card .brief .attrib {
display: block; margin-top: 5px; font-style: normal;
font-size: 10px; letter-spacing: .1em; color: #55677a;
}
/* --- section rules ------------------------------------------------------ */
#hud-card .sect {
margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
}
/* --- the pay schedule (job sheet) --------------------------------------
This is a QUOTE, not a receipt: it's what the night is worth if you do it
right. It reuses your .row shape so it lines up with everything else, but
the money is dimmed these are amounts you have NOT earned yet, and they
should not read with the same confidence as the bank line. The conditions
are the point; the numbers are just what the conditions are worth. */
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
#hud-card .sched .cond {
font-size: 10px; color: #55677a; letter-spacing: .02em;
padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
}
#hud-card .sched .row.headline b { color: #fff; }
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
border-top: 1px solid #24343d; }
#hud-card .sched .row.max span { color: #8ba0ad; }
#hud-card .sched .row.max b { color: #ffd9a3; }
/* --- the invoice -------------------------------------------------------
Your .ledger already IS an invoice; it just never said so. These rules sit
ON TOP of it paste them and .ledger keeps every behaviour it has, it
just gains a rule above the total and a due line under it.
.earned/.bad you already colour. The one thing I've added is .void: a
bonus that was on the job sheet and did NOT survive the night. Struck
through, not hidden. The player must see the money they didn't get sitting
in the same column as the money they did a bonus you never see fail is a
bonus you never learn to protect. This is the single highest-value line in
the whole handover, and it's four CSS declarations. */
#hud-card .ledger .row.void span { color: #55677a; }
#hud-card .ledger .row.void b {
color: #55677a; font-weight: 400; text-decoration: line-through;
text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
}
/* The amount due. An invoice's whole rhetorical shape is that everything
above is argument and this is the conclusion. Give it the weight. */
#hud-card .ledger .row.due {
margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
align-items: baseline;
}
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
font-size: 11px; }
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
#hud-card .ledger .row.due.neg b { color: #ff8f86; }
/* The terms line. Pure flavour, and the cheapest joke on the card: a real
invoice's most boring sentence, printed under a night that nearly killed
you. Left in as its own class so you can delete it in one line if it
doesn't land. */
#hud-card .terms {
margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
}
/* ---- end of E's kit ----------------------------------------------------- */
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
font-size:12px; color:#6d8494; }
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
@ -349,14 +488,22 @@ export function createHud(d) {
const dist = _p.distanceTo(_cam);
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
const frac = clamp01((c.load || 0) / c.hw.rating);
// SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
// rating × ratingHint, because that's the number sail.js fails on now.
// Off the bare rating a fascia corner showed 35% margin it didn't have
// and blew mid-green — the HUD's one job at that moment is the "…the
// shackle, I KNEW about the shackle" beat, and a bar that lies about
// the margin steals it. The label prints the same effective number:
// load-vs-what-it-breaks-at, one truth, both places.
const effRating = c.hw.rating * (c.anchor?.ratingHint ?? 1);
const frac = clamp01((c.load || 0) / effRating);
b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
b.fill.material.color.setHex(barColor(frac, c.broken));
if (redraw) {
const text = c.broken
? `${c.anchorId.toUpperCase()} BLOWN`
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(c.hw.rating / 1000).toFixed(1)}kN`;
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(effRating / 1000).toFixed(1)}kN`;
if (text !== b.lastText) {
b.lastText = text;
drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
@ -406,11 +553,28 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// else. The client and the brief are data (week.js NIGHTS), so an
// unbriefed night degrades to the old storm card rather than an empty
// letterhead — which is also what keeps every existing test honest.
//
// SPRINT12 — the letterhead is E's kit now: the outfit's mark on the
// left, the docket on the right, the client demoted to a bill-to block —
// because the business is the constant and the client changes nightly.
const job = wk.job ?? {};
const letterhead = job.client
? `<div class="letterhead">${job.client}</div>` : '';
const letterhead = job.client ? `
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">JOB SHEET</div>
<div class="no">JOB ${docketNo(wk.night)} · ${ABN}</div>
</div>
</div>
<div class="billto">
<div class="who">${job.client}</div>
${job.addr ? `<div class="addr">${job.addr}</div>` : ''}
</div>` : '';
const brief = job.brief
? `<p class="brief">${job.brief}</p>` : '';
? `<div class="brief">${job.brief}</div>` : '';
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
// existed since Sprint 8 and the player has never seen it until the money
@ -418,13 +582,22 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// was worth, which is a reveal, not a decision. Quoting the maxima up front
// makes the shop a judgement ("is $45 of garden bonus worth a $30
// shackle?") instead of a guess.
// E's schedule shape: each amount carries its CONDITION underneath,
// because the conditions are the point — the numbers are just what the
// conditions are worth. The garden's condition says "scales", honestly:
// E's preview copy read "bed above 80% at dawn" and the mechanic pays
// linearly, and a quote that misstates its own terms is a lie on paper.
const q = wk.quote;
const schedule = q ? `
<div class="jobsheet">
<div class="row"><span>base the job</span><b>$${q.base}</b></div>
<div class="row"><span>garden bonus if the bed lives</span><b>up to $${q.garden}</b></div>
<div class="row"><span>clean bonus nothing of theirs broken</span><b>$${q.clean}</b></div>
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div>
<div class="sect">WHAT IT PAYS</div>
<div class="sched">
<div class="row headline"><span>callout fee</span><b>$${q.base}</b></div>
<div class="cond">the night, rigged and stood up</div>
<div class="row"><span>garden bonus</span><b>up to $${q.garden}</b></div>
<div class="cond">paid on what's left of the bed at dawn</div>
<div class="row"><span>clean site</span><b>$${q.clean}</b></div>
<div class="cond">nothing of theirs broken</div>
<div class="row max"><span>the night, done right</span><b>$${q.total}</b></div>
</div>` : '';
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
@ -447,13 +620,18 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
</div>` : '';
// E's document order: letterhead, who it's for, their words, THEN the
// night — the paper establishes whose job this is before it says what the
// weather will do about it. The terms line under the button is E's
// cheapest joke and their best: quoted on the forecast, weather is not a
// variation, printed above a night that may cost you a carport.
card.innerHTML = `<div class="card jobcard">
${letterhead}
${brief}
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine}
${brief}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
@ -469,6 +647,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
A change means the corners that were slack all storm are the loaded ones after it.
</div>` : ''}
<button class="go">RIG IT</button>
${job.client ? '<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>' : ''}
</div>`;
card.classList.add('on');
hud.setVisible(false);
@ -557,18 +736,30 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
? (r.collateral ?? []).map((c) => `
<div class="row bad"><span>${c.what}</span><b>$${c.cost}</b></div>`).join('')
: '';
// SPRINT12 — E's .void: the forfeited bonus is STRUCK THROUGH, not zeroed.
// The quoted $${cleanMax} stays on the paper with a line through it — the
// player must see the money they didn't get sitting in the same column as
// the money they did. A bonus you never see fail is a bonus you never
// learn to protect. (E's words; the single highest-value line in the kit.)
const cleanRow = w ? (w.clean
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>`
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : '';
? `<div class="row"><span>clean site</span><b>+$${w.clean}</b></div>`
: `<div class="row void"><span>clean site</span><b>$${w.cleanMax ?? 0}</b></div>`) : '';
// AMOUNT DUE is what tonight's paper actually says: everything above it is
// argument, this is the conclusion. pay spent, so it goes negative the
// night you take the carport with you — and the bank line under it stays,
// because "what this night was worth" and "where that leaves you" are two
// different facts.
const due = w ? w.pay - w.spent : 0;
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
${cleanRow}
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${collateralRows}
<div class="row"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row bad"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row due ${due < 0 ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${due < 0 ? '' : '+'}$${Math.abs(due)}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
</div>` : '';
@ -577,14 +768,35 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
: w.outcome === 'win' ? 'SEE THE WEEK'
: 'SEE THE DAMAGE';
// The same letterhead as the job sheet, same docket number, and the kind
// flips to TAX INVOICE — one outfit, two papers, one night apart. The
// terms line: E's flavour when your failure broke something of theirs,
// the world's most boring sentence otherwise.
const letterhead = w?.client ? `
<div class="letterhead">
<div>
<div class="mark">${BUSINESS.mark}</div>
<div class="trade">${BUSINESS.trade}</div>
</div>
<div class="docket">
<div class="kind">TAX INVOICE</div>
<div class="no">INV ${docketNo(w.night)} · ${ABN}</div>
</div>
</div>
<div class="billto">
<div class="who">${w.client}</div>
${w.addr ? `<div class="addr">${w.addr}</div>` : ''}
</div>` : '';
card.innerHTML = `<div class="card jobcard">
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
${letterhead}
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
<h2>${r.subtitle}</h2>
${rows}
${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
<button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`;
card.classList.add('on');
hud.setVisible(false);
@ -600,7 +812,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
get cardOpen() { return card.classList.contains('on'); },
dispose() {
root.remove(); card.remove(); style.remove();
root.remove(); card.remove(); style.remove(); dawn.remove();
d.scene.remove(barGroup);
quad.dispose();
for (const b of bars) {

View File

@ -162,8 +162,15 @@ function createGarden(world) {
* @returns {{verdict: string, mode: string}}
*/
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) {
// SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on
// the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure
// line and B's summary.weakest tie-break fix. On the bare rating a rated
// shackle on the carport beam (6.5 × 0.22 = 1.43 kN effective) out-ranks a
// carabiner on honest steel and the verdict names the wrong culprit — this
// sentence exists to teach which steel lied.
const eff = (c) => c.hw.rating * (c.anchor?.ratingHint ?? 1);
const worst = lost.length
? lost.reduce((a, c) => (a && a.hw.rating <= c.hw.rating ? a : c))
? lost.reduce((a, c) => (a && eff(a) <= eff(c) ? a : c))
: null;
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
const hailKilled = dmg.hail > dmg.rain;

View File

@ -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();
},

View File

@ -12,6 +12,7 @@ import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor }
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
import { createHud } from '../hud.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@ -124,6 +125,7 @@ export default async function run(t) {
for (let i = 0; i < NIGHTS.length; i++) {
const j = nightAt(i);
assert(j.client, `night ${i + 1} has a client`);
assert(j.addr, `night ${i + 1} has an address — a letterhead bills somebody somewhere`);
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
assert(j.site, `night ${i + 1} has a yard`);
}
@ -143,6 +145,7 @@ export default async function run(t) {
// or printing "undefined" at a client.
const j = nightAt(99); // off the end entirely
assertEq(j.client, null, 'no client is null, not undefined');
assertEq(j.addr, null, 'no address is null');
assertEq(j.brief, null, 'no brief is null');
assertEq(j.site, 'backyard_01', 'and it still names a yard');
});
@ -264,6 +267,113 @@ export default async function run(t) {
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
});
// --- SPRINT12 gate 3: E's letterhead/invoice kit, rendered ----------------
// Both of Sprint 11's card bugs were invisible to the suite because nothing
// ever RENDERED a card and looked. These do the minimum of that in DOM: the
// kit's contract classes must exist on a real render AND their load-bearing
// CSS must actually draw. Eyes on the browser are still required — a DOM
// assert can't see a letterhead with buttons through it.
t.test('SPRINT12: the invoice wears the letterhead, and the dead bonus is struck through', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
// Night 3, the carport night: collateral forfeits the clean bonus, so this
// one render exercises the void, the neg AMOUNT DUE, and the docket number.
const wk = createWeek();
wk.advance(); wk.advance();
const w = wk.settle({ hp: 86, win: true,
collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 12 }, def, 71);
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showAftermath({ hp: 86, cornersLost: 1, cornersTotal: 4, hailBlocked: 'some hail',
bill: 34, collateral: [{ what: 'the carport', cost: 180 }],
subtitle: 'test', verdict: 'test', win: true, week: w }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', "the outfit's mark (ruling: adopted)");
const no = q('.letterhead .docket .no')?.textContent ?? '';
assert(no.includes('INV 1043'), `docket says INV 1043, got "${no}"`);
// The ruling is that the ABN STAYS sequential — it reads as "example" the
// way 555 numbers do, and a realistic one is a stranger's tax identity.
assert(no.includes('ABN 12 345 678 901'), `the sequential ABN survives, got "${no}"`);
assertEq(q('.billto .who')?.textContent, 'the Vasilaros place', 'the bill-to block names the client');
// E's .void — quoted, not paid, and still on the paper.
const voidB = q('.ledger .row.void b');
assert(voidB, 'the forfeited clean bonus renders as .void, not as $0 or missing');
assertEq(voidB.textContent, `$${w.cleanMax}`, 'struck at the QUOTED amount');
assert(getComputedStyle(voidB).textDecorationLine.includes('line-through'),
'and the strike actually draws — the class without its CSS is decoration');
// AMOUNT DUE is the conclusion: pay spent, negative the night you take
// the carport with you, and it must SAY so.
const due = w.pay - 71;
const dueB = q('.ledger .row.due b');
assert(dueB, 'the invoice concludes with AMOUNT DUE');
assertEq(dueB.textContent, `${due < 0 ? '' : '+'}$${Math.abs(due)}`, 'due = pay spent');
assertEq(q('.ledger .row.due')?.classList.contains('neg'), due < 0, 'a negative due reads as one');
} finally { hud.dispose(); }
});
t.test('SPRINT12: the job sheet carries the same letterhead, a quote, and the terms line', () => {
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
// The same outfit, the same night, the other document: JOB 1043 in the
// morning becomes INV 1043 — the pair answering each other is the design.
const wk = createWeek();
wk.advance(); wk.advance();
const hud = createHud({ scene: new THREE.Scene() });
try {
hud.showForecast(
{ key: 'storm_03b_earlybuster', def: earlyBusterDef, site: null, tomorrowDef: null },
{ night: wk.night, nights: wk.nights, log: [], job: wk.job,
quote: wk.quote(earlyBusterDef), bank: wk.bank }, () => {});
const q = (sel) => document.querySelector(`#hud-card ${sel}`);
assertEq(q('.letterhead .mark')?.textContent, 'HARD YARDS', 'same mark as the invoice');
assertEq(q('.letterhead .docket .kind')?.textContent, 'JOB SHEET', 'but this paper is the quote');
assert((q('.letterhead .docket .no')?.textContent ?? '').includes('JOB 1043'),
'and it shares the docket number with its invoice');
assert(q('.billto .addr'), 'the bill-to block says where the job is');
assertEq(document.querySelectorAll('#hud-card .sched .cond').length, 3,
'three pay lines, each carrying its condition');
assert((q('.terms')?.textContent ?? '').includes('not a variation'),
"the terms line: quoted on the forecast, weather is not a variation");
} finally { hud.dispose(); }
});
t.test("SPRINT12: the kid's bike leans INTO the south fence, not into open air", () => {
// E's axis trap, pinned from the PLACEMENT side. e.test proves the GLB
// leans toward local Z; nothing until now proved the site points that lean
// at anything. Un-rotate the bike or drag it mid-yard and this reddens —
// the failure mode is a bike leaning on air, which reads as a physics bug
// and would get chased in the wrong lane (E's words, their lean note).
const b = site.bike;
assert(b, 'backyard_01 places the bike');
assertEq(b.model, 'bike_kid_01_v1', "E's model, the v1 with the baked lean");
// Local Z rotated by rotY must land on +Z — the south fence side.
const ry = (b.rotYDeg * Math.PI) / 180;
const leanTowardFence = -Math.cos(ry); // z of (0,0,1) rotated about Y
assert(leanTowardFence > 0.95,
`rotYDeg ${b.rotYDeg} points the baked lean at z=${leanTowardFence.toFixed(2)} — not the fence`);
// The standoff, measured: bars reach 0.286 m past the tyre line toward the
// lean; the fence rails sit at z = depth/2 ± 0.02. The band accepts
// bars-on-rail ± a hand's width, and rejects mid-yard or tyres-in-fence.
const gap = site.yard.depth / 2 - b.z;
assert(gap > 0.15 && gap < 0.45,
`bike stands ${gap.toFixed(2)} m off the fence line — bars touch at ~0.31`);
// RULING (SPRINT12 gate 3.2): E's $60 is DECLINED until the sim can knock a
// bike over. Billing collateral for an event the player never sees is the
// lie the invoice exists to kill; leaning on the windward fence is the one
// pose a southerly can't falsify. This assert is the ruling's tripwire: if
// someone prices the bike, they must also build the fall, and this message
// is where they find that out.
assert(b.collateralValue == null, 'the bike is unpriced BY RULING — build the fall first');
});
// --- SPRINT11 gate 1: the three rulings, each pinned ----------------------
t.test('ruling: the carport is typed as a carport, not smuggled in as a post', () => {
@ -376,6 +486,27 @@ export default async function run(t) {
assertEq(site2World.collateralFor(undefined), null, 'and no key is not a bill');
});
t.test('SPRINT12 ruling: the gutter BILLS — 90, and the free failure is dead', () => {
// E priced it ($90, reasoning baked in the asset and adopted in the site
// JSON's _collateral) and named the seam: the house is not a structure, so
// collateralFor/wreckStructure had to be taught the house entry. This is
// the assert E left me to flip: backyard_01's house was the last free
// failure in the game.
const priced = world.collateralFor('gutter');
assert(priced, 'the gutter has a price at all — the null era ends here');
assertEq(priced.cost, 90, "A's ruling: 90 — wipes the trap's night (kit + fee), not the week");
assertEq(priced.label, 'the gutter', 'and reads as English on the invoice');
// The band that makes the number legible: ornament < gutter < structure.
assert(site.gnome.collateralValue < priced.cost && priced.cost < 180,
'gnome 25 < gutter 90 < carport 180');
// The key prices THIS site's house, not a global constant: the corner
// block has no house, so its 'gutter' stays null (pinned above).
// And nothing is wrecked before anyone breaks anything. (The wreck GLB
// arrives from lane/e at integration; absent, the swap no-ops false and
// the bill still lands — same offline contract as the carport.)
assertEq(world.isWrecked('gutter'), false, 'the gutter starts on the house');
});
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting

View File

@ -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

View File

@ -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,

View File

@ -48,34 +48,38 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
*/
export const NIGHTS = [
// SPRINT12: `addr` is the second line of E's bill-to block — a letterhead
// bills somebody somewhere. Street names are the site's fiction (E's kit
// drafted both), and the addr carries the yard so the invoice, which has no
// site line of its own, still says where the night happened.
{
storm: 'storm_01_gentle', site: 'backyard_01',
client: 'the Hendersons',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that '
+ 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
},
{
storm: 'storm_03_southerly', site: 'backyard_01',
client: 'the Hendersons',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed '
+ 'or there\'s no bed on Monday.',
},
{
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
client: 'the Vasilaros place',
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, '
+ 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
+ 'to tie off to.',
},
{
storm: 'storm_02_wildnight', site: 'backyard_01',
client: 'the Hendersons',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
+ 'they\'re watching, because Friday they will be.',
},
{
storm: 'storm_02b_icenight', site: 'backyard_01',
client: 'the Hendersons',
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
+ 'Last night before they land.',
},
@ -99,6 +103,7 @@ export function nightAt(i) {
storm: base.storm,
site: base.site ?? 'backyard_01',
client: base.client ?? null,
addr: base.addr ?? null,
brief: base.brief ?? null,
pay: base.pay ?? {},
};
@ -347,7 +352,7 @@ export function createWeek(opts = {}) {
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
client: job.client, site: job.site };
client: job.client, addr: job.addr, site: job.site };
log.push(s);
return s;
},

View File

@ -200,6 +200,9 @@ export function createWorld(scene, opts = {}) {
const SHED = site.shed;
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
// SPRINT12: the per-client prop, the gnome pattern — a named top-level key,
// not a generic props[]. See the site JSON's _why for the lean/fence math.
const BIKE = site.bike ? { ...site.bike, rotY: rad(site.bike.rotYDeg) } : null;
const treeSpecs = site.trees ?? [];
const postSpecs = site.posts ?? [];
@ -440,6 +443,23 @@ export function createWorld(scene, opts = {}) {
// rating_hint / collateral that make it a trap. The site's own asserts (E's
// e.test) pin those ratings below the fascia's — do not "fix" them upward.
const structures = new Map();
/**
* SPRINT12 the house's collateral seam, taught rather than restructured.
* E named the trap precisely: collateralFor()/wreckStructure() iterate
* `structures`, and the house is NOT a structure so the gutter stayed
* unpriced and unswappable. The alternative was moving the house into
* structures[] (it fits the shape), but the house is the one building with
* extra jobs graybox stand-in, north boundary, the thing validateSite and
* two other lanes' code read at `site.house` and re-homing it mid-sprint
* to avoid two small branches is churn, not design. If a third house-like
* thing ever appears, THEN generalise. `collateralKey` (E's field, baked in
* the GLB, canonical in site JSON) names which collateral string this
* price answers the carport never needed it because there structure id
* and collateral string are the same word.
*/
const houseEntry = { spec: HOUSE, glb: null, wreck: null };
const houseKey = () =>
HOUSE ? (HOUSE.collateralKey ?? houseEntry.glb?.userData?.collateral_key ?? null) : null;
for (const st of site.structures ?? []) {
const marker = new THREE.Group();
marker.name = st.id;
@ -614,9 +634,10 @@ export function createWorld(scene, opts = {}) {
// Model names come off the site now. `load(null)` resolves null, so a site
// that simply has no shed (site_02 has a carport) skips it without a branch.
const [shed, table, houseGlb, bedGlb, gnome] = await Promise.all([
const [shed, table, houseGlb, bedGlb, gnome, bike] = await Promise.all([
load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
load(BIKE?.model),
]);
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
@ -642,6 +663,20 @@ export function createWorld(scene, opts = {}) {
root.add(gnome);
}
// --- the kid's bike (per-client juice, SPRINT12) ----------------------
// The lean is baked into the GLB toward local Z (E's export note — in
// Blender it leans +Y; the exporter maps (x,y,z)→(x,z,y)), so placement's
// ONE job is to point that lean at the fence. rotY comes from site data;
// the site's _why records the measured bars-to-rail arithmetic. Not in
// solids — you can walk past a kid's bike — and not billed: unpriced by
// ruling until wind can knock it over.
if (bike) {
bike.name = 'bike_kid_01';
bike.position.set(BIKE.x, heightAt(BIKE.x, BIKE.z), BIKE.z);
bike.rotation.y = BIKE.rotY;
root.add(bike);
}
// --- house (decision 6: no re-cut, the GLB's data wins) ---------------
// E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
// graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
@ -657,6 +692,22 @@ export function createWorld(scene, opts = {}) {
solids.push(houseGlb);
houseGlb.updateWorldMatrix(true, true);
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
houseEntry.glb = houseGlb;
// SPRINT12 — the torn-gutter wreck, the carport pattern on the one
// building that is not a structure. Loaded now and parked invisible for
// the same reason the carport's is: the swap happens as the storm ends,
// and an async load there drops the payoff frames after the card.
if (HOUSE.wreckedModel) {
const wreck = await load(HOUSE.wreckedModel);
if (wreck) {
wreck.name = 'house_yardside_wrecked';
wreck.position.copy(houseGlb.position);
wreck.visible = false;
root.add(wreck);
houseEntry.wreck = wreck;
}
}
}
// --- trees -----------------------------------------------------------
@ -810,6 +861,16 @@ export function createWorld(scene, opts = {}) {
if (!Number.isFinite(cost)) return null;
return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id };
}
// SPRINT12 — the gutter (A's ruling: $90, E's proposal adopted; the
// reasoning is in backyard_01.json beside the number). Same contract as
// structures: site JSON canonical, GLB extras fallback, null when
// unpriced — a site whose house declares no collateralKey still reads
// "not scored", never "free".
if (HOUSE && key === houseKey()) {
const cost = HOUSE.collateralValue ?? houseEntry.glb?.userData?.collateral_value ?? null;
if (!Number.isFinite(cost)) return null;
return { cost, label: HOUSE.collateralLabel ?? houseEntry.glb?.userData?.collateral_label ?? key };
}
return null;
},
@ -823,14 +884,30 @@ export function createWorld(scene, opts = {}) {
*/
wreckStructure(id) {
const entry = structures.get(id);
if (!entry?.wreck || !entry.glb) return false;
entry.glb.visible = false;
entry.wreck.visible = true;
return true;
if (entry?.wreck && entry.glb) {
entry.glb.visible = false;
entry.wreck.visible = true;
return true;
}
// The house, keyed by its collateral string ('gutter'): the gutter
// unzips, the house stands. E's window_glow warning honoured — the wreck
// ships its glow hidden_by_default under the same name, so the swap
// copies the intact house's current glow state or the client's lights
// would go out with the gutter if this ever lands mid-night.
if (HOUSE && id === houseKey() && houseEntry.wreck && houseEntry.glb) {
const glowWas = houseEntry.glb.getObjectByName('window_glow')?.visible ?? false;
const glow = houseEntry.wreck.getObjectByName('window_glow');
if (glow) glow.visible = glowWas;
houseEntry.glb.visible = false;
houseEntry.wreck.visible = true;
return true;
}
return false;
},
/** Is this structure standing? Lane D asked for a poke-able truth. */
isWrecked(id) {
if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true;
return structures.get(id)?.wreck?.visible === true;
},

Binary file not shown.