diff --git a/THREADS.md b/THREADS.md index fd0a2e8..5adf8f9 100644 --- a/THREADS.md +++ b/THREADS.md @@ -5155,3 +5155,336 @@ anchors are your GLB), but the tooling is now waiting, not TODO. (audit.html:69) β€” same species as my landmine 2. Post-fuse re-audit should fly world.anchors live or every tr1 line stays optimistic; and note sweep2's corrected table still ranks tr1+q1+q3+q4 top on cover-AND-hold, so the tree line is worth pricing right. +--- + +[B] 2026-07-21 β€” 🌱 **GATE 1 EVIDENCE β€” C, A: I flew the sim's own garden loop for every night. The + sail's entire value is the hail it catches, and on three nights of five that value is ≀7.5 HP.** + New tool: `tools/site_audit/garden_probe.html` (browser β€” skyfx needs `document`, node throws, so + garden prediction can never live in audit.mjs). It steps `sky.gardenExposure` / + `gardenHailExposure` exactly the way `main.js:931-941` does, per rig line, and prints the garden HP + the sim would report. **Control: every corner is a rated shackle and nothing fails β€” so these are + the sail's BEST CASE. A real night with a blown corner is worse.** + + | night | storm | hail size | bare bed HP | best rig HP | **the sail is worth** | + |------:|-------|----------:|------------:|------------:|----------------------:| + | 1 | gentle | *none* | 97.6 | 99.1 | **+1.5** | + | 2 | southerly | 0.7 | 83.7 | 90.1 | **+6.4** | + | 3 | early buster | 0.7 | 82.6 | 90.1 | **+7.5** | + | 4 | wild night | 1.3 | 35.7 | 60.5 | **+24.8** | + | 5 | ice night | 1.4 | 0.0 | 27.7 | **+27.7** | + + (best-of-sample: 6-8 holdable lines spread across the cover range per night, not an exhaustive + sweep β€” the bare-bed control and the ranking exhibits below are exact.) + + **The separation IS the hail size, exactly as A's GARDEN_DRAIN comment predicted.** Cloth blocks + 100% of 1.3/1.4 stones and 74% of 0.7 pea hail (C's `hailBlockFor` ruling), and rain at a 73Β° lean + walks under and contributes ~nothing on every night. So the sail's worth tracks one variable: + hail 1.3+ β†’ +25-28 HP; pea hail β†’ +6-7; **no hail at all β†’ +1.5.** Night 1 is "learn the anchors" + and the anchors are worth one and a half HP. This isn't a constant that needs nudging β€” GARDEN_DRAIN + scales bare bed and perfect rig by the SAME factor, so no value of it can separate them. C: the + geometry is the bug and the data names your lever β€” **rain is already doing nothing (2.4-12.9 HP a + night); hail is doing everything.** Whatever you land, the three low-hail nights are where the + fantasy is currently false. + + **⚠️ A β€” two balance facts that fall out of this, before you rule on the separation target:** + 1. **The ice night's garden cannot be saved at any price.** Best line I found: **27.7 HP**, against + your `WIN = hp >= 50` ruling. Bare bed is 0.0 β€” the garden dies outright. Night 5 is currently + a guaranteed garden loss no matter what the player buys. Intended as the disaster night, or a + separation target that wants checking against the win line? (Once C's fix lands, this number + moves β€” but it moves for the bare bed too.) + 2. **Night 1's sail is worth 1.5 HP.** A first night that teaches "the seedlings are the whole + point" currently teaches that the sail is decoration. If C's fix is hail-shaped, gentle has no + hail to give it β€” night 1 may need a hail term or an honest re-frame. + +[B] 2026-07-21 β€” πŸ™ **A β€” one export, and it's gate 1.2's blocker: the garden model is private to + main.js.** `createGarden`, `HAIL_WEIGHT`, `RAIN_WEIGHT`, `GARDEN_DRAIN` are module-locals. Gate 1.2 + asks the audit to PREDICT the sim's garden β€” "same number, two harnesses" β€” and I cannot do that + without either sharing your model or **copying three constants that C is about to change this very + sprint**. A copy would be the exact drift this repo keeps getting bitten by; it's why `sweep.js` + exists as one shared copy of the winnability math across both audit front-ends. + + **Ask: export the garden β€” `createGarden` + the weights β€” ideally as `js/garden.js` that main.js + imports.** It's a mechanical lift of main.js:82-160 and I'm happy to do it myself if you'd rather + hand me the file; it's your module, so it's your call. Same route `setWorld`/`setBudget` took: + I faked, asked, you landed, I deleted the fake. **The fake is live now and marked**: `garden_probe.html` + carries a TEMPORARY COPY of the three constants with a comment naming this ask. It dies the day the + export lands, and until then nothing SHIPS against it β€” the probe is evidence, not a feature. + +[B] 2026-07-21 β€” πŸ” **My audit's "cover%" is the SUN shadow, and C wrote down why that's wrong two + sprints ago. It doesn't just fail to predict the garden β€” on the wild night it predicts it + BACKWARDS.** Gate 1.2's root cause, found and owned. + + `auditSweep` scores `rig.coverageOver(bed, {x:0,y:1,z:0})` β€” shade from straight overhead. + `skyfx.js:733`, C's comment on `rainShadowOver`, in as many words: *"this is NOT + rig.coverageOver(bed, world.sunDir). That one is the SUN shadow β€” the summer-afternoon question… + **during a storm at night the sun shadow is a number about nothing**."* It was right there, and my + tool has been quoting that number at three people as if it meant "will the garden live". + + The exhibits, from the probe: + Β· **wild night, cover% vs garden HP: r = βˆ’0.42.** NEGATIVE. The best garden line on the yard is + `p1,p2,p3,p4` β€” **25% cover, $65, 60.5 HP**. It beats `t2,t2b,p1,p4` β€” **71% cover, $120, + 52.8 HP**. My audit ranks the $120 line as nearly three times the sail and the sim pays it 8 HP + LESS. D's "$20 rig banked 39/45" and the QA's "+$97 on a 29%-cover line" are the same fact: + **cover% and bed-HP were never the same quantity, and I sorted the table by the wrong one.** + Β· r across the other nights: 0.87 (gentle β€” where nothing matters, so everything correlates), + 0.43, 0.52, 0.31. Small samples, but the sign flip on the wild night is not noise: it's steep + hail vs a vertical shadow measuring different geometry. + + **What I'm landing (gate 1.2), once C's model is in and A's export unblocks it:** the sweep reports + **predicted garden HP** β€” the sim's number, off the sim's own code β€” and cover% either goes or gets + demoted to a diagnostic column labelled as the sun shadow it is. Ranking moves to garden HP, which + is what "cheapest line that saves the garden" was always supposed to mean. Then the per-night + winnable-lines table gets re-posted against the new model, and if that re-breaks night balance + (4-5 are already bank-funded) the fee/bonus retune proposal comes with the table as evidence β€” A rules. + **C: ping me the moment your geometry lands and I'll re-run all five nights within the hour.** The + probe is built and parameterised; it's one URL per night. + +--- + +[B] 2026-07-18 β€” πŸ”§ **GATE 3 SMALLS β€” four landed, plus a correction to my own gate-1 flag. One of the + "smalls" was a determinism bug, not a nit.** + + **`rig.t` never reset between nights β€” and it was breaking the sim, not just the panel.** D's pool + item, filed as cosmetic; it isn't. main.js builds ONE SailRig at boot and re-`attach()`es it every + night, and `step()` runs every frame in EVERY phase once rigged β€” so the sim clock ran on through + the aftermath and the forecast card, and night 2 opened its storm wherever night 1 left off. + Measured, wild night, p1..p4 rated: + Β· clock reset (as authored): p4 **3.65** kN p2 2.78 p3 1.62 p1 0.98 + Β· clock carried to t=110: p4 **2.60** kN p2 1.92 p3 0.81 p1 0.54 + Every night after the first flew a storm 30-50% weaker than C authored β€” sampled past the end of + its own baseCurve, so no build and no peak β€” and the offset was however long you stared at the + invoice, so it wasn't even the same wrong storm twice. **A determinism break in a repo whose whole + sim rests on not having one.** Fixed in `sail.js` `attach()` (zeroes `this.t` and `this._acc`); + commit β†’ attach β†’ storm all land in one keypress, so t=0 at attach is t=0 at the first storm frame. + Asserted as the PROPERTY (two identical nights on one rig β†’ byte-equal traces), mutation-checked by + deleting the reset (night 2 diverges at sample 0). sail.selftest 40/40. + + **standing-kN preview (D's "first lesson costs $60 blind").** The prep panel now shows each corner's + still-air load against its EFFECTIVE rating, plus a summary line: `standing β‰ˆ1.03 kN/corner at this + tension (worst 1.62) β€” before any wind`. Crank tension 1.0β†’1.4 on the backyard cover quad and p4 + goes `0.23/1.2` β†’ `1.62/1.2 kN !! RIPS AT REST` β€” the drum-tight prep-rip is visible before you pay + for it. `session.standingLoads()` measures the load on UNBREAKABLE hardware (measuring the load, not + rehearsing the failure β€” attach it with the real tiers and the corner rips during the settle and + reads a serene 0 N; the selftest caught exactly that). Cross-checked against D: the 46 mΒ² carport + quad reads worst-corner 1.41 kN at tension 1.4; D measured "~1.4-1.5 kN/corner at 46 mΒ²" in play. + Two harnesses, same number. Cached on (picks, tension, fabric) so cycling hardware never recomputes. + + **D's paired panel-honesty gap, same fix.** The prep list printed the bare hardware kN while the + weak-link arrow reasoned on rating Γ— hint β€” "four equal 1.2s and one unexplained arrow". The column + now prints the effective rating and shows the hint as the discount it is: `cb1 carabiner 0.12/0.3 + kNΓ—0.22 <- weak link`. The arrow has its reason on the same line. + + **`setHardware()` no longer no-ops silently.** It THROWS on hardware that isn't a shop item (a + programmer error β€” no click can produce it), where player-reachable failures ('not rigged', 'not + enough budget') still return {ok:false}. balance.test's own comment records the old bite: "setHardware + fails silently, cheap hardware stays on, p2/p4 tear off" β€” a cascade misread as a finding. Also + catches a LOOKALIKE (a copy of a real tier): the shop compares by identity. + + **BLOWN label collision (in hud.js, which is A's file β€” flagging that I touched it).** The prompt + filed this under me as "marker layout is rigging.js", but the corner bars live in hud.js's update + loop, ~80 lines above A's card system. I added a screen-space de-collision in the bar loop only + (stack bars whose clip-space labels overlap; deterministic, O(nΒ²) on 4). **A β€” if this fights your + splash-card work, back it out and it's a 20-line hunk I'll re-land wherever you prefer; nothing else + of mine depends on it.** Verified by projecting cb1/cb2's real dressed positions through a Three + camera swept around the yard: 6 of 72 player viewpoints collide (all at ~12 m, which is where the QA + played from), and the thresholds catch exactly those and nothing closer. No headless assert β€” it + needs a live camera β€” but the sweep is in my scratch notes if anyone wants it. + + **↩ Correction to my 2026-07-21 gate-1 flag #2 (night 1's sail worth +1.5 HP).** I called that a + problem; it's intended. Night 1's job card reads "Nothing tonight can hurt the garden. Learn the + anchors." β€” it's the tutorial night by design, so +1.5 HP is correct, not a gap. **Flag #1 stands: + the ice night's garden tops out at 27.7 HP against WINβ‰₯50 β€” that's still a real question for A once + C's geometry lands.** Withdrawing the night-1 half so A isn't chasing a non-bug. + +[B] 2026-07-18 β€” ⏳ **Gate 1.2 (audit predicts the sim) and the mixed-hardware stretch are both HELD on + C's shadow-geometry fix + A's garden-model export.** No point re-ranking the audit or sweeping fuse + lines against a garden model that's about to change under me β€” I'd be measuring twice and shipping + the wrong number. `garden_probe.html` is built and parameterised (one URL per night); the moment + C's geometry lands and A exports `createGarden`, I re-run all five nights, wire the sim's garden HP + into the audit as the ranking quantity, re-post the winnable-lines table, and rule on the fuse + lines against real numbers. Ping me. Everything unblocked this sprint is done. + +--- + +[B] 2026-07-18 β€” βœ… **D, A β€” the free-infinite-spares hole: my half is landed. The count is now + reachable and the refund bug goes with it.** D's live-storm find (`interact.js` handed out + unlimited free shackles because it never read the count) β€” thanks for measuring it, that's a + difficulty hole a public player finds in one storm. + + `RiggingSession` now has the consume API D asked for: + Β· **`session.sparesRemaining`** β€” how many are still on the shed table (getter). + Β· **`session.takeSpare()`** β€” removes one, returns `{ok:false, reason:'no spares left on the + table'}` when the table's empty. + `spares` IS the table count (not a purchased total held elsewhere), so `takeSpare()` decrements it β€” + and that **fixes a second bug for free**: `main.js:720` already refunds `session.spares Γ— SPARE_COST` + as "a spare you never had to use", so before this you were refunded for spares you'd already spent. + Decrementing makes that line honest with **no change to main.js**. `reset()` zeroes it between nights. + + **D β€” the interact.js side, ready to paste** (you offered; it's your file): + ```js + // spare_table register: + canUse: (p) => !p.carrying && session.sparesRemaining > 0, // was: !p.carrying + onDone: (p, t) => { if (session.takeSpare().ok) p.pickUp('spare', t); }, + ``` + **A β€” one thread to pull:** `wireYardActions(interact, {sailRig, world})` at main.js:517 doesn't pass + the session, so interact.js can't see `sparesRemaining` yet. Thread `rigging.session` (or just a + `{sparesRemaining, takeSpare}` view) into `deps` and D's two lines above light up. That's the whole + seam β€” count (mine, done) β†’ wiring (yours) β†’ gate+consume (D's, ready). Asserted headless in + rigging.selftest ("spares are consumable and finite"): take without buying fails, the table runs + dry, and a consumed spare is no longer refundable. 21/21. + + Not in my prompt, but D flagged it to me by name and said fix-before-gate-4, and it's the spare + economy I own β€” so the count half's done rather than left for the seam to rot. + +--- + +[B] 2026-07-18 β€” πŸ”¬ **STRETCH β€” D's fuse line, ruled: the fuse is real DEPTH, keep it. But it exposed + that my audit is BLIND to it β€” it marks D's winning line unwinnable. Third face of the SPRINT6 trap.** + + D's exhibit measured (q2/q3/cb1/cb2 on the corner block, storm_03b, venturi on): + | line | cb1 peak | q3 blows | outcome | + |-----------------------------------|---------:|---------:|---------| + | unbreakable (what the audit flies)| **3122 N** | β€” | audit sees 3.1 kN on a 0.22-hint beam | + | **fuse: cb rated + q carabiner $70** | **1169 N** | t=18.5 (the change) | **HOLDS** β€” under cb1's 1430 line | + | uniform rated $120 (unbuyable) | **1602 N** | β€” | cb1 AND cb2 **BLOW** (t=41, t=48) β€” OVER 1430 | + + So D is right and it's better than "brilliant": the cheap q3 carabiner is a **fuse** β€” it lets go at + the change, the sail sheds through that corner, and cb1's peak drops from 3122 to 1169 N. The + "safer" $120 line keeps the sail intact and that's what KILLS the beam (1602 > 1430). Paying more + breaks the carport; paying less saves it. That is a genuine, physical, legible piece of depth and it + should stay exactly as it is. **Ruling: keep the fuse. It's not a hole.** + + **The hole is in MY tool.** I ran D's fuse quad through the real `auditSweep`: + `q2,q3,cb1,cb2 β†’ cb1 3.1kNβ†’NONE, cb2 1.8kNβ†’NONE, affordable:false` β€” **UNHOLDABLE.** + The audit flies UNBREAKABLE hardware (so a shed corner never sheds), prices every corner to hold its + unbroken peak, and therefore calls a line D actually WON "unwinnable at any price." That is the + SPRINT6 p1=7.4 kN failure a third time: unriggable (couldn't read the schema) β†’ cheap (couldn't see + the funnel) β†’ now **unwinnable (can't see the fuse)**. Each time the tool lied because it modelled + less than the sim. + + **Why it hasn't bitten the ship/no-ship verdict:** it's a false FAIL, not a false PASS β€” conservative. + site_02 still passes overall (64 non-fuse winning lines in SPRINT11), so the audit never called the + SITE unshippable. What it does is **hide what may be the best-value line from the player-facing + guidance** β€” "cheapest line that saves the garden" can't recommend a line it thinks is impossible. + + **Recommendation β€” fold the fix into the post-C re-audit, don't rush it now.** Making the sweep see + fuse lines means flying BREAKABLE hardware and detecting the shed/cascade, which is real work, and + D's headline claim ("the fuse is night 3's best NET, +$147") is GARDEN-inclusive β€” so it moves when + C's geometry lands. Doing it now measures a number that changes next week. When I re-run all five + nights against C's model, I'll add a breakable pass for the carport quads and report whether the + fuse line is genuinely the best net once the garden is honest. **A β€” no ruling needed from you yet; + this is a note that the audit under-counts winnable lines on fuse-shaped quads, safe for shipping, + flagged for the re-audit.** Nothing lands against it this sprint. + +[B] 2026-07-18 β€” 🌱 **GATE 1.2 LANDED: THE AUDIT PREDICTS THE GARDEN NOW β€” flown, not projected β€” + and the re-audit surfaced two findings A needs to see: the pinned separation line only reads + FULL through a harness with a 12-second clock skew, and night 5's garden cannot reach the win + line at any price.** Two commits on lane/b (the fake-kill + the rewrite), selftest **356/0/0 on + the scratch merge b+a+c@45bdc2d** β€” lane/b standalone can't run the full suite until the + branches meet (my files import A's garden.js and C's windForSite, which is the point), so the + judged merge is the honest count, D's precedent. +4 asserts, all mutation-checked (MARGIN=0 + reddens the margin test; starving gardenfly of the siteDef reddens the venturi test by name). + + **What the audit is now.** `gardenfly.js` is the scoring engine: windForSite (C's shared + builder β€” adopted, no fourth copy of site-wind wiring in this repo) β†’ real `attach` (throws on + D's skipped-attach trap) β†’ real skyfx exposure β†’ A's garden.js, per candidate line, LIVE + world.anchors via C's re-pointable wind proxy (my audit.html:69 frozen-sway remap was C's + landmine 2 wearing my filename β€” fixed). audit.html flies every affordable line + the bare bed + and judges the site's pinned `separation` block on the block's own storm; cover% survives only + as a labelled static-geometry diagnostic (it predicted the wild night BACKWARDS, r = βˆ’0.42). + C's margin rule is structural: every row prices twice β€” $holds vs $clean (β‰₯15% headroom, + AUDIT.MARGIN, one copy) β€” only clean lines are winners, and a budget that can only buy the + knife edge gets verdict `marginal-only`, which is D's 39.8-TATTERED wild night said in advance. + The phantom 12 s calm settle is GONE from the sweep (commitβ†’attachβ†’storm is one keypress β€” my + own rig.t entry's words; C measured the skew and shot their copy the same day). + + **AUDIT-PREDICTED vs SIM-ACTUAL β€” the four canonical scenarios (+1 exhibit), receipts:** + ``` + scenario (funnel ON where it exists) audit predicts sim-actual Ξ” + backyard p1..p4 $90 (D's line), wildnight 60.5 TATTERED 0/4 D UI 61.3 0.8 + backyard bare bed, wildnight 35.7 TATTERED D/C/A 35.7 0.0 + site_02 $80 line, buster 95.2 FULL 0/4 D UI 97.9 2.7* + site_02 carport bait $20, buster 82.6, 2/4 lost = bare D UI 82.6 0.0 + site_02 $80 line, WILDNIGHT (exhibit) 91.9 "FULL" ⚠q3,q4 D UI 39.8 TATT β€”** + backyard p1+p2+p3+p5 $75 (THE PIN), wildnight 63.8 TATTERED ⚠p2,p1 a.test 68.3 FULL 4.5*** + ``` + \* the honest bench-vs-UI residual C stated (~5–15%, cause in the pool); verdict identical. + \** the margin rule's whole job: q4 flies at 1.24 vs 1.20 rating β€” the audit now refuses to + call that a win, and D's play is the proof it's right to. + \*** NOT residual β€” the variable is FOUND, see next paragraph. My 95.2 buster number also + matches C's corrected bench to the decimal: two independent harnesses, one chain. + + **⚠️ A β€” FINDING 1, the pinned separation line reads FULL only through a.test's harness skew.** + a.test's separation flight settles 12 s on the calm day before flying the storm; `SailRig` + samples wind at its INTERNAL clock (sail.js:426, `this.t`), so after the settle its storm + flies 12 s off the authored curve while the sky's hail windows fly t=0..90. Isolated with a + three-way probe on the pinned line (settle kept/removed/clock-reset β€” receipts in my session, + reproducible in one page): + game-true chain (attach β†’ storm at t=0): **63.8 TATTERED**, 0/4, peaks p5 5.10 p2 3.08 p1 2.96 p3 1.81 + a.test chain (12 s settle, clock runs on): **68.4 FULL** β€” your 68.3, and your _why table's + peaks to the digit (4.88/3.14/2.91/1.82) + settle for SHAPE, clock reset to 0: 64.3 β€” the skew is the +4.1, the shape only +0.5 + C flagged this skew when they killed the bench settle ("worth 0.01 kN on backyard posts, which + is why nobody noticed") β€” the garden through the p5 line's twisted cloth is where it finally + bites, and it crosses the FULL boundary. So: **the pin's >66 is currently met by the harness, + not by the game.** gardenfly.selftest pins BOTH chains (63.8-band and 68.4-band) so whichever + way you re-rule, drift goes red by name. Your options as I see them (yours to rule): fix + a.test's flight (drop the settle, or reset rig.t after it) and re-measure the ruling β€” the pin + then needs ~3 more HP from somewhere honest (p5 position/height, tension, or C's hail + timing); or restate the threshold against game-true numbers. **D β€” the pinned line has never + been PLAYED; the UI is the arbiter a.test and I are both proxies for.** One night-4 run on + p5(R)+p1+p2+p3(S), $75, tension 1.0, garden number off the invoice, settles this. + **FINDING 1b, same line: the pin is knife-edged under C's margin rule** β€” p2 flies at 3.08 + vs a shackle's 3.20 (4% headroom), p1 at 7%. Upgrading p2 alone prices the recipe at $90 β€” + over START_BUDGET. So the backyard's only FULL-candidate line is both skew-flavoured AND + marginal. A physical nudge (p5 spec) fixes both at once; steel cannot. + **FINDING 1c, small:** the pinned quad is **45.9 mΒ²** β€” outside the audit's 18–45 band (the + band predates p5). The sweep now flies a site's pinned recipe regardless of band (flagged πŸ“Œ); + whether the BAND moves is yours β€” it mirrors a.test's rigging band, and your own ruling ships + a 45.9 mΒ² line as the best in the yard. + + **THE PAY TABLE, re-posted β€” game-true chain, funnel ON everywhere it exists, margin rule + live, garden predictions attached (successor to my Sprint-11 table):** + ``` + night storm@site bare bed cheapest CLEAN line best garden line (clean $) sail worth night result + 1 gentle @backyard 97.6 FULL $20 β†’ 97.8 FULL t2,t2b,p2,p4 $30 β†’ 99.1 FULL +1.5 WIN (tutorial, as designed) + 2 southerly @backyard 83.7 FULL $30 β†’ 87.2 FULL p5,p1,p2,p3 $40 β†’ 90.5 FULL +6.8 WIN β€” but bare ALSO wins; the bonus is the only stake + 3 buster @site_02 82.6 FULL $40 β†’ 86.4 FULL tr1,tr1b,q1,q4 $50 β†’ 91.3 FULL +8.7 WIN β€” bare also wins; bait line still dies ($205 collateral) + 4 wildnight @backyard 35.7 TATT p1..p4 $80clean β†’ 60.5 (marginal: p5 line $75 β†’ 63.8) +24.8 WIN yes / FULL no β€” nothing buyable reaches 66 game-true + 5 icenight @backyard 0.0 DEAD NONE reach the win line p1..p4 $80 ⚠ β†’ 27.7 DEAD +27.7 **UNWINNABLE β€” best garden 27.7 vs WIN β‰₯ 50, any price** + ``` + Notes: night-2's famous $20 flat quad prices "$20 holds / $40 clean" now β€” the gamble is + labelled a gamble instead of blessed (sim paid D's run 88.4; the audit's old table said 29% + cover; both were true about different quantities, that thread is closed). p5 helps night 2 + honestly (90.5, best line on the yard). Night 3's corner block is comfortably decidable at + $40–50 clean with the funnel ON β€” C's yard economy survives the correction. + + **⚠️ A β€” FINDING 2, the retune question: night 5 ends the week in a mandatory loss.** Icenight + backyard: bare bed 0.0, best buyable garden 27.7 (p1..p4 $80, ⚠p1 marginal), best clean 15.3. + Every line is under WIN's hp β‰₯ 50 β€” the week's last night cannot be won at any price, wallet + or bank. My Sprint-13 flag #1 said this before the sprint's twists; the final picture confirms + it unchanged (the model never moved β€” that was the sprint's whole finding). If night 5 IS the + disaster night by design ("the bank pays for it", your Sprint-9 ruling), then the loss wants + to be TAUGHT β€” the invoice reading "you could not have saved this" instead of implying a + better rig existed. If it isn't by design, the icenight hail budget (C's data) is the lever, + not GARDEN_DRAIN (which owns the whole week's arc, your ruling, agreed). Propose: rule it + explicitly either way; if "disaster by design", I'll wire the invoice line as gate-3-style + smalls next sprint. Nights 2–3's "bare bed also wins" is quieter but same family: on three + of five nights the garden bonus, not the win line, is the real stake β€” that reads as intended + (fee vs bonus is the difficulty gradient) but it deserves its sentence in the ruling. + + **C β€” two smalls back at you, no urgency:** (1) your bench.js still carries its marked + GARDEN_DRAIN/W_HAIL/W_RAIN copy with the match-assert β€” A's garden.js exists now, the copy can + die the way mine did. (2) windForSite adopted everywhere in site_audit, as proposed β€” and your + strictly-raises pin on the helper plus my sweep + gardenfly venturi tests makes three locks on + one door, which feels right for a door three harnesses walked into. + + **Files: gardenfly.js + gardenfly.selftest.js (new), sweep.js + sweep.selftest.js (margin rule, + game-true flight, πŸ“Œ pin sweep), audit.html (garden columns, separation verdict, live anchors), + audit.mjs (p5 in the verified dump, margin-aware output, points at browser for garden truth), + garden_probe.html (imports the real model, fake deleted per protocol), balance.test.js + (GARDEN_DRAIN/weights re-pointed at garden.js β€” A's flag closed), b.test.js (async, wires + gardenfly tests).** The b.test cornersLost fix rides along: my old probe counted `c.failed`, + a field that doesn't exist β€” corners are `.broken`; every "corners lost" probe number I posted + before today was structurally 0. The probe's hail-vs-cover conclusions survive (they were + all-held runs); the field is right now. diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html index 2ac46fe..db84ea1 100644 --- a/tools/site_audit/audit.html +++ b/tools/site_audit/audit.html @@ -1,6 +1,7 @@ @@ -45,6 +55,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; import { AUDIT, auditSweep } from './sweep.js'; +import { flyGarden, flySeparation } from './gardenfly.js'; const q = new URLSearchParams(location.search); const siteName = q.get('site') || 'backyard_01'; @@ -54,29 +65,39 @@ const el = (id) => document.getElementById(id); const loadJSON = async (path) => (await fetch(path)).json(); async function run() { - // Build the site the way the game does β€” data in, dressed yard out. + // Build the site the way the game does β€” data in, dressed yard out β€” on a + // RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly + // world.anchors THEMSELVES. The old version here remapped every anchor to a + // static `sway: () => pos`, which froze the gum tree β€” the same landmine + // that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig + // eats the tree's sway as dynamic load). Backyard posts never noticed; + // every tr1/t1/t2 line was optimistic. const site = await loadSite(siteName); const scene = new THREE.Scene(); - const calmStub = { + let currentWind = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, - gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], + gustTelegraph: () => null, eventsBetween: () => [], }; - const world = createWorld(scene, { wind: calmStub, site }); + const windProxy = { + sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => currentWind.speedAt(p, t), + rainAt: (t) => currentWind.rainAt(t), + rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0), + gustTelegraph: (t) => currentWind.gustTelegraph(t), + eventsBetween: (a, b) => currentWind.eventsBetween(a, b), + setSheltersFromTrees() {}, + }; + const use = (w) => { currentWind = w; }; + const world = createWorld(scene, { wind: windProxy, site }); let dressed = false; if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } } - // world.anchors are the REAL dressed positions β€” freeze sway like balance.test. - // ratingHint rides along (SPRINT12): the sweep prices hardware against - // rating Γ— hint, and dropping it here would audit a yard where the fascia - // and the carport beam are honest steel β€” the exact lie the wiring killed. - const anchors = world.anchors.map((a) => { - const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; - return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos }; - }); + // world.anchors, LIVE β€” sway closures and ratingHint intact. The sweep and + // every garden flight below re-point the proxy at their own wind via `use`. + const anchors = world.anchors; const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`); - const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`); const vlist = site.wind?.venturi ?? []; const vtxt = vlist.length @@ -89,23 +110,86 @@ async function run() { `venturi: ${vtxt}\n` + `shop: $${START_BUDGET} Β· ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' Β· ')}`; - // The venturi is the SITE's, not the storm's β€” main.js:424 sets it on the wind - // at every site load, so the audit must too or the corner block flies with its - // funnel switched off (an easier yard than ships). - const venturi = site.wind?.venturi ?? []; - const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi }); + // The venturi rides in on the SITE def β€” windForSite (C's shared builder, + // inside sweep.js and gardenfly.js) is the ONE door site wind comes through + // now. Three harnesses independently mis-built it; there is no fourth copy. + const bed = world.gardenBed; + const { cands, rows, verdict, winners, marginalWinners } = + auditSweep({ anchors, bed, stormDef, siteDef: site, use }); - // render rows + // ── SPRINT13: fly the garden for every line the budget can actually buy ── + // The scoring quantity is the FLOWN outcome (gardenfly.js β€” real attach, + // real skyfx exposure, real garden.js), not static cover%. Marginal lines + // fly too, deliberately: they are the trap the margin rule exists to name. + const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use }); + const FLY_CAP = 12; + const flown = new Map(); + for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) { + // clean winners fly the CLEAN tiers (what the audit recommends buying); + // marginal winners fly the knife-edge tiers (the trap, priced as sold). + const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId + flown.set(r.ids.join(','), flyGarden({ + anchors, bed, stormDef, siteDef: site, use, + ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)), + })); + } + const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP); + + // best GARDEN line the budget buys β€” the audit's answer to "what should I + // rig", which used to be "the cheapest line that holds" and is now "the line + // that saves the garden" β€” cheapest on ties, and never a marginal one (a + // marginal flight is C's 91.9-FULL illusion; it gets reported, not sold). + const rowBy = (key) => rows.find((w) => w.ids.join(',') === key); + const pickBest = (entries) => entries.reduce((best, [key, g]) => { + if (!best) return { key, g }; + if (g.hp > best.g.hp + 0.05) return { key, g }; + if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g }; + return best; + }, null); + const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean); + const bestGarden = pickBest(cleanFlown); + const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean)); + + // ── the site's pinned separation target (A's gate-1.4 ruling), judged on the + // block's OWN storm β€” the target is site data, not a per-run choice. + let sep = null, sepStormName = null; + if (site.separation) { + sepStormName = site.separation.stormKey; + const sepStorm = sepStormName === stormName ? stormDef + : await loadJSON(`../../web/world/data/storms/${sepStormName}.json`); + sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use }); + } + + // render rows β€” cover% is a DIAGNOSTIC column now (static overhead projection + // of the taut attach shape); the flown garden column is what scores. const tbl = document.createElement('table'); + const hd = tbl.insertRow(); + for (const h of ['line', 'mΒ²', 'vert cover (static)', 'holds?', 'garden (flown)', 'corners: peak β†’ tier']) { + const td = hd.insertCell(); td.textContent = h; td.className = 'corner'; + } for (const r of rows) { const tr = tbl.insertRow(); - tr.insertCell().textContent = r.ids.join(','); + tr.insertCell().textContent = `${r.pinned ? 'πŸ“Œ ' : ''}${r.ids.join(',')}`; tr.insertCell().textContent = `${r.area.toFixed(0)} mΒ²`; - tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`; + tr.insertCell().textContent = `${(r.cover * 100).toFixed(0)}%`; const mk = tr.insertCell(); if (r.unholdable.length) { mk.textContent = 'βœ— unholdable'; mk.className = 'bad'; } - else if (r.hw <= START_BUDGET) { mk.textContent = `βœ“ $${r.hw}`; mk.className = 'ok'; } - else { mk.textContent = `βœ— $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; } + else if (!r.affordable) { mk.textContent = `βœ— $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; } + else if (!r.clean) { + mk.textContent = `⚠ $${r.hw} MARGINAL (${r.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}%`).join(' ')})` + + ` β€” clean ${r.cleanHw != null ? `$${r.cleanHw} > $${START_BUDGET}` : 'over the shop ceiling'}`; + mk.className = 'warn'; + } else { + mk.textContent = `βœ“ $${r.cleanHw} clean${r.hw < r.cleanHw ? ` (holds at $${r.hw})` : ''}`; + mk.className = 'ok'; + } + const gd = tr.insertCell(); + const g = flown.get(r.ids.join(',')); + if (g) { + gd.textContent = `${g.hp} ${g.state.toUpperCase()}${g.cornersLost ? ` (${g.cornersLost} lost)` : ''}` + + `${g.marginal.length ? ` ⚠ ${g.marginal.join(',')} inside margin` : ''}`; + gd.className = (g.marginal.length || g.state === 'tattered') ? 'warn' : g.state === 'full' ? 'ok' : 'bad'; + } else { gd.textContent = 'β€”'; gd.className = 'corner'; } const cs = tr.insertCell(); cs.className = 'corner'; cs.innerHTML = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `Γ—${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kNβ†’${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' '); @@ -114,26 +198,79 @@ async function run() { // verdict const v = el('verdict'); + const gardenLine = () => { + const bg = bestGarden ? rowBy(bestGarden.key) : null; + return `\nβ€” the garden (flown, funnel ${vlist.length ? 'ON' : 'n/a'}): bare bed ${bare.hp} ${bare.state.toUpperCase()}` + + (bestGarden + ? ` Β· best clean buyable ${bg.ids.join(',')} ($${bg.cleanHw}) β†’ ${bestGarden.g.hp} ${bestGarden.g.state.toUpperCase()}` + + `${bestGarden.g.cornersLost ? ` (${bestGarden.g.cornersLost} corner(s) lost)` : ''}` + + ` Β· the sail is worth ${(bestGarden.g.hp - bare.hp) >= 0 ? '+' : ''}${(bestGarden.g.hp - bare.hp).toFixed(1)} HP` + : ' Β· NO clean buyable line to fly') + + (bestMarginal && (!bestGarden || bestMarginal.g.hp > bestGarden.g.hp) + ? `\n ⚠ best MARGINAL line ${bestMarginal.key} reads ${bestMarginal.g.hp} ${bestMarginal.g.state.toUpperCase()} on paper β€” ` + + `inside the 15% break-in-game band, treat as ${bestMarginal.g.hp > bare.hp ? 'the trap, not the answer' : 'dead'}` + : '') + + (skipped ? `\n (${skipped} affordable line(s) beyond the ${FLY_CAP}-flight cap not flown)` : ''); + }; + const sepLine = () => { + if (!sep) return `\nβ€” separation target: none pinned in the site JSON (A's gate-1.4 block) β€” nothing to judge against.`; + const s = site.separation; + return `\nβ€” separation target (${sepStormName}): ` + + `held ${sep.held.hp} ${sep.held.state.toUpperCase()} ($${sep.held.cost}, ${sep.held.cornersLost}/4 lost` + + `) vs pinned >${s.heldMustExceed}` + + ` Β· bare ${sep.bare.hp} vs pinned <${s.bareMustLoseBelow}` + + ` β†’ ${sep.ok ? 'MEETS the target' : `FAILS (${[!sep.heldOk && 'held not FULL', !sep.heldWin && 'held does not WIN', !sep.bareOk && 'bare does not lose'].filter(Boolean).join(', ')})`}` + + (sep.ok && !sep.heldClean + ? `\n ⚠ but the pinned line is KNIFE-EDGED: ${sep.held.marginal.map((id) => { + const p = sep.held.peaks.find((x) => x.id === id); + return `${id} ${(p.headroom * 100).toFixed(0)}% headroom (${p.peakN}/${p.effN} N)`; + }).join(', ')} β€” inside the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band. ` + + `Flagged to A/D in THREADS, not overruled here: the margin rule's cause is unfound and this yard's ` + + `bench has matched the UI to the decimal β€” but nobody has PLAYED this line yet.` + : ''); + }; if (verdict.code === 'no-cover') { v.className = 'verdict fail'; v.textContent = `βœ— FAIL β€” no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} mΒ² band shades the bed at all.\n` + `The site cannot be rigged. It needs an anchor near the bed before it ships.`; + } else if (verdict.code === 'marginal-only') { + const b = verdict.best; + v.className = 'verdict fail'; + v.textContent = `βœ— MARGINAL ONLY β€” the budget's ${marginalWinners.length} affordable line(s) all carry a corner inside ` + + `the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (best: ${b.ids.join(',')} at $${b.hw}, ` + + `${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')}; ` + + `clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).\n` + + `On the bench it holds; in the real game it is D's 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.` + + gardenLine() + sepLine(); } else if (!verdict.ok) { const b = verdict.best; v.className = 'verdict fail'; v.textContent = `βœ— FAIL β€” no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` + (b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.') + - `\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.`; + `\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.` + + gardenLine() + sepLine(); } else { const w = verdict.best; - v.className = 'verdict pass'; - v.textContent = `βœ“ PASS β€” ${winners.length} affordable line(s). Best: ${w.ids.join(',')} β€” $${w.hw} hardware` + - `${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` β€” no room for a $${AUDIT.SPARE_COST} spare`}` + - `, ${w.area.toFixed(0)} mΒ², ${(w.cover * 100).toFixed(0)}% of the bed.`; + const wTotal = w.cleanHw + AUDIT.SPARE_COST; + v.className = (sep && !sep.ok) ? 'verdict fail' : 'verdict pass'; + v.textContent = `${(sep && !sep.ok) ? 'βœ—' : 'βœ“'} winnability: ${winners.length} clean affordable line(s)` + + `${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}; cheapest clean ${w.ids.join(',')} β€” $${w.cleanHw} hardware` + + `${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, inside budget)` : ` β€” no room for a $${AUDIT.SPARE_COST} spare`}.` + + gardenLine() + sepLine(); } // a machine-readable line, so this page can also be driven headless-in-browser - window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) }; - document.title = `site_audit β€” ${verdict.ok ? 'PASS' : 'FAIL'}`; + window.__audit = { + site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, + cands: cands.length, verdict, + winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })), + marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw, + marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })), + garden: flown.get(w.ids.join(',')) ?? null })), + bare, bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null, + bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null, + separation: sep ? { storm: sepStormName, ...sep } : null, + }; + document.title = `site_audit β€” ${verdict.ok ? 'PASS' : verdict.code.toUpperCase()}${sep ? ` Β· sep ${sep.ok ? 'MEETS' : 'FAILS'}` : ''}`; } run().catch((e) => { diff --git a/tools/site_audit/audit.mjs b/tools/site_audit/audit.mjs index a82a91b..a2c1308 100644 --- a/tools/site_audit/audit.mjs +++ b/tools/site_audit/audit.mjs @@ -16,11 +16,19 @@ * balance decision disguised as art, and it should be audited the minute it's * drawn, not the sprint after it ships. * - * What it does NOT do: score the garden. Winnability is decided before any of - * that β€” by whether a quad exists that (a) covers the bed and (b) has peak - * corner loads the budget can hold. The garden drain only decides how BADLY you - * lose a site that was already unwinnable. That's also why this runs in node: - * no skyfx, no document, no browser, ~20 s per site. + * What it does NOT do: score the garden β€” and SPRINT13 made the garden the + * scoring quantity (the audit's old static cover% predicted the wild night's + * garden BACKWARDS, r = βˆ’0.42). skyfx needs `document`, so garden truth lives + * in the BROWSER front-end: audit.html flies every affordable line through + * gardenfly.js (real attach β†’ skyfx exposure β†’ garden.js) and judges the + * site's pinned `separation` block. This node tool remains the fast + * winnability check β€” peaks, pricing, the margin rule β€” and it TELLS you + * where the garden verdict is rather than pretending it has one. + * + * Known blindness beyond dressing (stated, not hidden): node hands the sweep + * STATIC anchors, so tree sway β€” real dynamic load on any tr1/t1/t2 line, + * C's landmine 2 β€” is frozen here. Post lines are exact; tree lines read + * optimistic. The browser front-end flies world.anchors live. */ import { readFile } from 'node:fs/promises'; @@ -94,11 +102,15 @@ const BACKYARD_01 = { ['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0], ['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0], ['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0], + // SPRINT13: p5, the clothesline post β€” A's gate-1 anchor (the ruling that + // gave the backyard a FULL-capable wild-night line). h 2.6 in the JSON, + // raked like every post; verified live like every post. + ['p5', 'post', 5.840064309022781, 2.591333620780842, -2.1236597487355566, 1.0], ].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })), }; /** Anchors dress() never touches β€” so live world.js IS authoritative for these. */ -const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']); +const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']); /** * Re-derive the posts from live world.js and fail loudly if the dump drifted. @@ -187,7 +199,9 @@ async function loadSite(path) { })); // A resolved export still owes us the site's funnel β€” the venturi is site data, // not storm data, and a sweep without it flies an easier yard than ships. - return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] }; + // The separation block rides along so the tool can at least PRINT the pin. + return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, + venturi: j.wind?.venturi ?? [], separation: j.separation ?? null }; } const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos })); @@ -196,7 +210,6 @@ async function main() { const site = await loadSite(sitePath); const anchors = withSway(site.anchors); const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8')); - const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8')); console.log(`\nsite_audit β€” ${site.name}`); if (site.dumped) { @@ -211,6 +224,9 @@ async function main() { console.log(` posts verified against live world.js βœ“ ` + `dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`); console.log(` (node cannot dress β€” live world.js here is the GRAYBOX yard, house at x=Β±5. See comment.)`); + // the pinned separation block is site JSON β€” read it so the dump path can print the pin + const j = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8')); + site.separation = j.separation ?? null; } console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? 'β€”'})`); const venturi = site.venturi ?? []; @@ -218,7 +234,11 @@ async function main() { console.log(`shop: $${START_BUDGET} Β· ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' Β· ')}\n`); // The sweep itself is shared with the browser front-end β€” see sweep.js. - const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi }); + // siteDef carries the venturi AND the pinned separation recipe (the pin + // sweeps regardless of the band β€” see sweep.js). + const { cands, rows, winners, marginalWinners, verdict } = + auditSweep({ anchors, bed: site.bed, stormDef: def, + siteDef: { wind: { venturi }, separation: site.separation ?? null } }); if (verdict.code === 'no-cover') { console.log(`βœ— FAIL β€” no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} mΒ² band shades the bed at all.`); @@ -226,14 +246,33 @@ async function main() { process.exit(1); } - console.log(`${cands.length} quad(s) in band shading the bed:\n`); + console.log(`${cands.length} quad(s) in band shading the bed (cover% is the STATIC vertical projection β€” a`); + console.log(`geometry diagnostic; the garden verdict is flown in audit.html via gardenfly.js):\n`); for (const r of rows) { const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `Γ—${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kNβ†’${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' '); - const mark = r.unholdable.length ? 'βœ— unholdable' : r.hw <= START_BUDGET ? `βœ“ $${r.hw}` : `βœ— $${r.hw} > $${START_BUDGET}`; - console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} mΒ² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`); + const mark = r.unholdable.length ? 'βœ— unholdable' + : !r.affordable ? `βœ— $${r.hw} > $${START_BUDGET}` + : !r.clean ? `⚠ $${r.hw} MARGINAL(${r.marginal.map((c) => c.id).join(',')})` + : `βœ“ $${r.cleanHw} clean`; + console.log(` ${((r.pinned ? 'πŸ“Œ' : '') + r.ids.join(',')).padEnd(18)} ${r.area.toFixed(0).padStart(3)} mΒ² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(22)} ${cs}`); + } + if (site.separation) { + console.log(`\nseparation target pinned (${site.separation.stormKey}): ` + + site.separation.line.map((l) => `${l.anchor} ${l.hw}`).join(' + ') + + ` β€” held >${site.separation.heldMustExceed} & WIN, bare <${site.separation.bareMustLoseBelow}.` + + `\n Judged in the BROWSER front-end (garden needs skyfx): tools/site_audit/audit.html?site=...`); } console.log(''); + if (verdict.code === 'marginal-only') { + const b = verdict.best; + console.log(`βœ— MARGINAL ONLY β€” every affordable line carries a corner inside the ` + + `${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (C's residual rule). Best: ${b.ids.join(',')} at $${b.hw}` + + ` (${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')};` + + ` clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).`); + console.log(` On this bench it holds; in the real game it is the 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.\n`); + process.exit(1); + } if (!verdict.ok) { const best = verdict.best; console.log(`βœ— FAIL β€” no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` + @@ -242,9 +281,12 @@ async function main() { process.exit(1); } const w = verdict.best; - console.log(`βœ“ PASS β€” ${winners.length} affordable line(s). Best: ${w.ids.join(',')} β€” $${w.hw} hardware` + - `${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` β€” no room for a $${AUDIT.SPARE_COST} spare`}` + - `, ${w.area.toFixed(0)} mΒ², ${(w.cover * 100).toFixed(0)}% of the bed.\n`); + const wTotal = w.cleanHw + AUDIT.SPARE_COST; + console.log(`βœ“ PASS β€” ${winners.length} clean affordable line(s)` + + `${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}. ` + + `Best clean: ${w.ids.join(',')} β€” $${w.cleanHw} hardware` + + `${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, still inside budget)` : ` β€” no room for a $${AUDIT.SPARE_COST} spare`}` + + `, ${w.area.toFixed(0)} mΒ², ${(w.cover * 100).toFixed(0)}% static cover. Garden verdict: audit.html.\n`); } main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); }); diff --git a/tools/site_audit/garden_probe.html b/tools/site_audit/garden_probe.html new file mode 100644 index 0000000..b6484b9 --- /dev/null +++ b/tools/site_audit/garden_probe.html @@ -0,0 +1,164 @@ + + + +garden_probe + +

garden_probe β€” does cover% predict the garden?

+
loading…
+
+
+ + + diff --git a/tools/site_audit/gardenfly.js b/tools/site_audit/gardenfly.js new file mode 100644 index 0000000..67890c7 --- /dev/null +++ b/tools/site_audit/gardenfly.js @@ -0,0 +1,175 @@ +/** + * gardenfly.js β€” fly a rig line through the REAL scoring chain and return the + * garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2] + * + * This module exists because the audit's old ranking quantity β€” static cover% + * of the taut attach-shape β€” predicted the wild night's garden BACKWARDS + * (r = βˆ’0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was + * bad while the sim paid it +$97. The quantity that scores is the FLOWN + * vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25 + * (garden.js), stones fall near-vertical, and a sail either blocks the hail + * over the bed or blocks nothing that matters. Only flying the real chain + * measures that, so this is the audit's scoring engine now; cover% is demoted + * to a geometry diagnostic in both front-ends. + * + * THE chain, one copy of it, shared by audit.html and garden_probe.html: + * + * windForSite(stormDef, siteDef, anchors) (C's shared builder β€” + * venturi + tree shelters, byte-for-byte main.js's site-load wiring) + * β†’ SailRig.attach(ids, hw, tension) (the real attach) + * β†’ rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call) + * β†’ createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy) + * + * NO calm settle, deliberately: in the real game the rig does not exist before + * commit, and commitβ†’attachβ†’storm is one keypress (the rig.t fix's own words), + * so the attach transient flies under STORM wind and counts. C measured the + * phantom settle's skew and removed it from the bench the same day. + * + * Landmines this file exists to never re-arm (each earned this sprint): + * + * Β· D's skipped-attach trap (skyfx.js:803): a harness that never runs + * attach() scores EVERY rig as bare β€” the bare-bed constant lies politely. + * flyGarden throws if the rig it built has no shadow mesh. + * Β· The venturi lives in the SITE def, not the storm def. THREE harnesses + * shipped that bug (my Sprint-11 audit, C's bench, D's first harness half); + * windForSite is the one door now, and the selftest mutation-checks that + * the funnel reaches this module's wind. + * Β· The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos` + * freezes the gum tree whose sway is the dynamic load a tr1 rig eats β€” + * q4 read 1.02 frozen vs 1.24 live. Callers hand this module + * world.anchors THEMSELVES and re-point the world's wind proxy via `use` + * so the sway closures sample the storm that is actually flying. + * Β· THE MARGIN RULE (C's residual): even corrected, benches under-read the + * real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool). Until + * it is found, any corner within 15% of its effective rating is scored + * "breaks in the game" β€” a flight that holds on paper with q4 at 1.24 vs + * 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden + * reports `marginal` and callers must not print PASS over it. + * + * Per-corner peaks are labelled from `corners[k].anchorId`, never input order β€” + * C's label-permutation warning (attach reorders picks into ring order). + * + * Browser-only by necessity: skyfx needs `document` (node throws), which is + * why audit.mjs (node) prints winnability and POINTS HERE for garden truth. + */ + +import { SailRig } from '../../web/world/js/sail.js'; +import { windForSite } from '../../web/world/js/weather.js'; +import { createSkyFx } from '../../web/world/js/skyfx.js'; +import { createGarden } from '../../web/world/js/garden.js'; +import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js'; +import { AUDIT } from './sweep.js'; + +// The margin rule's knob is AUDIT.MARGIN β€” sweep.js holds the ONE copy and +// both audit engines read it (a local copy here would be the exact drift this +// sprint spent itself killing). + +/** Shop hardware by its contracts.js name ("rated shackle") β€” throws on a + * stranger, because a typo'd tier silently becoming a carabiner is exactly + * the setHardware() lesson from gate 3. */ +export function hardwareByName(name) { + const hw = HARDWARE.find((h) => h.name === name); + if (!hw) throw new Error(`unknown hardware "${name}" β€” the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`); + return hw; +} + +/** + * Fly one line (or a bare bed) and return the garden verdict. + * + * @param {object} o + * @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved + * {id, type, pos, sway, ratingHint} for synthetic yards + * @param {object} o.bed garden bed rect {x, z, w, d} + * @param {object} o.stormDef storm JSON to fly + * @param {object} [o.siteDef] site JSON β€” its wind.venturi is half the yard's + * weather; omit ONLY for a site with no funnel + * @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern): + * called with this flight's wind so world.anchors' + * tree-sway closures sample the storm actually flying + * @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control) + * @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4 + * @param {number} [o.tension] + * @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }} + */ +export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) { + const wind = windForSite(stormDef, siteDef, anchors); + use?.(wind); + + let rig = null, cost = 0; + if (ids) { + const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle')); + cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0); + // shade cloth (porosity 0.30): the fabric a competent player flies β€” same as sweep.js + rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension); + // D's skipped-attach trap: a rig without its shadow mesh scores as a bare + // bed and the number LOOKS plausible. Refuse to fly it. + if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) { + throw new Error('flyGarden: rig is not attached (no shadow mesh) β€” a bypassed attach scores ' + + 'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.'); + } + } + + const sky = createSkyFx({ wind, night: true }); + const garden = createGarden({ setPlants() {} }); // THE model β€” garden.js, main.js's own + + const n = Math.round(stormDef.duration / FIXED_DT); + for (let i = 0; i < n; i++) { + const t = i * FIXED_DT; + if (rig) rig.step(FIXED_DT, wind, t); + sky.step(FIXED_DT, t, { sail: rig }); // main.js's exact third param + garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t)); + } + sky.dispose?.(); + + // labelled from the rig's OWN anchorId β€” attach reorders picks into ring + // order, and a peak quoted against input order swaps corners (C's warning). + const peaks = rig ? rig.corners.map((c) => { + const eff = c.hw.rating * (c.anchor.ratingHint ?? 1); + return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff), + headroom: +(1 - c.peakLoad / eff).toFixed(3) }; + }) : []; + + return { + hp: +garden.hp.toFixed(1), + state: garden.state, // 'full' | 'tattered' | 'dead' β€” garden.js's own thresholds + byHail: +garden.damage.hail.toFixed(1), + byRain: +garden.damage.rain.toFixed(1), + cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4, + peaks, + // C's margin rule: corners that held on paper but sit within MARGIN of + // their effective rating β€” in the real game these break. A prediction + // carrying names here is NOT a clean hold, whatever the hp says. + marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN) + .map((p) => p.id), + cost, + }; +} + +/** + * Judge a site against its pinned `separation` block (A's gate-1.4 ruling, + * backyard_01.json): fly the pinned recipe AND a bare bed on the block's own + * storm, and answer the only question the target asks β€” does the best line the + * budget buys read FULL and WIN, and does a bare bed LOSE the night. + * + * `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) β€” EXACTLY the + * rule, so this stays in lockstep with a.test's pin of the same block. + * `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on + * purpose: the pinned target is A's ruling and this module doesn't get to + * overrule it with a working rule whose cause is still unfound β€” but a pinned + * line carrying a corner inside the 15% band is a knife edge the front-end + * must SAY (it's a flag for A/D, not a verdict). + * + * @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }} + */ +export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) { + const ids = separation.line.map((l) => l.anchor); + const hw = separation.line.map((l) => hardwareByName(l.hw)); + const tension = separation.tension ?? 1.0; + const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension }); + const bare = flyGarden({ anchors, bed, stormDef, siteDef, use }); + const heldOk = held.hp > separation.heldMustExceed; + const heldWin = held.hp >= 50 && held.cornersLost < 2; + const heldClean = held.marginal.length === 0; + const bareOk = bare.hp < separation.bareMustLoseBelow; + return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk }; +} diff --git a/tools/site_audit/gardenfly.selftest.js b/tools/site_audit/gardenfly.selftest.js new file mode 100644 index 0000000..d92edd8 --- /dev/null +++ b/tools/site_audit/gardenfly.selftest.js @@ -0,0 +1,175 @@ +/** + * gardenfly.selftest.js β€” the audit's garden engine audits itself. [Lane B, SPRINT13] + * + * Browser-only (skyfx needs `document`), so unlike sweep.selftest.js this is an + * ASYNC factory: b.test.js awaits it and registers the returned [name, fn] + * pairs. The flights happen here, once; the tests assert on captured results β€” + * the same Suite-cannot-await shape a.test.js's pinned-separation flight uses. + * + * What it pins, and why each can fail: + * + * 1. TWO HARNESSES, ONE PIN. a.test flies backyard_01's `separation` block + * through the RiggingSession shop path; this flies the SAME block through + * the audit's own chain (gardenfly: windForSite, direct attach, named + * hardware). Both must land on A's ruling β€” held FULL and winning, bare + * losing. If a retune moves the wild night, both go red together and the + * argument happens in THREADS, not in a drifted tool. + * + * 2. THE VENTURI REACHES THE FLOWN WIND. Three harnesses shipped the same + * bug (site_audit SPRINT11, C's bench, D's first harness): the funnel + * lives in the SITE def and a harness that doesn't wire it flies an + * easier yard than ships. Same synthetic-yard trick as sweep.selftest: + * starve gardenfly of the siteDef's venturi and this goes red. + * + * 3. The shop has no mystery tier: hardwareByName throws on a stranger + * rather than quietly handing back nothing (the setHardware lesson). + */ + +import * as THREE from '../../web/world/vendor/three.module.js'; +import { createWorld, loadSite } from '../../web/world/js/world.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; +import { SailRig } from '../../web/world/js/sail.js'; +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'; + +const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + +/** sweep.selftest's synthetic yard: one quad, storm dead along +Z, funnel on it. */ +const SYN_ANCHORS = [ + { id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } }, + { id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } }, + { id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } }, + { id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } }, +].map((a) => ({ ...a, sway: () => a.pos })); +const SYN_BED = { x: 0, z: 0, w: 4, d: 4 }; +const SYN_STORM = { id: 'gardenfly_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14, + gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 } }; +const SYN_FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }]; + +export async function buildGardenflyTests() { + // The real yard, the way the game builds it β€” on a re-pointable wind proxy + // (C's bench pattern) so world.anchors fly THEMSELVES with live sway (a + // static remap froze the gum tree, C's landmine 2). Fail LOUD if dress + // fails β€” an undressed yard has no fascia hint, and a garden number + // measured on it is about a different game (C's bare-specifier gun). + const site = await loadSite('backyard_01'); + let currentWind = { + sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), + speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, + gustTelegraph: () => null, eventsBetween: () => [], + }; + const windProxy = { + sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => currentWind.speedAt(p, t), + rainAt: (t) => currentWind.rainAt(t), + rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0), + gustTelegraph: (t) => currentWind.gustTelegraph(t), + eventsBetween: (a, b) => currentWind.eventsBetween(a, b), + setSheltersFromTrees() {}, + }; + const use = (w) => { currentWind = w; }; + const world = createWorld(new THREE.Scene(), { wind: windProxy, site }); + await world.dress(); + + let sepRun = null, skewRun = null, sepErr = null; + if (site.separation) { + try { + const stormDef = await loadStorm(site.separation.stormKey); + sepRun = flySeparation({ + anchors: world.anchors, bed: world.gardenBed, + separation: site.separation, stormDef, siteDef: site, use, + }); + // The OTHER harness's chain, reproduced on purpose: a.test settles 12 s + // on the calm day before the storm, and SailRig samples wind at its + // INTERNAL clock β€” so its storm flies 12 s off the authored curve + // against the sky's t. Measured (2026-07-18, tmp probe β†’ THREADS): the + // skew is worth +4.6 HP on the pinned line (63.8 game-true vs 68.4 + // skewed; A's 68.3 to within 0.1, peaks to the digit). This flight + // exists so BOTH harnesses are pinned from one file: if either chain + // drifts, the agreement asserts below go red and name which one. + const calmDef = await loadStorm('storm_01_gentle'); + const sep = site.separation; + const wind = windForSite(stormDef, site, world.anchors); + const calm = windForSite(calmDef, site, world.anchors); + use(calm); + const rig = new SailRig({ anchors: world.anchors, gridN: 10, porosity: 0.30 }) + .attach(sep.line.map((l) => l.anchor), sep.line.map((l) => hardwareByName(l.hw)), sep.tension ?? 1); + for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calm, (i * FIXED_DT) % 3); + use(wind); + const sky = createSkyFx({ wind, night: true }); + const garden = createGarden({ setPlants() {} }); + const bed = world.gardenBed; + for (let i = 0, n = Math.round(stormDef.duration / FIXED_DT); i < n; i++) { + const t = i * FIXED_DT; + rig.step(FIXED_DT, wind, t); + sky.step(FIXED_DT, t, { sail: rig }); + garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t)); + } + sky.dispose?.(); + skewRun = { hp: +garden.hp.toFixed(1), lost: rig.corners.filter((c) => c.broken).length }; + } catch (err) { sepErr = String((err && err.stack) || err); } + } + + let venRun = null, venErr = null; + try { + const fly = (venturi) => flyGarden({ + anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM, + siteDef: { wind: { venturi } }, + ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'), + }); + venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) }; + } catch (err) { venErr = String((err && err.stack) || err); } + + return [ + ['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 β€” + // but the >66 FULL half of the target is only met in a.test's chain, + // whose 12 s calm settle runs the storm 12 s off the authored curve + // (SailRig samples wind at its INTERNAL clock). The game-true chain + // (commitβ†’attachβ†’storm at t=0, this module) reads ~63.8 TATTERED. + // Ruled numbers are A's; the game is D's to play; this test pins BOTH + // measurements so any drift in either chain goes red and names itself. + // When A re-rules (fix a.test's harness, restate the threshold, or + // re-steel the recipe), the constants below move WITH the ruling. + if (!site.separation) throw new Error('backyard_01 lost its separation block β€” the audit has no target to read'); + if (sepErr) throw new Error(`separation flight died: ${sepErr}`); + const s = site.separation; + assert(sepRun.held.cost <= 80, `the pinned recipe costs $${sepRun.held.cost} β€” must be night-1 buyable`); + assert(sepRun.held.cornersLost === 0, `pinned line lost ${sepRun.held.cornersLost}/4 corners β€” it must HOLD the wild night`); + assert(sepRun.bareOk, `bare bed ${sepRun.bare.hp} vs pinned <${s.bareMustLoseBelow} β€” bare must LOSE the night`); + assert(sepRun.held.hp - sepRun.bare.hp > 25, + `separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} β€” rigging must matter by a full state's width`); + // the two-chain pin: game-true in [60, 66]; a.test's skewed chain > 66. + assert(sepRun.held.hp > 60 && sepRun.held.hp <= s.heldMustExceed, + `game-true chain reads ${sepRun.held.hp} β€” measured 63.8 at landing, BELOW the pinned >${s.heldMustExceed}. ` + + `If this now exceeds the pin, the disagreement is RESOLVED: delete this ceiling, assert heldOk, ` + + `and close the [B] THREADS flag`); + assert(skewRun.lost === 0 && skewRun.hp > s.heldMustExceed, + `a.test's settle-skewed chain reads ${skewRun.hp} β€” measured 68.4 at landing (a.test's own 68.3). ` + + `If this dropped, a.test's pin is about to go red too: the wild night itself moved`); + assert(sepRun.ok === (sepRun.heldOk && sepRun.heldWin && sepRun.bareOk), + 'flySeparation.ok must agree with its own parts'); + }], + ['gardenfly: the SITE venturi reaches the flown wind (the bench landmine, third time charmed)', () => { + if (venErr) throw new Error(`venturi flight died: ${venErr}`); + const { bare, funnelled } = venRun; + assert(bare.peaks.length === 4 && funnelled.peaks.length === 4, 'both flights fly 4 corners'); + for (const b of bare.peaks) { + // align by anchorId β€” peaks are ring-ordered, never input-ordered (C's warning) + const f = funnelled.peaks.find((p) => p.id === b.id); + assert(f.peakN > b.peakN, + `corner ${b.id}: funnelled peak ${f.peakN} N is not above bare ${b.peakN} N β€” ` + + `the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`); + } + }], + ['gardenfly: unknown hardware names throw β€” the shop has no mystery tier', () => { + let threw = false; + try { hardwareByName('titanium dream'); } catch { threw = true; } + assert(threw, 'hardwareByName("titanium dream") must throw, not hand back undefined'); + assert(hardwareByName('rated shackle').rating === 6500, 'and the real names still resolve'); + }], + ]; +} diff --git a/tools/site_audit/sweep.js b/tools/site_audit/sweep.js index 1da3f2e..2501913 100644 --- a/tools/site_audit/sweep.js +++ b/tools/site_audit/sweep.js @@ -7,25 +7,44 @@ * differ in how they GET the anchors and how they PRINT the result. A tool built * to catch reimplemented-formula drift must not carry two copies of its own math. * - * Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}), - * the bed rect, the storm + calm-day defs, and the site's venturi list. It flies - * the same settle+storm the game flies and returns ranked rows + a verdict. - * No I/O, no process, no DOM. + * Pure given its inputs: hand it anchors (world.anchors themselves, ideally β€” + * live sway matters, see below), the bed rect, the storm def and the SITE def, + * and it flies the same attachβ†’storm chain the game flies and returns ranked + * rows + a verdict. No I/O, no process, no DOM. + * + * SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) β€” a + * corner priced inside 15% of its effective rating holds on this bench and + * "breaks in the game" (C's residual, cause unfound), so every row prices + * twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines + * are winners. The garden side of the audit lives in gardenfly.js (browser). */ import { SailRig, orderRing } from '../../web/world/js/sail.js'; -import { createWind } from '../../web/world/js/weather.js'; +import { windForSite } from '../../web/world/js/weather.js'; import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js'; /** Audit knobs, in one place so both front-ends and any future site agree. */ export const AUDIT = { BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, mΒ² MIN_COVER: 0.25, // A's "shades the bed" bar - SETTLE_S: 12, // D's settle β€” a player plays through prep - PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test) SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer - CALM_STORM: 'storm_01_gentle', + /** + * C's margin rule (SPRINT13 correction): even a corrected bench under-reads + * the real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool). + * Until it's found, a corner priced within this fraction of its effective + * rating "breaks in the game" β€” the bench held q4 at 1.24-vs-1.2 and read + * 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED. + * A line with a marginal corner is flagged, never sold as a clean PASS. + */ + MARGIN: 0.15, }; +// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom +// settle they parameterised. In the real game the rig does not exist before +// commit β€” commitβ†’attachβ†’storm is one keypress (the rig.t fix's own words) β€” +// so the attach transient flies under STORM wind and its loads count. The old +// 12 s calm settle + resetPeaks measured a chain the game never flies, and +// also skewed every storm sample 12 s off the authored curve (C measured both +// on the bench and removed its copy the same day). /** Ground-plane area, shoelace over the ring β€” the same formula a.test uses. */ export function areaOf(q) { @@ -52,20 +71,28 @@ export const tierFor = (peakN, ratingHint = 1) => /** * Sweep every bed-covering quad and price the cheapest holding line. * @param {object} o - * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway, - * ratingHint } β€” omit ratingHint and the anchor is - * priced at full hardware strength (hint 1), which - * is a LIE for any GLB-dressed anchor (fascia 0.35, + * @param {Array} o.anchors world.anchors THEMSELVES where possible (their + * sway closures carry the tree's real movement β€” a + * static remap froze the gum tree and under-read + * every tr1 corner, C's landmine 2), or resolved + * { id, type, pos, sway, ratingHint } for synthetic + * yards. Omit ratingHint and the anchor is priced + * at full hardware strength (hint 1), which is a + * LIE for any GLB-dressed anchor (fascia 0.35, * carport beam 0.22). Hand this the dressed truth. * @param {object} o.bed garden bed rect { x, z, w, d } * @param {object} o.stormDef the storm JSON to fly - * @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle) - * @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi). - * Omitted = no funnel, which is backyard_01's truth - * and a LIE on the corner block. See below. - * @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }} + * @param {object} [o.siteDef] the SITE json β€” its wind.venturi is half the + * yard's weather (three harnesses learned this the + * hard way); preferred over `venturi` + * @param {Array} [o.venturi] bare funnel list, for synthetic yards with no + * site JSON (sweep.selftest). Ignored if siteDef given. + * @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench + * pattern) β€” called with the sweep's wind so live + * tree-sway closures sample the storm being flown + * @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }} */ -export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) { +export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) { // 1. every quad, in the rigging band, that shades the bed const cands = []; for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++) @@ -81,61 +108,93 @@ export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) { if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover }); } - if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } }; + // The site's PINNED separation line sweeps regardless of the band: A's + // gate-1.4 recipe (p1+p2+p3+p5) is 45.9 mΒ² against the band's 45 β€” the band + // is an audit heuristic calibrated before p5 existed, and an audit that + // cannot see the site's own ruled-best line is auditing a different yard. + // Flagged `pinned` so front-ends can say why it's there; band-vs-pin + // reconciliation is A's (posted in THREADS). + const pinIds = siteDef?.separation?.line?.map((l) => l.anchor); + if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) { + const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean); + if (q.length === 4) { + try { + const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0); + cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true }); + } catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ } + } + } - // 2. peak corner loads, settled the way the game settles, on the real storm. - // Every clause here is a bug the first draft shipped: - // Β· createWind + setSheltersFromTrees β€” trees knock a hole downwind. - // Β· settle on the CALM day on a RUNNING clock (main.js's prep), not storm - // wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40). - // Β· resetPeaks() at entry β€” peakLoad is peak-since-ATTACH otherwise, folding - // the settle transient into the storm peak. - // Β· setVenturi β€” the SITE's funnel, and the bug this clause exists for. - // A venturi lives in the site JSON, not the storm, so a sweep built only - // from stormDef silently drops it: main.js:424 calls setVenturi on the - // wind at every site load, and this tool did not. On the corner block β€” - // whose entire weather personality IS the funnel β€” that under-reports - // every corner load and hands back an easier yard than the one that - // ships. A false PASS, which is the SPRINT6 trap wearing the other face: - // there the tool called a good site unriggable, here it would call a - // mean site cheap. Both are the tool lying about a yard it can't see. - const trees = anchors.filter((a) => a.type === 'tree'); - const wind = createWind(stormDef); - wind.setVenturi(venturi); - wind.setSheltersFromTrees(trees); - const calmWind = createWind(calmDef); - calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle - calmWind.setSheltersFromTrees(trees); + if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } }; + + // 2. peak corner loads, flown the way the GAME flies them. Every clause here + // is a bug some harness shipped: + // Β· windForSite β€” the one shared wind builder (C's helper): venturi from + // the SITE def + tree shelters, byte-for-byte main.js's site-load + // wiring. THREE harnesses independently mis-built site wind before it + // existed (this tool's Sprint-11 funnel-off audit, C's bench reading + // def.wind.venturi off the STORM def, D's first garden harness) β€” a + // fourth copy of the wiring is how there's a fifth bug. + // Β· `use` re-points the caller's world-wind proxy so LIVE tree-sway + // closures sample this sweep's storm (frozen sway under-read q4 by + // 0.22 kN on the $80 line β€” C's landmine 2). + // Β· NO calm settle, NO resetPeaks: commitβ†’attachβ†’storm is one keypress + // in the real game, so the attach transient flies under storm wind + // and its loads count (cheap steel genuinely dies "at the settle" β€” + // that's the storm's opening seconds, not a separate phase). + const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors); + use?.(wind); const rows = []; for (const cnd of cands) { // shade cloth (porosity 0.30): the fabric a competent player takes into a windy night const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }) .attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0); - for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) { - rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S); - } - rig.resetPeaks(); // ← the storm starts HERE for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT); // Price each corner against its anchor's EFFECTIVE strength. c.anchor is the // resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures. - const tiers = rig.corners.map((c) => ({ - id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1, - tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1), - })); + // + // TWO prices per corner (C's margin rule): + // `tier` cheapest hardware that HOLDS the measured peak β€” what the + // old audit sold, and what a player gambling the knife edge + // actually buys; + // `cleanTier` cheapest hardware that holds it WITH β‰₯ MARGIN headroom β€” + // the price the margin rule trusts (demand Γ· (1 βˆ’ MARGIN)). + // A corner whose `tier` sits inside the margin band is `marginal`: it + // holds on this bench and "breaks in the game" (the residual's working + // rule). The row's clean price is what closing that gap costs. + const tiers = rig.corners.map((c) => { + const hint = c.anchor.ratingHint ?? 1; + const tier = tierFor(c.peakLoad, hint); + return { id: c.anchorId, peak: c.peakLoad, hint, tier, + cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint), + headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null }; + }); const unholdable = tiers.filter((c) => !c.tier); + const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN); const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0); - rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST, - affordable: !unholdable.length && hw <= START_BUDGET }); + const cleanHw = tiers.every((c) => c.cleanTier) + ? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null; + rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw, + total: hw + AUDIT.SPARE_COST, + affordable: !unholdable.length && hw <= START_BUDGET, + clean: cleanHw != null && cleanHw <= START_BUDGET }); } rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1)); - const winners = rows.filter((r) => r.affordable); + // winners are lines the budget can buy at the CLEAN price (β‰₯15% headroom on + // every corner); a line only affordable on knife-edge steel is the wild-night + // 91.9-FULL illusion and gets its own bucket + verdict code so no front-end + // can print PASS over it by accident. + const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw); + const marginalWinners = rows.filter((r) => r.affordable && !r.clean); return { - cands, rows, winners, + cands, rows, winners, marginalWinners, verdict: winners.length ? { ok: true, code: 'pass', best: winners[0] } - : { ok: false, code: 'unaffordable', best: rows[0] }, + : marginalWinners.length + ? { ok: false, code: 'marginal-only', best: marginalWinners[0] } + : { ok: false, code: 'unaffordable', best: rows[0] }, }; } diff --git a/tools/site_audit/sweep.selftest.js b/tools/site_audit/sweep.selftest.js index b8523dd..f554efc 100644 --- a/tools/site_audit/sweep.selftest.js +++ b/tools/site_audit/sweep.selftest.js @@ -23,7 +23,8 @@ * collapse to the same peak loads and the strict inequality goes red. */ -import { auditSweep } from './sweep.js'; +import { AUDIT, auditSweep } from './sweep.js'; +import { HARDWARE } from '../../web/world/js/contracts.js'; const TESTS = []; const test = (name, fn) => TESTS.push([name, fn]); @@ -49,13 +50,12 @@ const STORM = { id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14, gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 }, }; -const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null }; /** Centred on the bed, wide enough to swallow it, aligned with the storm. */ const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }]; const peaksOf = (venturi) => { - const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi }); + const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, venturi }); assert(rows.length > 0, 'sweep selftest yard produced no candidate quad β€” fix the fixture, not the test'); return rows[0].tiers.map((c) => c.peak); }; @@ -97,7 +97,7 @@ test('pricing reads the ANCHOR, not just the steel β€” ratingHint reaches tierFo // every PEAK stays byte-identical (the hint is a pricing fact, not physics β€” // the sweep flies unbreakable audit hardware). Written to fail if the hint // is dropped from sweep.js's tierFor call: the two runs collapse into one. - const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0]; + const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }).rows[0]; const honest = sweep(ANCHORS); const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a))); @@ -119,4 +119,34 @@ test('pricing reads the ANCHOR, not just the steel β€” ratingHint reaches tierFo 'an over-ceiling corner must land in unholdable, or the verdict lies'); }); +test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => { + // SPRINT13, C's residual: even a corrected bench under-reads the real UI's + // peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game". + // The sweep must therefore refuse to call a knife-edge line a winner β€” the + // wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this + // (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8% + // headroom on a1's measured peak: still priced, still "holds" on paper, + // and the verdict must refuse to bless it. Delete the marginal logic from + // sweep.js and this goes red on the verdict code. + const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }); + const honest = sweep(ANCHORS); + const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1'); + assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1'); + assert(honest.verdict.ok && honest.verdict.code === 'pass', + 'fixture drift: the honest yard must be a clean pass or this test asserts nothing'); + + const RATED = HARDWARE.at(-1); + const hint = a1h.peak / (RATED.rating * 0.92); // β†’ best steel holds a1 with 8% headroom + const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: hint, sway: a.sway } : a))); + const row = lied.rows[0]; + const a1 = row.tiers.find((c) => c.id === 'a1'); + assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`); + assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN, + `fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`); + assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal'); + assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean'); + assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only', + `the only affordable line is marginal β€” verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`); +}); + export const SWEEP_TESTS = TESTS; diff --git a/web/world/js/hud.js b/web/world/js/hud.js index e79e000..f0eb975 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -373,6 +373,18 @@ export function createHud(d) { const quad = new THREE.PlaneGeometry(1, 1); quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward const BAR_W = 0.9, BAR_H = 0.1; + /** + * When two corner bars count as "on top of each other", in clip space (NDC + * spans -1..1 across the viewport), and how far apart to stack them. + * + * Measured off the collision the QA pass actually hit β€” CB1/CB2 on the carport + * beam, viewed from where a player stands to rig them. A bar is ~0.9 world + * units wide and scaled to hold constant screen size, so these are roughly + * "one bar wide, one line tall" and don't need to track the bar's dimensions. + * Y is the tighter of the two on purpose: labels stack vertically, so a pair + * side-by-side is already readable and only a vertical overlap is mush. + */ + const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34; const bars = []; function ensureBars(n) { @@ -430,6 +442,14 @@ export function createHud(d) { const _p = new THREE.Vector3(); const _q = new THREE.Quaternion(); const _cam = new THREE.Vector3(); + // Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where + // each bar landed in clip space, so bars that typeset on top of each other can + // be stacked instead. Pooled rather than reallocated β€” this runs every frame β€” + // and each entry carries `live`, because a corner that has no position this + // frame must not leave last frame's ghost for the others to stack against. + const _ndc = new THREE.Vector3(); + const _placed = []; + const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false })); // --- helpers ------------------------------------------------------------ const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK); @@ -602,7 +622,7 @@ export function createHud(d) { const c = d.rig.corners[i]; const b = bars[i]; const p = cornerPos(i); - if (!p) { b.holder.visible = false; continue; } + if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; } _p.set(p.x, p.y, p.z); b.holder.position.copy(_p); @@ -615,7 +635,43 @@ export function createHud(d) { // out there rather than in a corner of the screen. Clamped so it doesn't // balloon when you walk right up to a corner to repair it. const dist = _p.distanceTo(_cam); - b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7))); + const scale = Math.max(0.75, Math.min(3.2, dist / 7)); + b.holder.scale.setScalar(scale); + + /** + * Stack bars that would typeset on top of each other. [B's flag, SPRINT13] + * + * The QA pass: "CB1/CB2 died together and typeset on top of each other". + * The carport's two beam anchors are 2.82 m apart and the sail hangs + * between them, so from most of the yard their bars land in the same + * handful of pixels β€” and they are exactly the pair most likely to blow + * together, because they share a beam and a 0.22 ratingHint. The one + * moment the HUD has to be legible ("both beam corners went, that's why + * the sail is on the lawn") was the one moment it turned to mush. + * + * Screen space, not world space: whether two labels collide is a fact + * about the camera, and two corners a metre apart are unreadable from + * the fence and perfectly clear from underneath. Compared at the anchor + * point rather than after nudging, so the test is order-independent and + * a stack of three can't chase itself up the screen. Deterministic β€” + * corner index order, no clock β€” and O(nΒ²) on n=4. + * + * The offset rides `scale` so the gap is constant on screen at any range, + * the same reason the bar itself is scaled. + */ + _ndc.copy(b.holder.position).project(d.camera); + const onScreen = _ndc.z < 1; // behind the camera projects to nonsense + let clash = 0; + if (onScreen) { + for (let k = 0; k < i; k++) { + const o = _placed[k]; + if (!o || !o.live) continue; + if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++; + } + } + const slot = placedSlot(i); + slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen; + if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale; // SPRINT12 β€” B's flag, actioned: the bar reads the EFFECTIVE rating, // rating Γ— ratingHint, because that's the number sail.js fails on now. diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index 3d4fde6..4ed283c 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -12,8 +12,8 @@ * the bottom for the seam it will plug into. */ -import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js'; -import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js'; +import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js'; +import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js'; export { START_BUDGET, SPARE_COST }; export const MAX_CORNERS = 4; @@ -161,10 +161,34 @@ export class RiggingSession { return OK; } + /** + * Put specific hardware on a rigged corner, paying the difference. + * + * THROWS on hardware that isn't in the shop, where every other failure here + * returns {ok:false}. The split is deliberate and it is about who made the + * mistake: 'not rigged' and 'not enough budget' are things a PLAYER does, and + * the UI tickers the reason. Hardware that isn't in HARDWARE is not reachable + * by any click β€” the picking UI only ever cycles the shop's own list β€” so it + * can only mean a caller passed the wrong thing, and returning {ok:false} for + * that just lets a bug walk. + * + * It already had: balance.test's own comment records "setHardware fails + * silently, cheap hardware stays on, p2/p4 tear off" β€” a loadout that quietly + * kept its carabiners and then reported a cascade as if it were a finding. + * The SPRINT12 QA pass hit the same edge from the other side. A wrong tier is + * invisible in the result (the sim just runs, cheaper), so this is exactly the + * class of mistake that must not be ignorable. + */ setHardware(anchorId, hw) { + if (!HARDWARE.includes(hw)) { + throw new TypeError( + `setHardware("${anchorId}", ${JSON.stringify(hw?.name ?? hw)}) β€” not a shop item. ` + + `Pass a HARDWARE entry (${HARDWARE.map((h) => h.name).join(' / ')}), not a name or a copy: ` + + 'the shop compares by identity. A returned {ok:false} here would leave the old ' + + 'hardware on the corner and the sim would run cheaper without telling anyone.'); + } const p = this.pickOf(anchorId); if (!p) return fail('not rigged'); - if (!HARDWARE.includes(hw)) return fail('unknown hardware'); if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget'); p.hw = hw; return OK; @@ -189,7 +213,7 @@ export class RiggingSession { return this.tension; } - /** Spares are what Lane D's hold-E re-rig consumes mid-storm. */ + /** Buy (or sell back) spares in prep. `spares` is the count on the shed table. */ setSpares(n) { n = Math.max(0, Math.floor(n)); const delta = (n - this.spares) * SPARE_COST; @@ -198,6 +222,38 @@ export class RiggingSession { return OK; } + /** + * How many spares are still on the shed table. [D's ask, SPRINT13] + * + * The read half of the seam D found: interact.js gated the table pickup on + * `!player.carrying` alone and never looked at the count, so a player who + * bought ZERO walked off with unlimited free shackles mid-storm β€” the whole + * "limited hands, two trips" economy the ladder's cost is balanced against was + * free on the spare side, live on the public deploy. + */ + get sparesRemaining() { return this.spares; } + + /** + * Take one spare off the table. [D's ask, SPRINT13] + * + * The consume half. `spares` IS the count on the table, not a purchased total + * held elsewhere, so taking one decrements it β€” and that quietly fixes a + * SECOND bug for free: main.js:720 already refunds `session.spares * SPARE_COST` + * as "a spare you never had to use", so before this you were refunded for + * spares you'd already spent. Decrementing here makes that line honest with no + * change to main.js. reset() zeroes the count between nights, so nothing + * carries. + * + * interact.js calls `canUse: () => !p.carrying && session.sparesRemaining > 0` + * and `onDone: () => session.takeSpare()` once A threads the session into + * wireYardActions β€” D has the interact side ready. + */ + takeSpare() { + if (this.spares <= 0) return fail('no spares left on the table'); + this.spares -= 1; + return OK; + } + /** * Ring-order the picks by angle around their ground-plane centroid, so corner * i of the cloth grid always maps to a neighbouring anchor. Without it, @@ -232,6 +288,66 @@ export class RiggingSession { return p.hw.rating * (a?.ratingHint ?? 1); } + /** + * What each corner carries in STILL AIR at the current tension, newtons. + * Null until four corners are picked β€” a quad is the only thing that has a load. + * + * D's ask, SPRINT13 ("the first lesson always costs ~$60"): the tension dial + * has a static price and the panel never showed it. At 1.4 the standing load + * alone is ~1.4-1.5 kN/corner on a 46 mΒ² quad, which is over every tier the + * shop sells except a rated shackle on an honest anchor β€” so a drum-tight sail + * rips corners off during PREP, on the calm day, before a breath of wind. That + * reads fair when you can see it coming and daylight robbery when you can't. + * + * Still air, not "calm day": this is the number the DIAL owns. Gravity and + * tension only β€” no gusts, no forecast, nothing that could be mistaken for a + * prediction about tonight. The storm multiplies this by ~3-8x and the panel + * says "standing" for exactly that reason. + * + * Cheap enough to call on a click: 15.6 ms of settle on a 10Γ—10 grid (measured), + * and the result is cached by the caller on (picks, tension, fabric) β€” hardware + * doesn't move the cloth, so cycling tiers, the most-clicked action in prep, + * never recomputes. + * + * 4 s of settle, not 2: the cloth arrives at rest through a damped oscillation, + * and at low tension 2 s still reads ~4% high on the way down (0.195 vs 0.188 + * kN converged). 4 s is inside 1% everywhere measured and costs 8 ms more, once. + * + * Cross-checked against the game the honest way: on the corner block's real + * 46 mΒ² carport quad this reports a worst corner of 1.41 kN at tension 1.4 β€” + * D measured "~1.4-1.5 kN/corner at 46 mΒ²" in play, from the other side. + */ + standingLoads({ settle = 4.0 } = {}) { + if (this.picks.length !== MAX_CORNERS) return null; + const rig = new SailRig({ anchors: this.anchors, gridN: 10, porosity: this.fabric.porosity }); + /** + * UNBREAKABLE hardware, deliberately β€” and this is the whole subtlety. + * + * The preview measures the LOAD, which is a fact about geometry and the + * dial; it is not a rehearsal of tonight. Attach it with the player's real + * tiers and at tension 1.4 the corners genuinely rip during this very + * settle (D measured it: three go at tβ‰ˆ0.5 on the calm day) β€” and a broken + * corner carries nothing, so the panel would print a serene `0.00 kN` for + * the exact corner about to take the night down. The first version of this + * did that and the selftest below caught it. + * + * So: measure the load unbroken, then let the PANEL compare it against the + * corner's real effective rating and say "RIPS AT REST". The number stays + * honest and the warning lands where the player can act on it. + */ + const UNBREAKABLE = { name: 'preview', cost: 0, rating: Infinity }; + rig.attach(this.picks.map((p) => p.anchorId), Array(MAX_CORNERS).fill(UNBREAKABLE), this.tension); + // Still air. `sample` must honour the out-param β€” sail.js reuses one vector. + const stillAir = { + sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }), + rainMmPerHour: () => 0, + }; + for (let i = 0, n = Math.round(settle / FIXED_DT); i < n; i++) rig.step(FIXED_DT, stillAir, i * FIXED_DT); + const out = {}; + for (const c of rig.corners) out[c.anchorId] = c.load; + return out; + } + /** Everything the HUD needs to draw the prep panel, in one read. */ get summary() { return { @@ -241,7 +357,16 @@ export class RiggingSession { spares: this.spares, fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb }, canStart: this.canStart, - corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })), + // `effRating` rides along with the bare one: D's panel-honesty gap β€” the + // weak-link arrow reasoned on rating Γ— ratingHint while the printed kN was + // the bare steel, so a cold player saw four identical 1.2s and one + // unexplained arrow. The panel prints the effective number now; the bare + // rating stays for anyone who wants to show the steel's own spec. + corners: this.picks.map((p) => ({ + anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost, + effRating: this._effRating(p), + hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1, + })), // The weak link is the lowest EFFECTIVE rating β€” hw.rating Γ— the anchor's // ratingHint, the same product sail.js fails a corner on (SPRINT12, A's // ruling). D's #7: this used to reduce on hw.rating alone with strict <, @@ -407,23 +532,63 @@ export async function createRiggingUI({ return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]); } + /** + * Standing load per corner, cached on (picks, tension). [D's ask, SPRINT13] + * + * The cloth doesn't care what steel hangs off it, so cycling hardware β€” the + * most-clicked action in prep β€” never recomputes. Only closing the quad or + * moving the dial does, which is exactly when the number changes. + */ + let standingKey = null, standingCache = null; + function standing() { + const key = `${session.picks.map((p) => p.anchorId).join(',')}|${session.tension.toFixed(2)}|${session.fabric.id}`; + if (key !== standingKey) { standingKey = key; standingCache = session.standingLoads(); } + return standingCache; + } + function refresh() { if (!el) return; const s = session.summary; + const load = standing(); // padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner // block ships `tr1b` and the column broke the moment a second site existed. const rows = world.anchors.map((a) => { const pick = session.pickOf(a.id); if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)} β€”`; const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : ''; - return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`; + // The EFFECTIVE rating, not the steel's spec β€” D's panel-honesty gap: the + // arrow reasoned on rating Γ— hint while this column printed the bare kN, + // so a carport beam and a house post both read "1.2 kN" and only one of + // them was telling the truth. This is the number sail.js fails on. + const corner = s.corners.find((c) => c.anchorId === a.id); + const eff = (corner.effRating / 1000).toFixed(1); + // "1.2Γ—0.22" β€” the hint is shown as the discount it is, so the arrow has + // a reason on the same line instead of being folded into a smaller number + // the player can't account for. + const hint = corner.hint !== 1 ? `Γ—${corner.hint}` : ''; + const st = load?.[a.id] != null ? `${(load[a.id] / 1000).toFixed(2)}` : 'β€”'; + const over = load?.[a.id] != null && load[a.id] > corner.effRating; + return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${st.padStart(4)}/${eff.padEnd(4)}kN${hint.padEnd(6)} $${pick.hw.cost}` + + `${over ? ' !! RIPS AT REST' : weak}`; }); const area = quadArea(); + // D, SPRINT13: "the panel never shows standing kN at the chosen tension, so + // the first lesson always costs ~$60". At 1.4 the standing load alone is + // over most of the shop. Said as a per-corner average because that's the + // number the DIAL owns; the per-corner column above is where you find which + // one is about to let go. + const vals = load ? Object.values(load) : []; + const avg = vals.length ? vals.reduce((x, y) => x + y, 0) / vals.length : 0; + const worst = vals.length ? Math.max(...vals) : 0; + const standingLine = vals.length + ? `standing β‰ˆ${(avg / 1000).toFixed(2)} kN/corner at this tension (worst ${(worst / 1000).toFixed(2)}) β€” before any wind` + : null; el.textContent = [ `PREP β€” rig four corners $${s.budget} left`, `tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`, '', ...rows, + ...(standingLine ? ['', standingLine] : []), '', s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`, 'click anchor: rig / cycle hw shift-click: remove', diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js index 922b373..f67d9a5 100644 --- a/web/world/js/rigging.selftest.js +++ b/web/world/js/rigging.selftest.js @@ -156,6 +156,99 @@ test('setAnchors moves the session to a new yard and drops the old picks', () => return 'session followed the site; stale picks did not'; }); +test('setHardware THROWS on hardware the shop does not sell', () => { + // SPRINT13, QA pass: it returned {ok:false} and callers that didn't check + // sailed on with the old tier still hanging. balance.test's own comment + // records the bite β€” "setHardware fails silently, cheap hardware stays on, + // p2/p4 tear off" β€” a cascade reported as a finding. A wrong tier is + // invisible in the result, so this one must not be ignorable. + const s = session(); + s.rig('h1'); + let threw = null; + try { s.setHardware('h1', { name: 'titanium', cost: 0, rating: 99999 }); } + catch (e) { threw = e; } + assert(threw, 'a hardware object that is not in HARDWARE was accepted silently'); + assert(threw instanceof TypeError, `expected a TypeError, got ${threw?.constructor?.name}`); + assert(s.pickOf('h1').hw === CARABINER, 'the refused tier must not have been applied'); + // A LOOKALIKE is the real trap: same shape, same numbers, wrong identity. + let threw2 = null; + try { s.setHardware('h1', { ...CARABINER }); } catch (e) { threw2 = e; } + assert(threw2, 'a copy of a real HARDWARE entry was accepted β€” the shop compares by identity'); + // and the player-reachable failures stay {ok:false}, not throws + assert(s.setHardware('p3', RATED).ok === false, 'an unrigged corner should fail, not throw'); + return 'unknown tiers throw; player-reachable failures still return {ok:false}'; +}); + +test('standing load: the tension dial has a price, and the panel can see it', () => { + // D, SPRINT13: "the panel never shows standing kN at the chosen tension, so + // the first lesson always costs ~$60" β€” at 1.4 the standing load ALONE rips + // corners off during prep, on the calm day. This is the number that makes + // that visible before the money is spent. + const s = session(); + for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id); + + s.setTension(0.7); + const loose = s.standingLoads(); + s.setTension(1.4); + const tight = s.standingLoads(); + + assert(loose && tight, 'a closed quad must have standing loads'); + assert(Object.keys(tight).length === 4, `expected 4 corners, got ${Object.keys(tight).length}`); + for (const id of Object.keys(tight)) { + assert(Number.isFinite(tight[id]) && tight[id] > 0, `${id}: standing load ${tight[id]} is not a real load`); + // The whole point: drum-tight costs more at rest than loose does. If this + // ever ties, the dial is decoration and D's $60 lesson teaches nothing. + assert(tight[id] > loose[id], + `${id}: standing load at tension 1.4 (${tight[id].toFixed(0)} N) is not above tension 0.7 ` + + `(${loose[id].toFixed(0)} N) β€” the dial has no static price, so the prep readout is a lie`); + } + // still air means still air: no wind, no gust, nothing that could be mistaken + // for a forecast. Deterministic β€” same picks and dial, same number, always. + s.setTension(1.4); + const again = s.standingLoads(); + for (const id of Object.keys(tight)) { + assert(Math.abs(again[id] - tight[id]) < 1e-9, `${id}: standing load is not deterministic`); + } + const avg = Object.values(tight).reduce((a, b) => a + b, 0) / 4; + return `tension 0.7 -> 1.4 lifts the standing load to β‰ˆ${(avg / 1000).toFixed(2)} kN/corner, repeatably`; +}); + +test('standing load is null until the quad is closed', () => { + const s = session(); + assert(s.standingLoads() === null, 'no picks should have no standing load'); + for (const id of ['h1', 'h3', 'p1']) s.rig(id); + assert(s.standingLoads() === null, 'three corners is not a sail β€” there is no load to read'); + s.rig('p2'); + assert(s.standingLoads() !== null, 'four corners is a sail and must report'); + return 'null until four corners, then real'; +}); + +test('spares are consumable and finite β€” you cannot take one you did not buy', () => { + // D's live-storm find, SPRINT13: interact.js handed out unlimited free spares + // because it never read the count. This is the count's half of the fix β€” a + // spare economy that can actually run dry. + const s = session(); + assert(s.sparesRemaining === 0, 'a fresh session has no spares on the table'); + assert(s.takeSpare().ok === false, 'took a spare that was never bought β€” the QA bug, in one line'); + + s.setSpares(2); + assert(s.sparesRemaining === 2, `bought 2, table shows ${s.sparesRemaining}`); + assert(s.takeSpare().ok, 'first spare should come off the table'); + assert(s.takeSpare().ok, 'second spare should come off the table'); + assert(s.sparesRemaining === 0, `table should be empty, shows ${s.sparesRemaining}`); + const dry = s.takeSpare(); + assert(!dry.ok && /no spares/.test(dry.reason), `a third take must fail, got ${JSON.stringify(dry)}`); + + // The refund half: main.js pays back "a spare you never had to use" as + // session.spares Γ— SPARE_COST. A consumed spare must not still be refunded β€” + // so a taken spare has to leave the count, not just flip a used flag. + const s2 = session(); + s2.setSpares(3); + s2.takeSpare(); + assert(s2.spares === 2, `after taking 1 of 3, refundable count must be 2, is ${s2.spares}`); + return 'spares run dry, and a used spare is no longer refundable'; +}); + test('picks come back ring-ordered however you click them', () => { const s = session(); // deliberately crossing order: two diagonals first diff --git a/web/world/js/sail.js b/web/world/js/sail.js index d30ec41..614a46d 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -185,6 +185,39 @@ export class SailRig { const ring = orderRing(picked); this.tension = clamp(tension, TENSION_MIN, TENSION_MAX); + + /** + * A new sail is a new clock. [SPRINT13 β€” D's pool nit, and it wasn't a nit] + * + * `this.t` is the sim's OWN fixed-step clock: step() burns ragged frame dt + * into SIM_DT chunks and samples the wind at this.t, which is the whole + * reason a 144 Hz browser and a fast-forwarded selftest produce byte-equal + * traces. But it was only ever zeroed in the constructor, and main.js builds + * ONE SailRig at boot and re-attach()es it every night β€” while step() runs + * every frame in EVERY phase (storm, aftermath, the forecast card) the + * moment `rigged` is true. + * + * So night 2 opened its storm with the clock wherever night 1's aftermath + * left it, and sampled the storm curve from there. Measured on the wild + * night, p1..p4 with rated shackles: + * + * clock reset (as authored) p4 3.65 kN p2 2.78 p3 1.62 p1 0.98 + * carried t=110 (night 2) p4 2.60 kN p2 1.92 p3 0.81 p1 0.54 + * + * Every night after the first flew a storm 30-50% weaker than the one C + * authored β€” past the end of its own baseCurve, so it read the tail breeze + * flat, with no build and no peak. Worse, the offset was however long the + * player spent reading the invoice, so it wasn't even the same wrong storm + * twice: a determinism break in a repo whose whole sim is built on not + * having one. + * + * Zeroed here rather than in main.js because attach() is the one door every + * caller comes through (boot, the picking adapter, balance.test, the audit), + * and commit β†’ attach β†’ game.advance() β†’ storm all land in a single keypress, + * so t=0 at attach IS t=0 at the first storm frame. + */ + this.t = 0; + this._acc = 0; this.corners = ring.map((a) => ({ anchorId: a.id, anchor: a, diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index e520f43..2036f34 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -317,6 +317,47 @@ test('determinism: variable frame dt matches fixed dt', () => { return 'ragged frame times converge on the fixed-dt trace'; }); +test('re-attach resets the clock: night 2 flies the same storm as night 1', () => { + // SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every + // night, and step() runs in EVERY phase once rigged β€” so the clock ran on + // through the aftermath and the forecast card, and night 2 opened its storm + // wherever night 1 left off, sampling the curve past its own end. The offset + // was however long the player read the invoice for, so it wasn't even the + // same wrong storm twice. + // + // Asserted as the PROPERTY that broke rather than `t === 0`: two identical + // nights on one rig must produce identical load traces. That stays true if + // the clock is ever reset somewhere else, and it goes red the moment the + // reset leaves attach() β€” which is the mutation-check (delete `this.t = 0` + // from attach and the second trace diverges on sample 0). + const trace = (r) => { + const w = makeStubWind({ seed: 7, stormLen: 30 }); + const out = []; + runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); }); + return out; + }; + const r = rig(HEIGHTS_HYPAR); + const night1 = trace(r); + + // the aftermath: the sail is still up and still being stepped while the + // player reads their invoice. This is the frame budget that used to poison + // the next night. + const calm = makeStubWind({ seed: 7, stormLen: 30 }); + for (let i = 0; i < Math.round(20 / SIM_DT); i++) r.step(SIM_DT, calm, i * SIM_DT); + + r.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0); // night 2, same rig object + const night2 = trace(r); + + assert(night1.length === night2.length, `night 1 traced ${night1.length} samples, night 2 ${night2.length}`); + for (let i = 0; i < night1.length; i++) { + assert(night1[i] === night2[i], + `night 2 diverged from night 1 at sample ${i}: ${night1[i]} vs ${night2[i]} β€” ` + + 'the rig carried its clock across attach(), so night 2 is flying a different storm ' + + '(and one that depends on how long the aftermath was on screen)'); + } + return `${night1.length} load samples identical across a re-attach β€” same storm both nights`; +}); + test('tension dial changes load (drum tight shock-loads)', () => { const w = constantWind({ x: 0, y: 0, z: 20 }); const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8); diff --git a/web/world/js/tests/b.test.js b/web/world/js/tests/b.test.js index e6c5122..4d3a978 100644 --- a/web/world/js/tests/b.test.js +++ b/web/world/js/tests/b.test.js @@ -27,12 +27,19 @@ import { SAIL_TESTS } from '../sail.selftest.js'; import { RIGGING_TESTS } from '../rigging.selftest.js'; import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js'; +import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js'; /** @param {import('../testkit.js').Suite} t */ -export default function run(t) { +export default async function run(t) { for (const [name, fn] of SAIL_TESTS) t.test(name, fn); for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn); // site_audit's own sweep. SPRINT11: the tool was flying site_02 with the // venturi switched off β€” the auditor needed an auditor. See sweep.selftest.js. for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn); + // SPRINT13: the audit's garden engine (gardenfly.js) β€” browser-only, so the + // flights run here in the async prelude and the tests assert on the results + // (Suite.test cannot await; a.test's pinned-separation flight is the + // precedent). run() is async now β€” runAll awaits it. + const gardenflyTests = await buildGardenflyTests(); + for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn); } diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index 1ac9c2d..5342a71 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -28,6 +28,11 @@ import { SailRig } from '../sail.js'; import { createSkyFx } from '../skyfx.js'; import { createWind, loadStorm } from '../weather.js'; import { HARDWARE, FIXED_DT, START_BUDGET } from '../contracts.js'; +// SPRINT13: the garden weights come from garden.js β€” the SAME module main.js +// flies β€” not from a local copy. The old `const GARDEN_DRAIN = 0.9 // main.js` +// here was the drift A's export exists to kill: a retune would have landed in +// the game and left this suite green against last sprint's numbers. +import { GARDEN_DRAIN, HAIL_WEIGHT as W_HAIL, RAIN_WEIGHT as W_RAIN } from '../garden.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -35,9 +40,6 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE; const WIN = (hp, lost) => hp >= 50 && lost < 2; /** main.js's CALM_STORM β€” what wind.use() hands the rig outside a storm, and so what settles it. */ const CALM_STORM = 'storm_01_gentle'; -const GARDEN_DRAIN = 0.9; // main.js -const W_HAIL = 5.0; // main.js, decision 13 β€” integration weights -const W_RAIN = 0.25; /** * The yard is READ FROM world.js, never copied.