diff --git a/THREADS.md b/THREADS.md
index 8881e43..d8e79db 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -9147,6 +9147,308 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
worth saying out loud); fees carry standing multipliers down to Γ0.88 at β 0.0, so the
rep bleed is FELT in the wallet, which is REP doing its job unmeasured or not.
+[D] 2026-07-21 β π€ **CONVERGENCE, DECLARED FIRST: TWO LANE-D SESSIONS RAN GATES 3.2 AND 4 IN
+ PARALLEL. I found out by having my push REJECTED. The pool yard (site_04) and the seven-night
+ cold play landed ABOVE are the other session's, and they STAND β I did not force, I did not
+ rebase over them, and I dropped my whole duplicate cut (a second pool yard + a second
+ cold-play).** Their pool yard is the better one and for the exact reason this convergence is
+ lucky: **it ships ONE corroded post on purpose to dodge the second-rust-post landmine β the
+ one my cut walked straight into.** I authored TWO corroded posts (the "different sets" thesis
+ read to me as needing a genuine choice of rust corners), scored it green, and only at exposure
+ time did the landmine they filed bite. So I bring their filing a measured receipt and nothing
+ else it doesn't already have. Verified their tree with my own eyes before adding a line:
+ selftest 515/0/0 on :8827, my own fronted tab.
+
+ **THE RECEIPT FOR THEIR SECOND-RUST-POST FILING (E/A β a number for when per-instance keys get
+ plumbed).** On a real two-corroded-post yard (both structures baking `collateralKey:
+ 'corroded_post'`), I measured the two exposure harnesses in the browser and they DISAGREE:
+ Β· A's `exposureOf(siteJson)` (the OFFER CARD) reads **$115** β corroded $45 + corroded $45 +
+ gnome $25, per STRUCTURE.
+ Β· B/C's `collateralExposure` (SCORE IT / the failure route) reads **$45** β the corroded post
+ $45 ONCE (de-duped by key), and the gnome not counted at all (no anchor reaches it).
+ That is C's gate-1.2 exposure cross going RED by construction β the cross WORKING, on the first
+ yard that would trip it. The other session avoided it by shipping one post; the number is here
+ so the fix (per-instance key plumbing + whether the gnome belongs in `exposureOf`) has a datum,
+ because arc 2's glasshouse/granny-flat yards will want more than one priceable hazard someday.
+
+ **A SECOND PLAYTESTER'S TOP BOARD FIX, from an independent seventh night: the offer card LIES
+ about the soaker, live, exactly as C measured.** I took the N7 soaker off the board and read
+ the card against the sheet it leads to:
+ Β· board CARD: `sustained to 9 m/s (33 km/h) Β· fine pea stones` β reads as the SOFT job (the
+ alternative buster card reads 76 km/h, windier).
+ Β· job SHEET (one click later): `rain heavy Β· southerly change at 40s Β· hail likely Β· rain to
+ 44 mm/hr`.
+ The card hides the 44 mm/hr downpour and the hail that ARE the soaker's trap, so on the board
+ alone the trap reads as the safe pick β the board recommending the trap in the storm's own
+ voice. C's `offerBand` hunk (hail duration + rain rate + change, mapped so a dropped fact can't
+ hide) is FILED and NOT INTEGRATED. **From the chair: this is the #1 board fix for the next
+ integration** β it is the one place the board is dishonest, and it is dishonest on the week's
+ signature trap. Everything else on the card (client, site, wind, fee, exposure, β terms) is
+ honest and load-bearing; the exposure line in particular is what a broke wallet reads to stay
+ off the carport.
+
+ Everything else my pass would have said, the landed cold-play above already says better (it
+ mouse-rigged where mine went through `session.rig` for want of a pointer-capture orbit, it
+ took the pool yard AND the $45 cap as its alternatives, and its declining-costs-nothing and
+ $5-tier clean/marginal findings are sharper than anything I had). No duplicate lands. My
+ scratch merge judged the wider tree (main + the newest lane/b + lane/c) at **517/0/0**,
+ per-lane sum verified, nothing lost in the merge β flagged for the integrator as the number to
+ reproduce when b/c's newest commits and this pool yard all land together.
+
+[B] 2026-07-21 β π¦ **GATE 3 LANDED: THE CORRODED TIER PRICES HONESTLY IN THE CARD β the piece the
+ 0.2/0.3/2 pass deferred, now pinned. Plus: I re-ran 0.2/0.3/2 independently and they STAND.**
+ A recovered/parallel session (this one) rebuilt gates 0.2/0.3/2 from 75d5f9a before finding the
+ landed work on origin/lane/b. Rather than overwrite three pushed, receipted, mutation-checked
+ commits, I **reset to the landed versions and added only gate 3** β the one thing the earlier
+ pass explicitly filed as DEFERRED ("gate 3 support is armed and DEFERRED as briefed⦠Corroded
+ steel prices through ratingHint, which every sweep already reads live"). The parallel
+ reimplementation is kept in a local ref for reference; nothing from it overwrote the landed
+ lane/b. selftest still green (node sail 47/47, rigging 29/29 = 27 + the 2 gate-3 pins).
+
+ **What gate 3 pins**, off E's REAL resolver, not a hand-typed 0.55:
+ Β· **The STEEL half** (`rigging.selftest`): `factoryExtras('corroded_post')` β hint 0.55 +
+ collateral key, and `_effRating` on a corroded corner is `rating Γ 0.55` β so a load a
+ carabiner holds on a sound post (1200 N) is OVER a carabiner on a corroded one (660 N). The
+ rust costs a tier, measured, and if it EVER prices as sound steel the trap inverts into the
+ safest anchor in the yard (E's load-bearing `_v1` warning).
+ Β· **The COLLATERAL half** (`gardenfly.selftest`, a real dressed world, ~40 ms): a node-less
+ corroded placement (E's "plain entry, no node field" case β the harder one, rating and
+ collateral both from the factory) β `world.collateralFor('corroded_post')` = $45 "the
+ corroded post", summed by `collateralExposure` β NOT the baked GLB value read directly, the
+ shortcut that priced the carport to null for five sprints.
+ **Mutations** (run on the scratch merge where the tier is live): `collateralFor` drops the
+ price β card reads null; `factoryExtras` returns hint 1 β the steel pin reddens. Both caught.
+
+ β οΈ **CROSS-LANE SEAM, disclosed.** The corroded tier (0.55, the GLB, the gen entry) is on
+ lane/e. On bare lane/b `factoryExtras('corroded_post')` is null, so **both gate-3 pins
+ SKIP-WITH-DISCLOSURE here** and go live at integration β the "build against the shape as
+ pushed" pattern. Measured GREEN on a scratch merge of lane/a + lane/e (hint 0.55, collateral
+ $45). Browser suite on bare lane/b: 0 failures, the corroded pins visibly skipped with their
+ reason in the run.
+
+ **D β your pool yard's corroded corners price right the moment you place them**, node or
+ node-less; the card names the $45 collateral and the 0.55 steel with no round-trip to me. The
+ tier is E's, the pricing surfaces are pinned, and the pin activates itself when your yard and
+ E's tier meet on the integrated tree.
+
+ **On 0.2/0.3/2 (the landed pass): reviewed, sound, nothing to change.** One optional
+ strengthening I found while reimplementing, filed NOT forced β the 0.2 negative control's
+ "over-arming is EQUIVALENT" finding is real (the heal is pure repair and emits only when it
+ healed, so an over-arm is a silent no-op to an event-counter). It IS observable if the control
+ spies the heal CALL rather than the event: a stub that arms on finite state calls
+ `_healNonFinite` every substep of a fully-broken-but-finite sail. If a future pass wants to
+ close that mutation gap, a `let calls=0; wrap _healNonFinite` in the finite-lost-sail control
+ turns the recorded "equivalent" into a red. Not this landing; the current control is honest
+ about what it does and does not catch.
+
+[C] 2026-07-21 β π€ **CONVERGENCE, DECLARED FIRST: TWO LANE-C SESSIONS RAN GATE 1 IN PARALLEL,
+ and I found out by having my push REJECTED. What follows is landed ON TOP of `f029274`
+ with NOT ONE LINE OF IT CHANGED β I did not force, I did not rebase over it, and I dropped
+ every part of my own work that duplicated it.** Selftest **494/0/0** (their 490 + my 4).
+
+ The split turned out clean, which is luck rather than planning. `forecastHonest(def)`
+ answers *"can the forecast describe this STORM"* β validator, finite stats, the stone
+ vocabulary ceiling β and it is better than the storm-side half I had written, so mine is
+ gone. What it structurally cannot reach is the two things my brief named, because it takes
+ a DEF and not a PAIRING and it never touches the card:
+ 1. **WHAT THE CARD PRINTS.** Their gate can pass a storm whose card then omits the fact
+ that matters. It does, tonight, on night 7 β see below.
+ 2. **WHICH YARD the storm is printed over.** `forecastHonest` has no `site` argument, so
+ "the soaker only ever offers over site_02" was still enforced by nothing but a comment
+ in A's POOL.
+ My work is now exactly those two, added after a divider in weather.js that says so. What I
+ abandoned: my own `forecastRefusal` (theirs is better), my exposure cross (theirs crosses
+ A's `exposureOf` directly and item-wise β strictly stronger than mine, which could only
+ cross the data against the runtime), and the `forecast.js` pure-module split. **The split
+ is the one thing I still think was right and am NOT relitigating today** β it made the
+ whole worded surface run in the node one-second loop, and 4 of my 6 mutation checks ran
+ there in seconds instead of two minutes a go. It is on branch `s17-c-mine` (commit
+ `3884108`, 485/0/0 standalone) if the integrator ever wants it; forcing a file move onto a
+ landed design to win an argument about addresses is churn, and I am not spending the
+ sprint's goodwill on it.
+
+[C] 2026-07-21 β π **THE OFFER BAND β and the measured finding that A's BOARD LIES ABOUT
+ NIGHT 7 as pushed.** The single thing my brief was most explicit about: *"an offer card
+ that hides the hail is the board lying, and the soaker's whole design is that its wind
+ reads gentle while its hail is the trap."* It does, and it was.
+
+ **MEASURED FIRST, before a line was written.** A's card prints `f.wind` and `f.stones` β a
+ reasonable pair. Night 7's two offers under that pair:
+ | night 7 | wind line | stones line |
+ |---|---|---|
+ | **the soaker** (scripted, site_02) | gusts to ~55 km/h | fine pea stones |
+ | the early buster (alt, swing lawn) | gusts to ~76 km/h | pea stones |
+ **On both printed lines the soaker is the softer job.** It is not: 14.9 s of hail against
+ 2.4 s, and 44 mm/hr of rain against 16.5. The board recommended the trap in the storm's own
+ voice, before a dollar is spent. Not A's mistake to have made β the fields were there and
+ the card picked two of them, which is exactly why the fix is not "also print rainRate".
+
+ **`offerBand(def, lead)` returns an ORDERED LIST** of `{key, text}` β wind, hail (iff the
+ storm hails), rain rate (always), change (always), confidence (iff hedged). The FORECAST
+ decides which facts a night carries and hands over all of them, so a card that maps the
+ list cannot drop the next fact somebody adds to a storm. Same construction and the same
+ reasoning as A's own `stormsToPreload(nights, pool)` split: an omission that is possible
+ will eventually happen. Night 7 now reads, in the real card, my own eyes on :8826 β
+ Β· soaker β `gusts to ~55 km/h` Β· **`hail likely Β· fine pea stones Β· 15s of it`** Β·
+ **`rain to 44 mm/hr`** Β· `southerly change at 40s` Β· $46 Β· $205 at risk
+ Β· buster β `gusts to ~76 km/h` Β· `hail possible Β· pea stones Β· 2s of it` Β·
+ `rain to 16 mm/hr` Β· `southerly change at 18s` Β· $57 Β· $255 at risk
+ The choice is now real and genuinely hard: **the soaker pays LESS, risks LESS, and is the
+ worse night.** That is the game arguing with the player instead of flattering them.
+
+ **β οΈ SECOND FINDING β THE CHANGE LINE IS NOT DECORATION.** `storm_03_southerly` and
+ `storm_03b_earlybuster` are IDENTICAL in every number the band prints β sustained 13.0,
+ gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4 s at size 0.70 β and differ ONLY in when the
+ change lands, 30 s against 18 s. **The board offers exactly that pair** (the buster is the
+ drawn alternative on nights 5 AND 7). Drop the change line and the board offers two jobs
+ whose weather it has just claimed to describe, described identically, and calls it a
+ choice. Pinned as its own case; it goes red with the two bands diffed in the message.
+
+ **A β THE HUNK, verbatim.** I applied it on a scratch tree to LOOK at the card, then
+ reverted it β hud.js and main.js are yours (the leadFor precedent).
+ 1. `main.js` import: `import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';`
+ 2. `main.js` `pricedOffers()`, one line beside `forecast:` β which I left in place, so
+ nothing of yours has to move and the card can adopt on its own schedule:
+ `band: def ? offerBand(def, 0) : null,`
+ 3. `hud.js` `offerHtml`, replacing your two forecast lines with:
+ ``${(o.band ?? []).map((l) => `
${l.text}
`).join('')}``
+ plus two CSS rules in your SPRINT17 board block:
+ `#hud-card .offer .line.wx { color:#8ba0ad; }` and
+ `#hud-card .offer .line.hail span { color:#cbd8e0; }` β the hail line is the one a
+ player must not skim past. `const f = o.forecast;` then goes unused in `offerHtml`.
+ **lead 0 and only lead 0, and I am not proposing a dial.** Both offers are for TONIGHT β
+ you are choosing which yard to stand in, not which night to work. A hedged board would
+ invent uncertainty the job sheet contradicts one card later, and the sheet is the document
+ the fee is quoted on.
+
+[C] 2026-07-21 β βοΈ **THE PAIRING LAW β A's three POOL exclusions stop being a comment.**
+ `forecastHonest` asks whether the forecast can describe a STORM. A band can be perfectly
+ honest about a storm and still be an invitation to a night nobody can work β that is a fact
+ about GEOMETRY Γ STORM, it needs a `site`, and nothing enforced it before this.
+
+ `PAIRING_LAW` (weather.js): storm key β `{sites, owner, why}`, with the measurement in the
+ data. **A β two of the three entries are YOUR reasons and are recorded as yours** (wildnight
+ and icenight off backyard_01: the pinned separation, the measured beyond-saving flag). I did
+ not re-measure them; I moved your stated grounds into a structure that goes red. The
+ soaker's is mine, carrying the S16 receipt verbatim: backyard_01 caps hail cover over the
+ bed at ~31%, its full-cover quads pond-tear at 3.3β3.7 kN on BOTH fabrics, best membrane
+ 37.5 against cloth 17.3 β a fabric bet with no win in it. **An absent key means
+ UNRESTRICTED, deliberately:** this table records refusals somebody PAID FOR, and inventing
+ one for a pairing nobody flew is the same offence in the other direction. Pinned in both
+ directions, including `pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null`
+ β that is night 4, and a law that refused it would be refusing the shipped ladder.
+
+ `pairingRefusals(pairings)` is **deliberately NOT a filter.** Silently dropping a bad entry
+ leaves a morning with one offer and no explanation β a runtime surprise wearing a fix's
+ clothes. It returns the offences with the measurement in each message and lets the caller
+ decide how loud to be.
+
+ **RECEIPTS.** The check walks NIGHTS βͺ POOL carrying the SITE β the same union the other
+ session's storm walk uses, extended by the dimension that walk cannot see (their own reason
+ is the right one: today every POOL storm is also in NIGHTS, so a NIGHTS-only walk is green
+ by coincidence). **12 pairings, 0 refusals.** The gate accuses nobody today, which is the
+ correct outcome and exactly why it carries a vacuity guard in the same case: move the
+ soaker onto backyard_01 and at least one refusal must appear, or the clean result above is
+ being produced by a law that cannot say no.
+
+ **A β if you want it at DATA level rather than in my suite**, `import { pairingRefusals }
+ from './weather.js'` in `board.js` and throw on a non-empty result at load. My brief's words
+ were "a DATA-level constraint on the pool agreed with A in THREADS β never a runtime
+ surprise", and the suite is where I can put it without touching your file. Yours to promote.
+
+[C] 2026-07-21 β π¬ **MUTATIONS, THE DISCLOSURE, AND A FALSE ALARM I KILLED BY MEASURING IT.**
+ Six run on my own tree before the convergence, and the two that guard the surviving code
+ re-run on THIS tree after the code changed address (the whole point of re-running: a
+ mutation receipt is about the shipped file, not about a file that used to exist):
+ | mutation | result |
+ |---|---|
+ | offer band drops the HAIL line (the night-7 trap) | RED β "storm_02_wildnight: hails for 11.4s but the band's hail line is MISSING" |
+ | soaker's restriction deleted from `PAIRING_LAW` | RED Γ2 β "the soaker over the backyard is offerable β the ~31% hail-cover measurement has stopped being enforced" |
+ | offer band drops the CHANGE line | RED β "the two busters differ on `sustained to 13 m/sβ¦` β expected the CHANGE TIME to be the difference" |
+ | offer band drops the rain-rate line | RED β "storm_01_gentle: no rain-rate line on the offer band" |
+ | night 7's soaker moved onto backyard_01 in week.js | RED β the ladder gate names the yard AND the measurement; D's two week laws also went red, which is them working |
+ | `collateralKey: "swing_set"` stripped from site_03 JSON | RED β reachability; superseded by the other session's stronger cross, recorded because it was run |
+ All restored; final run green.
+
+ **β οΈ A HOLE IN MY OWN GATE, found by probing it rather than reading it** β and worth
+ recording even though the code it lived in is gone, because the OTHER session's
+ `STONE_WORD_CEILING` fixes the top of exactly this range and nothing yet fixes the bottom:
+ a def with `hail.size: 0` and a live hail curve worded itself **"fine pea stones"**, because
+ `stoneWord` has no FLOOR and `0 < 0.6`. The card names a stone the storm does not carry, on
+ the axis the entire fabric bet is decided on. Their ceiling refuses 2.6; nothing refuses 0.
+ **Filed for whoever holds the pen on `forecastHonest` next** (one line beside the ceiling
+ check): `if (s.hailSeconds > 0 && !(s.hailSize > 0)) errors.push(β¦)`. Not landed by me β it
+ is inside their predicate and this is the wrong sprint for two people editing one function.
+
+ **THE FALSE ALARM, recorded because the process is the point.** I thought my dressed worlds
+ had taken the selftest from ~25 s to ~115 s and was about to trim the harness. Measured it
+ instead: **baseline 485/0/0 takes 131 s; 496/0/0 with my work took 115 s.** The "25 s" was
+ never a measurement β it was the first number I happened to read off a poll. Dressing all
+ three sites costs ~90 ms total (loadSite 4β5 ms, dress 28β32 ms each). Nothing was trimmed,
+ and the suspicion is dead rather than parked. Measure, don't reason.
+
+ **Two smalls for A, neither urgent.** (1) Pool ids `corner_block_no_house` and
+ `corner_block_cheapest` β the first is `site_03_swing_lawn`, not the corner block (the
+ `_why`, the client and the brief are all right; only the id reads wrong, and ids surface in
+ `takenOffer` and the settlement). (2) On most nights the two offers carry DIFFERENT STORMS
+ for the same night β a fiction question, not a bug. Night 2 happens to offer the same
+ southerly over two yards and reads noticeably more coherent for it. Yours to rule if it
+ ever matters; the band tells the truth either way.
+
+[I] 2026-07-21 β **SPRINT 17 INTEGRATION (main) β THE BOARD IS LIVE, SEVEN NIGHTS, arc 2 open.**
+ Selftest **522/0/0**, the exact sum (D's 515-integration + lane/b's 3 corroded cross-lane pins,
+ green now that lane/e is present + lane/c's 4 offerBand/pairing pins). Verified in the browser
+ and deployed to partly.party/hardyards. This integration was UNUSUAL and worth recording:
+
+ **Two full sets of lane sessions ran β mine (a workflow, on Opus after the Fable limit killed
+ the first wave) and a parallel set on John's other Mac.** Every lane hit the collision and
+ CONVERGED rather than clobbered: B reset to the landed 0.2/0.3/2 and added only gate 3; C reset
+ onto the other session's better forecast design and added only the offer band + pairing law; D
+ dropped their whole pool-yard cut and adopted the other session's (which ships ONE corroded post,
+ dodging the two-rust-post exposure-harness split their own cut walked into). E resumed the
+ Fable-killed session's uncommitted tree, found it was mid-negative-control (both GLBs were the
+ UNTELLED post, two asserts defective β one that couldn't fail, one whose stated reason was
+ backwards), and rebuilt it honestly. The convergence protocol C invented in S14 held across four
+ lanes and two machines. lane/d's own "pt-1 integration" commit did the heavy merge; I layered
+ lane/b and lane/c's newest commits on top.
+
+ **The one integrator hunk, applied verbatim (C filed it; hud/main are A's files):** C measured
+ that A's board LIED ABOUT NIGHT 7 β the soaker's offer read SOFTER than the buster's on the two
+ printed lines (55 vs 76 km/h gusts) while carrying 6Γ the hail (14.9 s vs 2.4) and 2.7Γ the rain.
+ The board was recommending its own trap in the storm's own voice. `offerBand(def, lead)` returns
+ the ORDERED list (wind Β· hail Β· rain Β· change Β· confidence) so the card can never drop a fact by
+ picking two fields. Applied the main.js `band:` line + the hud.js card map + two CSS rules (the
+ hail line lit brighter β the one a player must not skim). VERIFIED LIVE: night 7's two cards now
+ read the soaker as pays-less/risks-less/worse-night, the game arguing instead of flattering. The
+ fix is the same species as A's own `stormsToPreload(nights, pool)` split β an omission that is
+ possible eventually happens.
+
+ **What shipped, gate by gate:** the JOB BOARD (A) β two offers a morning, seeded deterministically
+ off week+night, the chosen job carrying a WHOLE night entry so sheet/quote/invoice/ledger/warranty/
+ rep read it through the seven surfaces they already had, ZERO changed lines; CONSTRAINTS (A data +
+ B enforcement) β house-ban refused at pick time in the client's own words, budget cap thrown at
+ commit, SCORE IT drops banned candidates and prices at the client's ceiling; the CORRODED TIER (E)
+ β 0.55 on the ladder because it is the only rung whose true capacity is unknowable by inspection,
+ $45, the tell verified to read at 20 m by the game's own renderer; the POOL YARD (D) β authored
+ cold, the legal tie-offs and the trustworthy tie-offs deliberately DIFFERENT SETS, in the game only
+ by being chosen off the board (the Karalis place, live on night 1's board with the corroded post
+ billed in its risk column).
+
+ **Rulings (A):** hail-light balance is CANON (the mild-night market buys the steel and the
+ collateral, not the bed β the refused separation pins already say so); constraint premiums +25%
+ (anchor ban) / +20% (budget cap), both flagged UNMEASURED and cut-first; the rain drain-weight
+ lever DECLINED at its proper price (its own gate, full re-measure).
+
+ **Carried to SPRINT18 (its callout types are John's gate-4 pick):** A's `stormsToPreload` preload
+ assert can't currently fail (every pool storm also lives in NIGHTS β the calm-day coincidence
+ again; split it so a pool-only storm can bite); C's `stoneWord` has no floor (size 0 words itself
+ "fine pea stones"); D's two-corroded-post exposure-harness split ($115 per-structure vs $45 per-key)
+ is the receipt for arc-2's multi-hazard yards; B's optional heal-control strengthening (spy the
+ call, not the event).
+
+ **Gate 4 stands and it now steers the next sprint: John plays the seven β partly.party/hardyards,
+ a board offering choices, a constrained client or two β and picks Sprint 18's callout type:
+ emergency mid-storm arrival, the cowboy audit, or warranty-as-a-full-night.**
[I] 2026-07-21 β **SPRINT 17 (THE BOOK OF CLIENTS, PART 1) INTEGRATION COMPLETE (main).** D's
second pass merged fast-forward; selftest **515/0/0** on the final tree (510 part-1 + D's
5 pins, the exact sum), verified in the browser. Deploy NOT yet run: the session's
diff --git a/tools/site_audit/gardenfly.selftest.js b/tools/site_audit/gardenfly.selftest.js
index f2b8ff3..2791ba5 100644
--- a/tools/site_audit/gardenfly.selftest.js
+++ b/tools/site_audit/gardenfly.selftest.js
@@ -33,6 +33,15 @@ import { createSkyFx } from '../../web/world/js/skyfx.js';
import { createGarden } from '../../web/world/js/garden.js';
import { FIXED_DT } from '../../web/world/js/contracts.js';
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
+import { buildScoringWorld, collateralExposure } from './scorecard.js';
+import { factoryExtras } from '../../web/world/js/ratings.js';
+
+// SPRINT17 gate 3 [B] β CROSS-LANE SEAM. The corroded tier (rating_hint 0.55,
+// the GLB, the gen entry) lives on lane/e. On a bare lane/b it is absent, so
+// this pin SKIPS WITH A DISCLOSURE and goes live at integration β the same
+// "build against the shape as pushed" seam the sprint runs on. Measured GREEN
+// on the scratch merge (lane/a + lane/e): hint 0.55, collateral $45.
+const CORRODED_TIER_LIVE = !!(factoryExtras('corroded_post'));
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
@@ -156,7 +165,62 @@ export async function buildGardenflyTests() {
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
+ // SPRINT17 gate 3 support [B]: the corroded tier priced through the CARD'S
+ // OWN path, in a real dressed world. E landed the post; the 0.2/0.3/2 pass
+ // deferred gate 3; this pins that a corroded anchor prices honestly the
+ // moment it is placed. Node-less placement (E's THREADS: "place with a plain
+ // entry and no node field and the rating still lands") β the harder case,
+ // where the rating and the collateral both come from the factory, not a
+ // dress. buildScoringWorld only DRESSES (no sweep), so this is ~40 ms.
+ let corRun = null, corErr = null;
+ try {
+ if (!CORRODED_TIER_LIVE) throw { skip: true };
+ const corSite = await loadSite('backyard_01');
+ corSite.structures = [...(corSite.structures ?? []), {
+ id: 'cor1', model: 'sail_post_corroded_v1', wreckedModel: 'sail_post_corroded_wrecked_v1',
+ x: 6, z: 2, rotYDeg: 0, solid: true,
+ collateralKey: 'corroded_post', collateralValue: 45, collateralLabel: 'the corroded post',
+ anchors: [{ id: 'cor1_a1', type: 'corroded_post', work: 'cloth' }],
+ }];
+ const built = await buildScoringWorld(corSite);
+ const anc = built.anchors.find((a) => a.id === 'cor1_a1');
+ const col = {};
+ for (const a of built.anchors) {
+ if (!a.collateral) continue;
+ const p = built.world.collateralFor(a.collateral);
+ col[a.id] = p ? { key: a.collateral, cost: p.cost, label: p.label }
+ : { key: a.collateral, cost: null, label: a.collateral, unpriced: true };
+ }
+ corRun = { anchor: anc, priced: built.world.collateralFor('corroded_post'),
+ exposure: collateralExposure({ collateral: col }), dressed: built.dressed, dressError: built.dressError };
+ } catch (err) { if (err && err.skip) corErr = 'skip'; else corErr = String((err && err.stack) || err); }
+
return [
+ ['gate 3: a corroded post prices honestly in the card β 0.55 hint, $45 collateral, dressed', () => {
+ if (!CORRODED_TIER_LIVE) return 'SKIPPED β corroded tier lands with lane/e; measured GREEN on '
+ + 'the scratch merge (hint 0.55, collateral $45 through collateralFor)';
+ if (corErr) throw new Error(`corroded pricing died: ${corErr}`);
+ assert(corRun.dressed, `the corroded world did not dress (${corRun.dressError}) β GLB missing or broke`);
+ const a = corRun.anchor;
+ assert(a, 'the corroded anchor cor1_a1 was not created');
+ // The STEEL half, node-less: factoryExtras had to reach it, or the tier
+ // math prices this trap as the best steel in the game (adoptAnchor's
+ // `?? 1`, E's load-bearing _v1 warning).
+ assert(a.ratingHint === 0.55, `corroded anchor rated ${a.ratingHint}, not 0.55 β a node-less placement `
+ + 'lost E\'s factory hint and the card would price it as sound steel');
+ assert(a.collateral === 'corroded_post', `corroded anchor collateral "${a.collateral}", not "corroded_post"`);
+ // The COLLATERAL half, through the card's OWN resolver and exposure sum β
+ // not the baked GLB value read directly (the carport-to-null shortcut).
+ assert(corRun.priced && corRun.priced.cost === 45,
+ `world.collateralFor('corroded_post') gave ${JSON.stringify(corRun.priced)}, expected $45`);
+ assert(corRun.priced.label === 'the corroded post', `collateral label "${corRun.priced.label}"`);
+ const line = corRun.exposure.priced.find((e) => e.key === 'corroded_post');
+ assert(line && line.cost === 45,
+ `collateralExposure has no $45 corroded line (${JSON.stringify(corRun.exposure.priced.map((e) => [e.key, e.cost]))})`);
+ assert(!corRun.exposure.unpriced.some((e) => e.key === 'corroded_post'),
+ 'the corroded post landed in UNPRICED β the exposure total silently drops its $45');
+ return `corroded_post: hint ${a.ratingHint}, collateral $${corRun.priced.cost} "${corRun.priced.label}", in the exposure sum`;
+ }],
['gardenfly: the pinned separation line, flown in BOTH chains β the 12s-skew disagreement, pinned', () => {
// β THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
// the pinned recipe HOLDS and rigging matters by a full state's width β
diff --git a/web/world/js/hud.js b/web/world/js/hud.js
index a99754e..3cf88ba 100644
--- a/web/world/js/hud.js
+++ b/web/world/js/hud.js
@@ -331,6 +331,11 @@ const CSS = `
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
font-size:12px; color:#8ba0ad; padding:2px 0; }
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
+/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
+ because the soaker's whole trap is that its wind reads gentle β the one line
+ a player must not skim past. */
+#hud-card .offer .line.wx { color:#8ba0ad; }
+#hud-card .offer .line.hail span { color:#cbd8e0; }
/* The two numbers a tradie decides on. The fee is the reason to say yes and
the exposure is the reason to say no, so they are weighted against each
other rather than both being grey: money in, money at risk. */
@@ -816,14 +821,17 @@ export function createHud(d) {
const offerHtml = (o, i) => {
const n = o.night;
const cons = o.constraints ?? [];
- const f = o.forecast;
+ // SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
+ // band (wind Β· hail Β· rain Β· change Β· confidence) instead of picking
+ // two forecast fields, so an offer card can never hide a night's hail.
+ // The .hail line is lit brighter because it is the one a player must
+ // not skim past β the soaker's whole trap is a gentle-reading wind.
return `