Compare commits
17 Commits
2b1c8cf15e
...
688e821fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688e821fcb | ||
|
|
4dce82268b | ||
|
|
28ef4ef138 | ||
|
|
3df0d0c5d0 | ||
|
|
aff0020148 | ||
|
|
ec45e7b465 | ||
|
|
38b6fe9c87 | ||
|
|
807de80969 | ||
|
|
3d89ce8fe8 | ||
|
|
c9ca211a51 | ||
|
|
f9ce210218 | ||
|
|
e5c0e3ffee | ||
|
|
a52e2377fb | ||
|
|
b7f065cd57 | ||
|
|
4d1a2d7aa9 | ||
|
|
375e7a29ce | ||
|
|
8208c80d78 |
453
THREADS.md
453
THREADS.md
@ -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.0−0.12·i ladder up the branches (build_yard_assets.py:575). Your re-audit will move tree
|
||||
quads nobody has flagged, and any retune debate with E should start from these numbers, not
|
||||
the sprint doc's. (t1 on the southerly also reads only 15.45 m/s — branch anchors shadow
|
||||
each other through setSheltersFromTrees, the same as in-game. Honest, but it means the
|
||||
t1-side quads are cheaper than the raw curve suggests.)
|
||||
|
||||
Full five-night tables are one click: `tools/storm_envelope/envelope.html` (week mode, and
|
||||
`window.__envelope` is machine-readable). If our envelopes disagree anywhere, we find the
|
||||
variable together BEFORE anyone touches E's numbers — the rule exists because both of us have
|
||||
now been the drifted harness once.
|
||||
|
||||
[C] 2026-07-17 — 🌩 **D (and A) — gate 3.4 is IN: the change announces itself. Judge it.** Every
|
||||
`windchange` event now has an in-world tell in skyfx: a dark front wall standing low on the
|
||||
horizon in the quarter the new wind comes FROM, rising from a smudge to a wall across the 12 s
|
||||
before the change, clearing overhead as the swing completes — and a distant low rumble that
|
||||
swells with it. A real buster looks like this (the shelf cloud arrives before the wind), so the
|
||||
tell is weather, not UI. No HUD text, no countdown: the exact moment stays the storm's secret,
|
||||
the direction and the approach do not, because in a real sky they never are.
|
||||
|
||||
Why it serves the corner block's thesis: night 2 (storm_03, change at 30 s) TEACHES the wall —
|
||||
it starts rising ~18 s in, southerly arrives, lesson filed. Night 3 looks like night 2 — same
|
||||
peak, same rain, same pea hail — except the SAME wall is already standing at t≈6, before you've
|
||||
tied off the second corner. "Looks like night 2 and isn't", visible from the middle of the yard
|
||||
with your hands full of rope.
|
||||
|
||||
Deterministic off (dt, t), like everything here: progress is a pure function of t; the wall's
|
||||
azimuth is dirAt(change + over + 4) + π, sampled at a fixed instant so gust wander can't swing
|
||||
the wall; two skyfx instances agree to the bit (asserted). Pinned in c.test.js: nothing before
|
||||
the lead window opens, monotonic rise to ~full at the change, gone after the fade, stands in the
|
||||
SOUTHERN sky (+Z from-vector — the open fence side), mesh really rotated there, and the gentle
|
||||
day never shows one. Selftest **322/0/0**.
|
||||
|
||||
**D, the ten-second judging rig: `web/world/dev_skyfx.html`** — the new skyfx bench (one storm,
|
||||
bare ground, scrubbable clock; `?storm=storm_03_southerly` for night 2's slower version, `[`/`]`
|
||||
to scrub, click for audio). Checked by eye there myself: the first cut's band had razor edges
|
||||
and read as a floating rectangle, so the wall now fades at its arc ends and top via vertex
|
||||
alpha and sits ON the horizon like a bank of weather. What I want your eyes on in PLAY: (a) at
|
||||
the corner block, do you LOOK SOUTH in the first ten seconds and change your rigging order —
|
||||
without being told; (b) does it spoil — does knowing feel like reading the data, or like
|
||||
reading the sky; (c) night 5 (ice night, change ~60 s per storm_02b) now shows a wall too — is
|
||||
that a bonus or noise. If it needs to be meaner/subtler the knobs are FRONT_LEAD/FRONT_MAX_OP
|
||||
in skyfx.js and they are one-line tunes.
|
||||
|
||||
(Deliberately NOT done: a fake wind lull before the change. storm_03b's baseCurve keeps rising
|
||||
through t=18, the cloth would visibly disagree with any audio hush, and faking a lull in the
|
||||
view while I'm simultaneously the second harness on the load envelope is exactly the kind of
|
||||
view-vs-physics lie this repo keeps a graveyard about. If design wants a REAL lull it's a
|
||||
baseCurve dip — storm data, B's audit and my envelope both re-run, next sprint's call, not a
|
||||
quiet Friday edit.)
|
||||
|
||||
[C] 2026-07-17 — ✅ **B — your wiring landed (9700b40); first cross-harness readings, and one
|
||||
pre-emption so nobody sounds a false alarm.** Read via origin/lane/b, nothing merged here.
|
||||
|
||||
· **Prediction 1 CONFIRMED at the qualitative level**: your in-suite assert has a cb corner
|
||||
blowing before any q corner at DEFAULT tension on the carport line, with the hint-forced-to-1
|
||||
control holding 4/4. That is exactly what the storm side said must happen (Pa/hint: all four
|
||||
carport anchors above every honest anchor). The trap now fires from the ANCHOR. D's fork is
|
||||
next — their replay, not ours.
|
||||
· **The hints agree bit-for-bit between harnesses**: your live-dumped 0.35 fascia /
|
||||
1.0 / 0.88 / 0.76 branches are exactly what my envelope read off the dressed anchors. The
|
||||
"trees are not 1.0" surprise is now double-confirmed and E should treat it as settled fact.
|
||||
· **Do NOT read your "cb blows t~29 s" against my "peaks t≈48.5-49" as a disagreement — they
|
||||
are different quantities.** Yours is the first threshold CROSSING (load first exceeds
|
||||
rating×0.22, which happens while the storm is still building); mine is when the storm's
|
||||
delivery PEAKS at that anchor. A corner that breaks at 29 never survives to feel the 49 s
|
||||
peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load
|
||||
tables on lines that HOLD — when you post the full re-audit of both yards, that's the number
|
||||
to put beside my tPeak column. If THOSE disagree, then we hunt the variable.
|
||||
[B] 2026-07-17 — ⚡ **THE RATINGS ARE WIRED. A's ruling, implemented, mutation-checked, and the trap
|
||||
now fires at DEFAULT tension.** SPRINT12 gate 2 item 1, plus D's #7. Selftest 319/0/0 (315 + 4).
|
||||
|
||||
@ -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 1–4 + the blurb, judged in play on a scratch merge of origin/lane/b +
|
||||
origin/lane/c + origin/lane/a (which had pushed the letterhead paste — judged as the bonus).
|
||||
Merged selftest **328/0/0** before I touched anything. Method as sprint 11: real boot path
|
||||
(`boot({site:'site_02_corner_block'})` — A's honoured hook, and it WORKS now, night 3 at its own
|
||||
$80 bank), real cards, real clicks into `rigging`, storm driven by `SHADES.step(1/60)` because
|
||||
the shared pane still throttles rAF dead. Nothing of mine is on lane/d this sprint but this entry.
|
||||
|
||||
**1. THE TRAP, my exact sprint-11 line (cb1 cb2 q2 q3, carabiners, tension 1.0).** Sprint 11:
|
||||
"garden 83%, +$82, THE GARDEN MADE IT." Now: **cb1 blows t=0.72, cb2 t=1.32 — in the CALM, wind
|
||||
0.0** — settle load ≈311 N against carabiner-on-beam 1200×0.22=264 N effective. q3 follows at
|
||||
t=49.6 (the funnel-alignment window), and dawn reads: collateral **−$205** (carport $180 + the
|
||||
gnome), AMOUNT DUE **−$128**, bank $80→$0, **game over.** Same clicks, opposite morning. Verdict
|
||||
as playtester: at carabiner prices this is no longer even a trap — it's a DEMONSTRATION. The
|
||||
beam sheds the cheap steel in front of you, in the quiet, with 88 s left to fix it; the $180
|
||||
lands only if you then walk away. The trap B measured (blows ~29 s, mid-storm) needs $30 rated
|
||||
steel on the beam, which is exactly right: **the beam now costs you either up front or at dawn.**
|
||||
· Fair-warning check on the temptation the shop can actually SELL: 4× rated is $120 — over the
|
||||
$80 bank, so B's uniform-rated fixture is unbuyable at night 3. The buyable temptation is
|
||||
**cb1/cb2 rated + q2/q3 carabiner = $70**, and it is a hell of a ride: q3's carabiner blew AT
|
||||
the change (t=18.07), shed the quad, and **cb1 peaked 1350 N against its 1430 N line — the
|
||||
carport held by 80 N** and paid +$147. B: your sweep should look at mixed-hardware lines —
|
||||
the cheap post corner acts as a FUSE that protects the beam, and I suspect the fuse line is
|
||||
now night 3's best net. If that's intended, it's brilliant; if unswept, it's unpriced.
|
||||
· **Max tension 1.4: three corners rip at t≈0.5 on the calm day** (q3, cb1-rated, q2 — only cb2
|
||||
survived, its load shed). B's PREP-rip confirmed and extended: at 1.4 the standing load
|
||||
(~1.4–1.5 kN/corner at 46 m²) kills every tier the shop sells except rated-on-honest. Reads
|
||||
FAIR — you watch it happen, it's physics, you have the calm to re-rig — but note the panel
|
||||
never shows standing kN at the chosen tension, so the first lesson always costs ~$60. A
|
||||
one-line "≈0.3 kN/corner standing at this tension" on the prep panel is sprint-13 material.
|
||||
· One panel honesty gap, same family as B's hud warning: the prep list prints the HARDWARE's
|
||||
kN ("cb1 carabiner 1.2 kN") — the weak-link arrow knows about hints, the printed number
|
||||
doesn't. A cold player sees four equal 1.2s and one unexplained arrow. Defensible as "you
|
||||
know your gear, not their steel" — but then the arrow is leaking the answer. Pick one story.
|
||||
|
||||
**2. THE LADDER TRIP (my #6): FIRES BY ITSELF, and the funnel gives it teeth.** Standing under
|
||||
blown cb1: *"the fascia needs the ladder — it's by the shed"* (greyed reason, interact.visible
|
||||
doing its job). Full chain exercised in the live storm: take ladder → set under cb1 → spare in
|
||||
hand (the spare is ALSO the disambiguator: empty hands at a planted ladder reads "take it back",
|
||||
carrying the spare makes E climb — that's design, not accident) → climb to reachY 4.46 →
|
||||
"re-rig corner" usable → **2.3 s into the 2.5 s hold, a funnel gust knocked me off the ladder
|
||||
(t≈36) and the spare went with me.** Bracket work at height, in the throat, timed against the
|
||||
change: the site's stated thesis, now playable. Two notes: ladder.js's label says "fascia" on a
|
||||
carport bracket — my file, my wording, one-line fix next sprint (or grab it, B/A, if you're in
|
||||
there); and where does a knockdown-dropped spare GO? It left my hands and I couldn't find it —
|
||||
if it evaporates, that's $15 vanishing without an invoice line.
|
||||
|
||||
**3. THE WEAK-LINK LABEL (my #7): HONEST IN EVERY ORDER I THREW AT IT.** q-first uniform → cb1;
|
||||
cb-first uniform → cb1; mixed rated-cb + carabiner-q clicked q-first → **q2** (1200 N effective
|
||||
beats 1430 — the label correctly ignores the scary type and points at the cheap steel on the
|
||||
honest post); tree line → **tr1b** (0.88 branch taper read live). B, the fix is real in the UI,
|
||||
not just the suite. Ties on the cb pair resolve by ring order not click order — both are 264 N,
|
||||
either is honest, no complaint.
|
||||
|
||||
**4. C'S FRONT WALL: it reads as WEATHER, and the bench is a good judging rig.** dev_skyfx.html
|
||||
face-on (the bench aims you south already — don't touch the arrows, I turned 180° and spent five
|
||||
minutes judging your prop slab; the razor-edged black rectangle in my first three screenshots
|
||||
was scenery): t=8 a smudge you'd miss with rope in your hands — correct; t=15 (0.86) a smoky
|
||||
bank with soft arc-ends and top, flat base ON the horizon — a shelf cloud, not a UI element.
|
||||
Night 2 teaches it (18→30 s), night 3 stands the same wall at 6→18: same vocabulary, earlier
|
||||
clock — "looks like night 2 and isn't", visible mid-yard. In play at t=13 in-game + the ticker's
|
||||
"that's the change already" it LANDS. Answers to your three: (a) I looked south and it changed
|
||||
my STANCE, not my rigging — by the time the wall rises you're committed; its actionable window
|
||||
is repairs, bracing, where you stand, and on night 3 that's the window that matters (see the
|
||||
knockdown above). It cannot re-order the rig, and it shouldn't — the card already prints
|
||||
"southerly change at 18s"; the sky dramatizes what the paper said, which is reading the SKY,
|
||||
not the data. Not spoiled. (b) covered. (c) ice night: the wall is buried under hail and an
|
||||
already-black sky — neither bonus nor noise, just true. Keep it. And the missing lull did NOT
|
||||
bother the cloth-watcher: the cloth stayed loaded through the change in every run, and a hush
|
||||
the cloth contradicts would have been the repo's favourite kind of lie. Right call.
|
||||
|
||||
**5. THE ECONOMY: meaner-but-fair, leaning fair.** Cheap carport lines are dead as billed
|
||||
(instant, visible, $205 if ignored), but the yard is decidable at $80: honest+tree $40, fuse
|
||||
line $70, cp1-rated line $45 (B's audit). The beam's three tiers are three different lessons —
|
||||
$5 dies at settle, $15 dies at the peak, $30 holds if a fuse pops first. That's a shop teaching,
|
||||
not gouging. Week sampled by mouse+step nights 1→3: n1 gentle banked $116 (my own greed-quad of
|
||||
156 m² snapped h2 at settle — the fascia's 0.35 bites the backyard now, and it was MY fault and
|
||||
readable), n2 southerly +$131 clean, bank $212 into night 3.
|
||||
· ⚠️ **B, one cross-harness disagreement to chase, mine vs your night-2 row:** your table says
|
||||
cheap $20 @29%, honest ≥70% needs $40. I rigged **p1p2p3p4, 40 m², 4× carabiner, $20 flat**
|
||||
and dawn paid **garden 39/45 (87% of bonus), clean $20, +$131**. Either the audit's cover%
|
||||
and the sim's bed-HP measure different things (my bet — bonus is hail-through-time, not
|
||||
geometric cover), or the flat posts quad is missing from your sweep's band. Two harnesses,
|
||||
one number, find the variable — because if $20 honestly clears night 2 in-sim, the "$30→$40"
|
||||
story is the dump's, not the game's. Receipts: my run, this entry; your table, your entry.
|
||||
· Also for B, tiny: `rig.t` never resets between nights — my night-2 break event carried
|
||||
t=696.5 (cumulative week seconds). Only bites anyone reading event.t as storm time.
|
||||
|
||||
**6. THE BLURB, on the rendered card: honest now.** HARD YARDS masthead → "the Hendersons / 14
|
||||
Kurrajong St — the backyard" → "You've worked this one for years. You know where everything is,
|
||||
which is the only advantage you get." The familiarity is the contractor's, not the owner's, and
|
||||
it sits under a client's name without lying. The old "Your own place" would have been three
|
||||
lines below the bill-to block calling the client's yard yours. A's paste judged in play, too:
|
||||
job sheet and invoice share letterhead + docket (JOB 1043 / INV 1043), the ABN reads 555-style,
|
||||
the struck-through clean $20 on the dead-bonus invoice is the best teaching line in the game,
|
||||
AMOUNT DUE −$128 in red concluded my own funeral correctly, and the card scrolls at 800×450
|
||||
(place-items:safe center verified by scrolling to RIG IT). Bonus judged: **ship it.**
|
||||
|
||||
Gate-2 line for the integrator: **D confirms the trap fires at default tension · ladder trip
|
||||
fires by itself · weak-link honest in every order · C's tell telegraphs without spoiling.**
|
||||
New sprint-13 candidates from this pass, cheapest first: ladder.js "fascia" wording on bracket
|
||||
anchors (mine) · standing-kN line on the prep panel (A/B) · panel kN column vs hint story (A+B
|
||||
ruling) · knockdown-dropped spare's whereabouts (B/D) · B sweeps mixed-hardware fuse lines.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 12 INTEGRATION (main).** All five lanes merged, THREADS-only conflicts,
|
||||
selftest **335/0/0** — again the exact per-lane sum (315 + A 4 + B 4 + C 7 + E 5). Every gate
|
||||
delivered except the one only John can: gate 1 is still his.
|
||||
|
||||
**The sprint in one line: the trap is real now, and the paperwork looks like a business.**
|
||||
B wired the ratings and re-audited both yards (verdict: no retune — E's numbers stand); C
|
||||
second-harnessed the envelope and gave the change its wall; A pasted the kit, adopted HARD
|
||||
YARDS, wired the bike, ruled the gutter at E's $90 and declined the bike's $60 with a tripwire;
|
||||
E priced the gutter and built its wreck with a real negative control; D replayed everything and
|
||||
filed the verdict that matters: fork dead, trap fires at default tension, ladder thesis playable,
|
||||
wall telegraphs without spoiling, paperwork ships.
|
||||
|
||||
**One integration edit (the leadFor precedent):** skyfx.js's creak audio read bare `hw.rating` —
|
||||
B's flag, C's file, C had already wrapped. It now reads rating × ratingHint, in-file comment says
|
||||
why: a creak keyed on the shackle alone goes quiet exactly where the steel is worst.
|
||||
|
||||
**Verified live at merge, not just green:** `collateralFor('gutter')` returns {cost:90} on the
|
||||
backyard and stays null for the carport there (both correct); E's wreck GLB + A's wiring met for
|
||||
the first time here and the plumbing holds; the bike stands at world (−7, 9.69) against the south
|
||||
fence; the card reads HARD YARDS / JOB SHEET. Zero console errors.
|
||||
|
||||
**Carried to SPRINT13's pool, none blocking (mostly D's replay finds):**
|
||||
· prep shows no standing-kN, so max-tension's prep-rip lesson always costs ~$60 blind — a
|
||||
readout is the obvious fix and it's A/B seam work;
|
||||
· mixed-hardware "fuse" lines — cheap post corners protecting the beam is either emergent
|
||||
depth or a hole; B to sweep (D's +$147 line is the exhibit);
|
||||
· cover% (audit) vs bed-HP (sim) read differently on the same $20 rig — find-the-variable
|
||||
posted B↔D, no retune till it's found;
|
||||
· `rig.t` never resets between nights (break.t=696.5 in D's log) — B's file;
|
||||
· ladder label says "fascia" on a carport bracket (D's file); the dropped spare's whereabouts
|
||||
are unknown; C's bench aims you south (noted in the bench).
|
||||
· Doc erratum: SPRINT12.md said "tree 1.0" — the branch ladder is 1.0/0.88/0.76, deliberate
|
||||
Sprint-7 intel (C flagged twice, B confirmed).
|
||||
|
||||
**Gate 4 is OPEN: Sprint 13's branch — reputation/warranty vs site_03 vs heatwave — waits on
|
||||
John's three sentences, and the game is the best argument for playing it that it has ever been.**
|
||||
|
||||
28
docs/license-request-cortiz.md
Normal file
28
docs/license-request-cortiz.md
Normal file
@ -0,0 +1,28 @@
|
||||
# License request — cortiz2894/stylized-components
|
||||
|
||||
Post at: https://github.com/cortiz2894/stylized-components/issues/new
|
||||
(or email cortiz2894@gmail.com)
|
||||
|
||||
**Title:** Add a LICENSE file so the "free code" is formally free
|
||||
|
||||
---
|
||||
|
||||
Hey Christian — the WaterFloor breakdown is fantastic, and your video
|
||||
describes this as a free resource to drop into our own projects. The repo
|
||||
doesn't currently have a LICENSE file though, which under GitHub's terms
|
||||
means it defaults to all-rights-reserved — so cautious folks (and any
|
||||
company) legally can't use it despite your clear intent.
|
||||
|
||||
Would you consider adding one? MIT is the usual pick for "use it anywhere,
|
||||
keep my copyright line" (it's what three.js itself uses). GitHub can add it
|
||||
in two clicks: Add file → choose the MIT template.
|
||||
|
||||
Same goes for your other free-resource repos (flow-shield-effect,
|
||||
hologram-particles, charged-blast-vfx, showcase-images, mouse-effects…) —
|
||||
they all say "free open-source resource" in the description, and none has a
|
||||
LICENSE file, so none of them is formally open source yet. One MIT file per
|
||||
repo and the whole collection becomes actually usable by the community you
|
||||
built it for.
|
||||
|
||||
Thanks for sharing the breakdowns — the F1−SmoothF1 Voronoi write-up
|
||||
especially is gold.
|
||||
@ -133,6 +133,31 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside_wrecked",
|
||||
"dims": [
|
||||
9.4552,
|
||||
2.1021,
|
||||
2.9172
|
||||
],
|
||||
"tris": 340,
|
||||
"nodes": [
|
||||
"debris_fascia",
|
||||
"door",
|
||||
"downpipe_loose",
|
||||
"fascia_torn",
|
||||
"gutter_down",
|
||||
"gutter_torn",
|
||||
"house_yardside_wrecked",
|
||||
"roof",
|
||||
"wall",
|
||||
"window",
|
||||
"window_glow",
|
||||
"window_light_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carport_01",
|
||||
"dims": [
|
||||
@ -468,42 +493,5 @@
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
"debris": []
|
||||
}
|
||||
@ -684,10 +684,16 @@ def build_gate(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_house_yardside(name):
|
||||
"""Rear façade only — no interior. The fascia is the point: DESIGN.md says
|
||||
it holds until the first real gust, then leaves with the gutter."""
|
||||
root = add_empty(name)
|
||||
def _house_facade(name, root):
|
||||
"""The rear façade shared VERBATIM by the intact house and its torn-gutter
|
||||
wreck — wall ring, door, window, night glow, roof. Split out in Sprint 12
|
||||
because a house does not fall down when a sail rips its eave line off (the
|
||||
carport folds; brick veneer shrugs), so the two variants have to be the SAME
|
||||
building or the aftermath swap reads as the house moving. One set of numbers,
|
||||
two eave lines.
|
||||
|
||||
Returns the measurements and materials the eave line is built from, because
|
||||
the eave is exactly the part that differs between the two callers."""
|
||||
wall_m = get_material("Mat_Render", PAL["render_wall"], 0.9)
|
||||
brick_m = get_material("Mat_Brick", PAL["brick"], 0.9)
|
||||
trim = get_material("Mat_Timber", PAL["timber_dark"], 0.8)
|
||||
@ -754,12 +760,47 @@ def build_house_yardside(name):
|
||||
root, size=0.2)
|
||||
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
|
||||
|
||||
# Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y).
|
||||
# The roof and its eave. The eave overhangs 0.55 into the yard (-Y).
|
||||
eave_y = -0.55
|
||||
fascia_z = H + 0.10
|
||||
join_group([add_box(f"{name}_roof", (W + 0.2, D + 0.75, 0.10),
|
||||
(0, (eave_y + D / 2) / 2, H + 0.05), roof_m,
|
||||
rot=(math.radians(-6), 0, 0))], "roof", root)
|
||||
return dict(W=W, H=H, D=D, eave_y=eave_y, fascia_z=fascia_z,
|
||||
trim=trim, gutter_m=gutter_m)
|
||||
|
||||
|
||||
# What it costs to take the client's gutter — and the fascia it hangs off —
|
||||
# with you. Same deal as CARPORT_COLLATERAL below, second time around: Lane A
|
||||
# owns this number, it's a design call, not an art one. But the fascia anchors
|
||||
# have said collateral:"gutter" since Sprint 6 and NOBODY has ever priced one —
|
||||
# collateralFor('gutter') returns null, so backyard_01's house is a free
|
||||
# failure: tie 25 m² to a 0.35 fascia, rip it off, pay for a shackle. My
|
||||
# proposal and the reasoning, to argue with rather than adopt:
|
||||
# · the band is established now: gnome 25 at the floor, carport 180, my
|
||||
# stated ceiling ~250. The gutter goes between the ornament and the
|
||||
# structure, because that is what it is;
|
||||
# · a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a
|
||||
# 32 m/s peak). At 90 the gutter costs slightly more than EITHER — the
|
||||
# night it fires on is wiped, kit and fee together. That is the fascia's
|
||||
# lesson in one number: the "free" anchors were the dearest kit in the yard;
|
||||
# · under ~50 it's a shrug — less than the fee alone, so the trap couldn't
|
||||
# even wipe its own night;
|
||||
# · over ~130 the backyard outbids half a carport for a smaller lie: the
|
||||
# fascia (0.35) is honestly better steel than the beam (0.22), it's three
|
||||
# anchors not four, and it's the tutorial yard — the first trap a player
|
||||
# can hit should cost them a night, not the week.
|
||||
GUTTER_COLLATERAL = 90
|
||||
|
||||
|
||||
def build_house_yardside(name):
|
||||
"""Rear façade only — no interior. The fascia is the point: DESIGN.md says
|
||||
it holds until the first real gust, then leaves with the gutter."""
|
||||
root = add_empty(name)
|
||||
f = _house_facade(name, root)
|
||||
W, H, eave_y, fascia_z = f["W"], f["H"], f["eave_y"], f["fascia_z"]
|
||||
trim, gutter_m = f["trim"], f["gutter_m"]
|
||||
|
||||
fascia = add_box("fascia", (W + 0.2, 0.035, 0.20), (0, eave_y, fascia_z),
|
||||
trim, parent=root)
|
||||
gutter = join_group([
|
||||
@ -780,6 +821,109 @@ def build_house_yardside(name):
|
||||
e["collateral"] = "gutter"
|
||||
stamp(root, name, "structure")
|
||||
root["facade_width"] = W
|
||||
# The proposal rides in the asset, exactly like the carport's did (and A
|
||||
# adopted that one into site JSON, where the canonical number now lives —
|
||||
# sites are data; this is the fallback and the argument). collateral_key is
|
||||
# NEW and explicit because the carport never needed it: there, the priced
|
||||
# thing and the structure share a name. Here the structure is "house" and
|
||||
# the thing you take is the GUTTER — the key names which collateral string
|
||||
# this value prices, so Lane A's wiring never has to guess.
|
||||
root["collateral_key"] = "gutter"
|
||||
root["collateral_value"] = GUTTER_COLLATERAL
|
||||
root["collateral_label"] = "the gutter"
|
||||
return root
|
||||
|
||||
|
||||
def build_house_yardside_wrecked(name):
|
||||
"""The house after the fascia let go (SPRINT12 §gate 3.3) — DESIGN.md's line
|
||||
made geometry: "holds until the first real gust, then rips off taking the
|
||||
gutter with it".
|
||||
|
||||
Same origin, same footprint, same façade — _house_facade, shared verbatim —
|
||||
because a house does not fall down when a sail takes its eave line; only
|
||||
what the anchors betrayed changes. The fascia is torn through the span the
|
||||
fixings held, and the gutter it carried is in two states at once: the left
|
||||
run still hangs off the surviving board by its last bracket, torn end sagged
|
||||
toward the grass, and the right run is ON the grass out in the yard, kinked
|
||||
where it landed, with the downpipe leaning out of plumb after it. A gutter
|
||||
doesn't shatter — it unzips, springs, and lands where the wind was going.
|
||||
|
||||
NO fascia_anchor_* empties survive into this file. You cannot re-tie to a
|
||||
ripped eave, and an anchor that outlived its fascia would be the free-
|
||||
failure bug back again wearing a wreck for a costume. e.test.js pins that,
|
||||
and that the gutter is DOWN here while its intact twin's is UP at the eave.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
f = _house_facade(name, root)
|
||||
W, eave_y, fascia_z = f["W"], f["eave_y"], f["fascia_z"]
|
||||
trim, gutter_m = f["trim"], f["gutter_m"]
|
||||
|
||||
# The fascia, minus what the sail took: a 3.4 m board survives on the left,
|
||||
# a stub clings to the far right corner, and both torn ends splinter past
|
||||
# the break. The gap in between is where fascia_anchor_02 used to be.
|
||||
fascia = [
|
||||
add_box(f"{name}_fascia_left", (3.4, 0.035, 0.20),
|
||||
(-2.9, eave_y, fascia_z), trim),
|
||||
add_box(f"{name}_fascia_stub", (0.6, 0.035, 0.20),
|
||||
(4.3, eave_y, fascia_z), trim),
|
||||
]
|
||||
for i, (bx, ang) in enumerate(((-1.05, 14), (3.85, -18))):
|
||||
fascia.append(add_box(f"{name}_fascia_splinter_{i}", (0.38, 0.028, 0.09),
|
||||
(bx, eave_y, fascia_z + 0.02), trim,
|
||||
rot=(0, math.radians(ang + rng.uniform(-3, 3)), 0)))
|
||||
join_group(fascia, "fascia_torn", root)
|
||||
|
||||
# The left run: still bracketed at the healthy end, torn free at the break,
|
||||
# hanging diagonally with the freed end sprung out over the grass.
|
||||
join_group([add_tube_between(
|
||||
f"{name}_gutter_hang",
|
||||
(-4.4, eave_y - 0.05, fascia_z - 0.12),
|
||||
(-0.55, eave_y - 0.34, 1.28), 0.055, gutter_m, verts=8)],
|
||||
"gutter_torn", root)
|
||||
|
||||
# The right run went where the wind was going: out over the yard, down on
|
||||
# the grass, kinked where it hit. Flat ON the grass — the carport wreck's
|
||||
# first-pass lesson (a corner sunk to z=-0.41) is why nothing here sinks.
|
||||
kx = 2.4 + rng.uniform(-0.3, 0.3)
|
||||
join_group([
|
||||
add_tube_between(f"{name}_gutter_ground_a",
|
||||
(0.35, -1.35, 0.055), (kx, -1.72, 0.055),
|
||||
0.055, gutter_m, verts=8),
|
||||
add_tube_between(f"{name}_gutter_ground_b",
|
||||
(kx, -1.72, 0.055), (4.85, -1.48, 0.055),
|
||||
0.055, gutter_m, verts=8),
|
||||
], "gutter_down", root)
|
||||
|
||||
# The downpipe keeps its bottom strap and loses the top one with the run —
|
||||
# so it leans out of plumb into the yard. Cheapest possible "this eave is
|
||||
# finished" tell: it reads from across the lawn at night.
|
||||
join_group([add_tube_between(
|
||||
f"{name}_downpipe_loose",
|
||||
(W / 2 - 0.25, eave_y - 0.05, 0.02),
|
||||
(W / 2 - 0.12, eave_y - 0.62, 2.58), 0.04, gutter_m, verts=8)],
|
||||
"downpipe_loose", root)
|
||||
|
||||
# What a ripped fascia leaves on the lawn: offcuts scattered near where the
|
||||
# run came down, every board flat on the grass.
|
||||
debris = []
|
||||
for i, (dx, dy, ln, ang) in enumerate(((1.35, -1.15, 0.85, 22),
|
||||
(3.25, -1.58, 0.55, -34),
|
||||
(2.15, -0.92, 0.34, 63))):
|
||||
debris.append(add_box(f"{name}_fascia_bit_{i}", (ln, 0.20, 0.035),
|
||||
(dx + rng.uniform(-0.08, 0.08),
|
||||
dy + rng.uniform(-0.08, 0.08), 0.0175), trim,
|
||||
rot=(0, 0, math.radians(ang + rng.uniform(-6, 6)))))
|
||||
join_group(debris, "debris_fascia", root)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "house_yardside"
|
||||
root["facade_width"] = W
|
||||
# Same price as the state it used to be, so scoring can read either —
|
||||
# the carport wreck's rule, verbatim.
|
||||
root["collateral_key"] = "gutter"
|
||||
root["collateral_value"] = GUTTER_COLLATERAL
|
||||
root["collateral_label"] = "the gutter"
|
||||
return root
|
||||
|
||||
|
||||
@ -2277,6 +2421,15 @@ ASSETS = [
|
||||
nodes=["wall", "door", "window", "roof", "fascia", "gutter",
|
||||
"window_glow", "window_light_anchor",
|
||||
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
|
||||
# The torn-gutter aftermath (SPRINT12 §gate 3.3). Same height as the intact
|
||||
# house — a house doesn't fall, only its eave line does — but DEEPER (y),
|
||||
# because the right gutter run and the fascia offcuts are on the grass out
|
||||
# in the yard. If y ever measures ~1.2 again someone un-tore it.
|
||||
dict(name="house_yardside_wrecked", fn=build_house_yardside_wrecked,
|
||||
dims=((9.0, 9.9), (1.7, 2.6), (2.8, 3.3)),
|
||||
nodes=["wall", "door", "window", "roof", "window_glow",
|
||||
"fascia_torn", "gutter_torn", "gutter_down",
|
||||
"downpipe_loose", "debris_fascia"]),
|
||||
# site_02 (corner block): the anchor trap, not just dressing.
|
||||
dict(name="carport_01", fn=build_carport_01,
|
||||
dims=((3.0, 3.5), (5.2, 5.8), (2.4, 2.8)),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.5 MiB |
140
tools/storm_envelope/envelope.html
Normal file
140
tools/storm_envelope/envelope.html
Normal file
@ -0,0 +1,140 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
storm_envelope, browser front-end. [Lane C, SPRINT12 — gate 2.4's second harness]
|
||||
|
||||
B's site_audit measures the failure envelope through the CLOTH (peak corner
|
||||
loads, kN). This measures the same envelope through the STORM: what the wind
|
||||
actually delivers at every dressed anchor, per night — peak local speed, when,
|
||||
dynamic pressure, dose, and the pressure/hint ranking that predicts failure
|
||||
ORDER under B's `load > rating × ratingHint` wiring. Two harnesses, one
|
||||
envelope; if they disagree, find the variable before anyone retunes E's hints.
|
||||
|
||||
Dressed positions the way the game gets them — createWorld(await loadSite(name))
|
||||
then dress() — because a graybox envelope measures a yard that does not ship
|
||||
(site_audit's own lesson, kept).
|
||||
|
||||
tools/storm_envelope/envelope.html ← the whole week
|
||||
tools/storm_envelope/envelope.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||
|
||||
Served from the repo root (server.py).
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>storm_envelope</title>
|
||||
<style>
|
||||
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||||
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||||
h2 { font-size:13px; color:#9cf; margin:18px 0 2px; }
|
||||
.sub { color:#888; margin-bottom:10px; white-space:pre-wrap; }
|
||||
table { border-collapse:collapse; margin:6px 0; }
|
||||
th { text-align:right; color:#789; font-weight:normal; padding:1px 12px 1px 0; }
|
||||
th:first-child, td:first-child { text-align:left; }
|
||||
td { padding:1px 12px 1px 0; white-space:nowrap; text-align:right; }
|
||||
.weak { color:#e96; } .probe { color:#678; font-style:italic; }
|
||||
</style>
|
||||
<h1>storm_envelope — the wind side of the failure envelope</h1>
|
||||
<div class="sub" id="sub">loading…</div>
|
||||
<div id="out"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "../../web/world/vendor/three.module.js",
|
||||
"three/addons/": "../../web/world/vendor/addons/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { NIGHTS } from '../../web/world/js/week.js';
|
||||
import { stormEnvelope } from './envelope.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
// One dressed world per site, cached — the yard doesn't change between storms.
|
||||
const siteCache = new Map();
|
||||
async function dressedSite(name) {
|
||||
if (siteCache.has(name)) return siteCache.get(name);
|
||||
const site = await loadSite(name);
|
||||
const calmStub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: calmStub, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* flagged below */ } }
|
||||
const anchors = world.anchors.map((a) => ({
|
||||
id: a.id, type: a.type, ratingHint: a.ratingHint,
|
||||
pos: { x: a.pos.x, y: a.pos.y, z: a.pos.z },
|
||||
}));
|
||||
const out = { site, anchors, dressed, bed: world.gardenBed };
|
||||
siteCache.set(name, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function render(title, siteName, stormName, res, dressed) {
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = title;
|
||||
el('out').appendChild(h);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = `${siteName} × ${stormName} — downdraftOfTotal ${res.downFrac}, ${res.duration}s`
|
||||
+ (dressed ? '' : ' ⚠ dress() FAILED — graybox positions, not what ships');
|
||||
el('out').appendChild(sub);
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
const head = tbl.insertRow();
|
||||
for (const c of ['anchor', 'type', 'hint', 'peak m/s', 'tPeak s', 'peak Pa', 'Pa/hint', 'dose m²/s', 'eff. cara/shackle/rated N']) {
|
||||
const th = document.createElement('th'); th.textContent = c; head.appendChild(th);
|
||||
}
|
||||
for (const r of res.rows) {
|
||||
const tr = tbl.insertRow();
|
||||
if (r.probe) tr.className = 'probe';
|
||||
else if (r.hint < 0.5) tr.className = 'weak';
|
||||
tr.insertCell().textContent = r.id;
|
||||
tr.insertCell().textContent = r.type;
|
||||
tr.insertCell().textContent = r.hint.toFixed(2);
|
||||
tr.insertCell().textContent = r.peak.toFixed(2);
|
||||
tr.insertCell().textContent = r.tPeak.toFixed(1);
|
||||
tr.insertCell().textContent = r.peakPa.toFixed(0);
|
||||
tr.insertCell().textContent = r.paPerHint.toFixed(0);
|
||||
tr.insertCell().textContent = r.dose.toFixed(0);
|
||||
tr.insertCell().textContent = r.probe ? '—' : r.effN.map((n) => n.toFixed(0)).join(' / ');
|
||||
}
|
||||
el('out').appendChild(tbl);
|
||||
}
|
||||
|
||||
async function runPair(siteName, stormName, title) {
|
||||
const { site, anchors, dressed, bed } = await dressedSite(siteName);
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
const probes = [{ id: 'bed centre', pos: { x: bed.x, z: bed.z } }];
|
||||
if (venturi.length) probes.push({ id: 'throat', pos: { x: venturi[0].x, z: venturi[0].z } });
|
||||
const res = stormEnvelope({ anchors, stormDef, venturi, probes });
|
||||
render(title, siteName, stormName, res, dressed);
|
||||
return { site: siteName, storm: stormName, dressed,
|
||||
rows: res.rows.map(({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose }) =>
|
||||
({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose })) };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const single = q.get('site') || q.get('storm');
|
||||
const pairs = single
|
||||
? [{ site: q.get('site') || 'backyard_01', storm: q.get('storm') || 'storm_02_wildnight', title: 'requested pair' }]
|
||||
: NIGHTS.map((n, i) => ({ site: n.site, storm: n.storm, title: `night ${i + 1}` }));
|
||||
el('sub').textContent = `${pairs.length} pair(s) · sampling at FIXED_DT over each storm · `
|
||||
+ `venturi + tree shelters wired the way main.js wires them`;
|
||||
const results = [];
|
||||
for (const p of pairs) results.push(await runPair(p.site, p.storm, p.title));
|
||||
// machine-readable, so the page can be driven headless-in-browser
|
||||
window.__envelope = results;
|
||||
document.title = 'storm_envelope — done';
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
el('sub').textContent = `storm_envelope crashed: ${e.message}\n${e.stack || ''}`;
|
||||
window.__envelope = { error: e.message };
|
||||
document.title = 'storm_envelope — ERROR';
|
||||
});
|
||||
</script>
|
||||
107
tools/storm_envelope/envelope.js
Normal file
107
tools/storm_envelope/envelope.js
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* envelope.js — the STORM-side failure envelope, per anchor. [Lane C, SPRINT12]
|
||||
*
|
||||
* SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the
|
||||
* cloth sim (site_audit: settle, fly the storm, read peak corner loads in N).
|
||||
* This is the SECOND harness on the same envelope, measured from the other side:
|
||||
* no cloth, no rig, no tension — just what the STORM delivers at each anchor's
|
||||
* position, through the same wind everyone samples (createWind + the site's
|
||||
* venturi + the tree shelters, exactly as main.js wires them at site load).
|
||||
*
|
||||
* Two harnesses, one number — the repo rule. What must agree:
|
||||
* · ORDERING: anchors ranked by wind-side pressure/hint should rank the same
|
||||
* way B's peak corner loads do, once quad geometry is accounted for. An
|
||||
* anchor whose load outranks its wind exposure has a variable to find.
|
||||
* · TIMING: B's peak corner load should land near the wind-side tPeak (cloth
|
||||
* lag is under a second; the venturi's alignment window is tens of seconds).
|
||||
* What will NOT agree, by construction: absolute newtons. Corner load carries
|
||||
* quad geometry, cloth porosity, tension and rain mass — all B's side. This
|
||||
* harness deliberately knows none of it, which is what makes it independent.
|
||||
*
|
||||
* Per anchor, per storm:
|
||||
* peak highest local horizontal wind speed over the storm, m/s
|
||||
* (wind.speedAt — the anemometer number, same units as every probe
|
||||
* table in THREADS)
|
||||
* tPeak when it happened, s
|
||||
* peakPa peak dynamic pressure, Pa: ½ρ·v²·(1+downFrac²) — the (1+d²) folds
|
||||
* the downdraft back in, since cloth feels the full vector while
|
||||
* speedAt deliberately reads horizontal-only
|
||||
* dose ∫ v² dt over the whole storm, m²/s — exposure × time, so a long
|
||||
* grind and a single spike stop looking alike
|
||||
* paPerHint peakPa / ratingHint — the wind-side "who blows first" ranking.
|
||||
* B's wiring is `load > hw.rating * ratingHint`, so dividing the
|
||||
* delivered pressure by the hint predicts failure ORDER for equal
|
||||
* hardware and equal geometry. Prediction, not measurement — B's
|
||||
* harness is the one that owns the geometry.
|
||||
*
|
||||
* Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = i·dt over
|
||||
* the storm's duration, same seed path (createWind(def) — def.seed), so the two
|
||||
* harnesses fly the SAME storm, not merely the same JSON.
|
||||
*/
|
||||
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
|
||||
export const ENVELOPE = {
|
||||
RHO: 1.225, // kg/m³, sea-level air — the constant, named once
|
||||
DT: FIXED_DT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Measure the storm at every anchor.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? }
|
||||
* — pass the DRESSED world.anchors (browser) or a
|
||||
* verified dump; graybox positions measure a yard
|
||||
* that does not ship (site_audit's lesson).
|
||||
* @param {object} o.stormDef parsed storm JSON
|
||||
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) —
|
||||
* a sweep without it flies an easier yard than ships
|
||||
* @param {Array} [o.probes] extra { id, pos } points (bed centre, throat…)
|
||||
* measured alongside, hint fixed at 1
|
||||
* @param {number} [o.dt]
|
||||
* @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending
|
||||
*/
|
||||
export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) {
|
||||
const wind = createWind(stormDef);
|
||||
wind.setVenturi(venturi);
|
||||
// exactly main.js:447 — every tree anchor casts a shadow, defaults and all
|
||||
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
|
||||
const downFrac = wind.core.downFrac;
|
||||
const vecFold = 1 + downFrac * downFrac; // |v|² = h²·(1+d²) when vy = -d·h
|
||||
|
||||
const rows = [
|
||||
...anchors.map((a) => ({
|
||||
id: a.id, type: a.type, probe: false,
|
||||
hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1,
|
||||
pos: { x: a.pos.x, z: a.pos.z },
|
||||
peak: 0, tPeak: 0, dose: 0,
|
||||
})),
|
||||
...probes.map((p) => ({
|
||||
id: p.id, type: 'probe', probe: true, hint: 1,
|
||||
pos: { x: p.pos.x, z: p.pos.z },
|
||||
peak: 0, tPeak: 0, dose: 0,
|
||||
})),
|
||||
];
|
||||
|
||||
const duration = stormDef.duration ?? 90;
|
||||
const n = Math.round(duration / dt);
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i * dt;
|
||||
for (const r of rows) {
|
||||
const s = wind.speedAt(r.pos, t);
|
||||
if (s > r.peak) { r.peak = s; r.tPeak = t; }
|
||||
r.dose += s * s * dt;
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold;
|
||||
r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1);
|
||||
// what B's wiring makes each shop tier hold HERE: rating × hint, in N
|
||||
r.effN = HARDWARE.map((h) => h.rating * r.hint);
|
||||
}
|
||||
rows.sort((a, b) => b.paPerHint - a.paPerHint);
|
||||
return { rows, downFrac, duration };
|
||||
}
|
||||
100
tools/storm_envelope/envelope.selftest.js
Normal file
100
tools/storm_envelope/envelope.selftest.js
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* envelope.selftest.js — the second harness proves it measures. [Lane C, SPRINT12]
|
||||
*
|
||||
* Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
|
||||
* selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
|
||||
* flew the corner block with the funnel switched off for two sprints; this file
|
||||
* exists so the harness built to CHECK that tool can't quietly do the same.
|
||||
* Every assert here is written to fail if a wiring line is deleted:
|
||||
* · drop setVenturi → the throat anchor's funnelled/unfunnelled peaks
|
||||
* collapse to equal and the strict < goes red
|
||||
* · drop setSheltersFromTrees → the sheltered anchor stops reading lower
|
||||
* · reseed per call → the determinism byte-compare goes red
|
||||
* · flip paPerHint to × → the weak-anchor-ranks-first assert goes red
|
||||
*/
|
||||
|
||||
import { stormEnvelope } from './envelope.js';
|
||||
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
/**
|
||||
* A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
|
||||
* gusts, no wander, no spatial noise — so every local effect is the ONLY thing
|
||||
* moving a number, and the asserts can be strict instead of statistical.
|
||||
*/
|
||||
const FLAT = {
|
||||
name: 'flat_test', seed: 7, duration: 20,
|
||||
baseCurve: [[0, 10], [20, 10]],
|
||||
dirCurve: [[0, 0], [20, 0]],
|
||||
dirWander: { amp: 0, rate: 0 },
|
||||
spatial: { amp: 0 },
|
||||
gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
|
||||
};
|
||||
|
||||
const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
|
||||
|
||||
test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => {
|
||||
const anchors = [A('a', 0, 0), A('b', 5, 3)];
|
||||
const def = JSON.parse(JSON.stringify(FLAT));
|
||||
const r1 = stormEnvelope({ anchors, stormDef: def });
|
||||
const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
|
||||
for (let i = 0; i < r1.rows.length; i++) {
|
||||
assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
|
||||
&& r1.rows[i].tPeak === r2.rows[i].tPeak,
|
||||
`run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => {
|
||||
const anchors = [A('throat', 0, 0), A('far', 100, 0)];
|
||||
const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
|
||||
const off = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
|
||||
const g = (res, id) => res.rows.find((r) => r.id === id);
|
||||
// strict: delete envelope.js's setVenturi call and this collapses to equal
|
||||
assert(g(on, 'throat').peak > g(off, 'throat').peak,
|
||||
`funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`);
|
||||
assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
|
||||
`gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
|
||||
assert(g(on, 'far').peak === g(off, 'far').peak,
|
||||
'the funnel reached an anchor 100 m away — radial falloff is broken');
|
||||
});
|
||||
|
||||
test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => {
|
||||
// tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
|
||||
const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
|
||||
const without = [A('lee', 4, 0)];
|
||||
const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||||
const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
|
||||
// strict: delete envelope.js's setSheltersFromTrees call and these read equal
|
||||
assert(sheltered.peak < open.peak,
|
||||
`the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`);
|
||||
});
|
||||
|
||||
test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
|
||||
// identical position, identical wind — only the hint differs (E's beam 0.22
|
||||
// vs a clean post at 1.0). The wind-side prediction must rank the beam first.
|
||||
const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
|
||||
const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
assert(rows[0].id === 'beam',
|
||||
`equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
|
||||
assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
|
||||
'paPerHint is not peakPa/hint — the ranking formula drifted');
|
||||
assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
|
||||
`effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
|
||||
});
|
||||
|
||||
test('envelope: peaks land inside the storm and doses are finite and positive', () => {
|
||||
const anchors = [A('a', -3, 1), A('b', 6, -2)];
|
||||
const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
|
||||
for (const r of rows) {
|
||||
assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
|
||||
assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
|
||||
// flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted
|
||||
assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`);
|
||||
}
|
||||
});
|
||||
|
||||
export const ENVELOPE_TESTS = TESTS;
|
||||
@ -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
100
web/world/dev_skyfx.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
dev_skyfx — Lane C's storm viewer. [SPRINT12]
|
||||
|
||||
The skyfx bench: one storm, a bare ground plane, and the whole sky stack
|
||||
(dome, rain, hail, lightning, change front, audio) with a scrubbable clock.
|
||||
Exists so "does the change ANNOUNCE itself" (gate 3.4) can be judged by eye
|
||||
in ten seconds instead of playing to night 3 — D, this is for you: load the
|
||||
early buster, watch the southern sky from t=0, and say whether you'd have
|
||||
known the change was coming without reading a number.
|
||||
|
||||
dev_skyfx.html ← storm_03b_earlybuster
|
||||
dev_skyfx.html?storm=storm_03_southerly&t=12 ← night 2's slower version
|
||||
|
||||
keys: space pause · ] +5 s · [ −5 s (forward-clean; rewinds may re-flash)
|
||||
←/→ turn · click once to unlock audio
|
||||
The VIEW runs on rAF (it's a viewer); the sim is stepped at FIXED_DT
|
||||
accumulated, same as the game, so what you see is what ships.
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>dev_skyfx</title>
|
||||
<style>
|
||||
body { margin:0; background:#000; overflow:hidden; }
|
||||
#hud { position:fixed; top:10px; left:12px; color:#cde; font:12px/1.5 ui-monospace,Menlo,monospace;
|
||||
text-shadow:0 1px 2px #000; white-space:pre; pointer-events:none; }
|
||||
</style>
|
||||
<div id="hud">loading…</div>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { FIXED_DT } from './js/contracts.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const stormName = q.get('storm') || 'storm_03b_earlybuster';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 400);
|
||||
camera.position.set(0, 1.7, -4);
|
||||
let yaw = Math.PI; // start looking toward +Z — the south
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||||
sun.position.set(30, 50, -20);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.2);
|
||||
scene.add(hemi);
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2),
|
||||
new THREE.MeshLambertMaterial({ color: 0x4a5d3a }),
|
||||
);
|
||||
scene.add(ground);
|
||||
// a fence-height slab to the north, so there's a yard-ish thing in frame
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(14, 3, 1), new THREE.MeshLambertMaterial({ color: 0x8a7f70 }));
|
||||
slab.position.set(0, 1.5, -11);
|
||||
scene.add(slab);
|
||||
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
|
||||
|
||||
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') paused = !paused;
|
||||
if (e.key === ']') t = Math.min(def.duration, t + 5);
|
||||
if (e.key === '[') t = Math.max(0, t - 5);
|
||||
if (e.key === 'ArrowLeft') yaw += 0.15;
|
||||
if (e.key === 'ArrowRight') yaw -= 0.15;
|
||||
});
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
const hud = document.getElementById('hud');
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
const wall = Math.min(0.1, (now - last) / 1000); last = now;
|
||||
if (!paused && t < def.duration) {
|
||||
acc += wall;
|
||||
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
|
||||
}
|
||||
camera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
const ch = (def.events || []).find((e) => e.type === 'windchange');
|
||||
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
|
||||
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
|
||||
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
|
||||
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
@ -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 ? 'They’ll 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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
|
||||
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||
@ -41,6 +42,11 @@ export default async function run(t) {
|
||||
const { cases } = weatherCases(storms);
|
||||
for (const c of cases) t.test(c.name, c.fn);
|
||||
|
||||
// --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
|
||||
// Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
|
||||
// B's audit must not be the thing that drifts. See envelope.selftest.js.
|
||||
for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
|
||||
|
||||
// --- the bits that need the THREE adapter, not just the core ---
|
||||
|
||||
t.test('weather.js satisfies the wind contract', () => {
|
||||
@ -271,6 +277,92 @@ export default async function run(t) {
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
// --- SPRINT12 gate 3.4: the change announces itself ---
|
||||
// The corner block's thesis is "looks like night 2 and isn't" — storm_03b's
|
||||
// change lands at 18 s, not 30. The tell is a dark front wall standing in the
|
||||
// quarter the new wind comes FROM, rising across the 12 s before the change
|
||||
// and clearing after the swing. These asserts pin: the window (nothing before
|
||||
// the lead opens, gone after the fade), the rise (monotonic, reaches ~full),
|
||||
// the DIRECTION (the wall stands in the southern sky — +Z, the open fence
|
||||
// side — and the mesh really is rotated there), and that a changeless storm
|
||||
// never raises it. D judges whether it READS; this pins that it exists,
|
||||
// where it stands, and when.
|
||||
t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => {
|
||||
const scene = new THREE.Scene();
|
||||
const wind = createWind(storms.storm_03b_earlybuster);
|
||||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||||
const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange');
|
||||
const t0 = ev.t, over = ev.over ?? 6; // 18, 6
|
||||
|
||||
let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false;
|
||||
fixedLoop(40, FIXED_DT, (dt, time) => {
|
||||
sky.step(dt, time, {});
|
||||
const f = sky.changeFront;
|
||||
assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`);
|
||||
if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} — before its lead window opens`);
|
||||
if (time > t0 - 12 && time <= t0) {
|
||||
assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} → ${f.toFixed(3)} at t=${time.toFixed(2)}`);
|
||||
prev = f;
|
||||
if (f > peak) peak = f;
|
||||
}
|
||||
if (Math.abs(time - t0) < FIXED_DT) {
|
||||
azAtChange = sky.changeFrontAz;
|
||||
rotAtChange = sky.front.rotation.y;
|
||||
opAtChange = sky.front.material.opacity;
|
||||
visAtChange = sky.front.visible;
|
||||
}
|
||||
if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} — the swing has long cleared`);
|
||||
});
|
||||
|
||||
assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change — it should stand near full`);
|
||||
assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`);
|
||||
|
||||
// direction: the wall stands where the new wind comes FROM. dirAt is the
|
||||
// heading the wind blows TOWARD, so from = settled post-change dir + π.
|
||||
const expected = wind.dirAt(t0 + over + 4) + Math.PI;
|
||||
assert(Math.abs(azAtChange - expected) < 1e-9,
|
||||
`front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`);
|
||||
// and that quarter is the SOUTH: the buster blows toward -Z (the house),
|
||||
// so it comes from +Z, the open fence side. sin(az) is the from-vector's z.
|
||||
assert(Math.sin(azAtChange) > 0.3,
|
||||
`the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} — from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`);
|
||||
// the mesh is really rotated there (local band centre sits at azimuth π,
|
||||
// so rotation.y = π − az carries it to az; see skyfx geometry note)
|
||||
assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9,
|
||||
`wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`);
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => {
|
||||
// storm_03 (night 2): same wall, later — up but not full at t=20 (change 30)
|
||||
const mk = (name) => createSkyFx({
|
||||
scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
|
||||
wind: createWind(storms[name]),
|
||||
});
|
||||
const a = mk('storm_03_southerly'), b = mk('storm_03_southerly');
|
||||
let mid = 0;
|
||||
fixedLoop(21, FIXED_DT, (dt, time) => {
|
||||
a.step(dt, time, {}); b.step(dt, time, {});
|
||||
// determinism: two instances at the same t agree to the bit — no hidden
|
||||
// clock, no per-instance random draw in the tell
|
||||
assert(a.changeFront === b.changeFront,
|
||||
`two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`);
|
||||
mid = a.changeFront;
|
||||
});
|
||||
assert(mid > 0.05 && mid < 0.95,
|
||||
`night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`);
|
||||
a.dispose(); b.dispose();
|
||||
|
||||
// the gentle day has no windchange: no wall, ever
|
||||
const calm = mk('storm_01_gentle');
|
||||
fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => {
|
||||
calm.step(dt, time, {});
|
||||
assert(calm.changeFront === 0 && !calm.front.visible,
|
||||
`a changeless storm raised the front at t=${time.toFixed(2)}`);
|
||||
});
|
||||
calm.dispose();
|
||||
});
|
||||
|
||||
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
|
||||
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
|
||||
// because honest rain walks under a sail. Steep hail doesn't — a sail over the
|
||||
|
||||
@ -58,6 +58,9 @@ const ASSETS = [
|
||||
{ name: 'house_yardside', h: [2.5, 3.5],
|
||||
nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
|
||||
'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
|
||||
{ name: 'house_yardside_wrecked', h: [2.5, 3.5],
|
||||
nodes: ['wall', 'door', 'window', 'roof', 'fascia_torn',
|
||||
'gutter_torn', 'gutter_down', 'downpipe_loose', 'debris_fascia'] },
|
||||
{ name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
|
||||
{ name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
|
||||
{ name: 'garden_bed', h: [0.5, 1.1],
|
||||
@ -178,6 +181,93 @@ export default async function run(t) {
|
||||
'wrecked carport must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// The gutter, priced — the carport ruling's second application (SPRINT12
|
||||
// §gate 3.3). backyard_01's fascia anchors have said collateral:"gutter"
|
||||
// since Sprint 6 and nobody ever priced one, so collateralFor('gutter')
|
||||
// returns null and the house is a FREE failure. The proposal is 90 and the
|
||||
// reasoning lives beside GUTTER_COLLATERAL in build_yard_assets.py; what THIS
|
||||
// pins is the calibration, not the digit: the gutter must cost more than the
|
||||
// ornament and less than the structure, because the fascia (0.35) is honestly
|
||||
// better steel than the carport beam (0.22) and the tutorial yard's trap must
|
||||
// cost you a night, not the week. A rules the number; if it moves, move it in
|
||||
// the factory and re-export — do not let site JSON and the GLB tell two prices.
|
||||
t.test('the gutter is priced, inside the band, and keyed to what the anchors say', () => {
|
||||
const h = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside');
|
||||
assert(h, 'house_yardside root missing');
|
||||
const cost = h.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', `house carries no collateral_value (${JSON.stringify(h.userData)})`);
|
||||
// collateral_key exists because, unlike the carport, the structure and the
|
||||
// thing you take have different names: the key says WHICH collateral string
|
||||
// this value prices, and it must be the one the fascia anchors carry or
|
||||
// Lane A's wiring prices nothing.
|
||||
assert(h.userData?.collateral_key === 'gutter',
|
||||
`collateral_key is ${JSON.stringify(h.userData?.collateral_key)} — must name the anchors' collateral string`);
|
||||
const a1 = loaded.get('house_yardside')?.scene.getObjectByName('fascia_anchor_01');
|
||||
assert(a1?.userData?.collateral === h.userData.collateral_key,
|
||||
`fascia anchor says collateral=${JSON.stringify(a1?.userData?.collateral)} but the price is keyed ${JSON.stringify(h.userData.collateral_key)} — the chain is broken`);
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene
|
||||
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
|
||||
const carport = loaded.get('carport_01')?.scene
|
||||
.getObjectByName('carport_01')?.userData?.collateral_value;
|
||||
assert(cost > gnome, `gutter (${cost}) must cost more than the gnome (${gnome}) — structural collateral beats ornament`);
|
||||
assert(cost < carport, `gutter (${cost}) must cost less than the carport (${carport}) — the backyard's trap is the gentler one`);
|
||||
// Wreckage carries the same price, so scoring can read either state.
|
||||
const w = loaded.get('house_yardside_wrecked')?.scene.getObjectByName('house_yardside_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked house must price its gutter the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'house_yardside',
|
||||
'wrecked house must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// The wreck's whole message is vertical: the gutter that was UP at the eave is
|
||||
// DOWN. Both halves are measured, and the intact half is the negative control
|
||||
// that proves the measurement measures — if someone rebuilds the wreck with
|
||||
// the run back at the eave (or flips the export axis), gutter_down's box
|
||||
// lands ~2.6 m up and this goes red. Verified red once already: built with
|
||||
// the ground run at eave height as a negative control, this failed at
|
||||
// "tops out at y=2.68" (1 failed / 319 passed) while the Blender verify said
|
||||
// [PASS] 9.46 x 2.10 x 2.92 on the same bad file — the run's PLAN position
|
||||
// didn't move, so no box span changed. A bounding box cannot answer "did it
|
||||
// fall" unless something pins which height it fell FROM.
|
||||
t.test('the torn gutter is DOWN in the wreck, UP in the intact house', () => {
|
||||
const up = loaded.get('house_yardside')?.scene.getObjectByName('gutter');
|
||||
assert(up, 'intact house lost its gutter node');
|
||||
const upBox = new THREE.Box3().setFromObject(up);
|
||||
assert(upBox.max.y > 2.5,
|
||||
`intact gutter tops out at y=${upBox.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
|
||||
|
||||
const wreck = loaded.get('house_yardside_wrecked');
|
||||
assert(wreck, 'house_yardside_wrecked did not load');
|
||||
const down = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_down'));
|
||||
assert(down.max.y < 0.5,
|
||||
`gutter_down tops out at y=${down.max.y.toFixed(2)} — the run is not on the grass`);
|
||||
const hang = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_torn'));
|
||||
assert(hang.max.y > 2.3,
|
||||
`gutter_torn tops out at y=${hang.max.y.toFixed(2)} — it should still reach the eave it tore from`);
|
||||
assert(hang.min.y < 1.6,
|
||||
`gutter_torn bottoms out at y=${hang.min.y.toFixed(2)} — the torn end should sag well below the fascia line`);
|
||||
|
||||
// And the house itself did NOT fall: unlike the carport, this wreck keeps
|
||||
// its full height, because only the eave line failed. A shorter (or taller)
|
||||
// wreck here means someone wrecked the building instead of the gutter.
|
||||
const hi = new THREE.Box3().setFromObject(loaded.get('house_yardside').scene);
|
||||
const hw = new THREE.Box3().setFromObject(wreck.scene);
|
||||
const dh = Math.abs((hi.max.y - hi.min.y) - (hw.max.y - hw.min.y));
|
||||
assert(dh < 0.05, `wreck height differs from the house by ${dh.toFixed(3)} m — a house does not fall when its gutter does`);
|
||||
});
|
||||
|
||||
// You cannot re-tie to a ripped eave. An anchor that outlives its fascia
|
||||
// would be the free-failure bug back again, wearing a wreck for a costume —
|
||||
// world.anchors would keep offering the tie-off after the aftermath swap.
|
||||
t.test('no fascia anchor survives into the wreck', () => {
|
||||
const w = loaded.get('house_yardside_wrecked');
|
||||
assert(w, 'house_yardside_wrecked did not load');
|
||||
for (const n of ['fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03']) {
|
||||
assert(!w.scene.getObjectByName(n),
|
||||
`${n} survives in the wreck — the eave it anchored is gone, so it must be too`);
|
||||
}
|
||||
});
|
||||
|
||||
// A wreck that stands taller than the thing it was is a bug, not a wreck.
|
||||
t.test('the wrecked carport is shorter than the carport', () => {
|
||||
const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
|
||||
@ -400,7 +490,8 @@ export default async function run(t) {
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_snapped']]) {
|
||||
['fence_panel', 'fence_panel_snapped'],
|
||||
['house_yardside', 'house_yardside_wrecked']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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.
BIN
web/world/models/house_yardside_wrecked_v1.glb
Normal file
BIN
web/world/models/house_yardside_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user