Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-18 14:26:16 +10:00
commit 646efa7244
15 changed files with 1630 additions and 118 deletions

View File

@ -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 (~515%, 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 1845 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
$4050 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 23'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.

View File

@ -1,6 +1,7 @@
<!doctype html>
<!--
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool]
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool;
SPRINT13 gate 1.2 — the audit predicts the GARDEN now]
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only
sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
@ -11,8 +12,17 @@
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to
audit a site with a carport (site_02) before it ships.
SPRINT13: winnability was never the whole question — the audit's static
"cover%" predicted the wild night's garden BACKWARDS (r = 0.42) and told
three people a $20 rig was fine. Every affordable line is now FLOWN through
the real scoring chain (gardenfly.js: attach → settle → skyfx exposure →
garden.js) with the SITE's venturi live, and the verdict reports the garden
state the sim would invoice — FULL / tattered / dead — plus the site's
pinned `separation` target (A's gate-1.4 block in the site JSON), judged on
the block's own storm. cover% stays as a geometry diagnostic only.
tools/site_audit/audit.html?site=backyard_01
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
Served from the repo root (server.py), so the relative imports resolve.
-->
@ -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 : '<span class="none">NONE</span>'}`).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) => {

View File

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

View File

@ -0,0 +1,164 @@
<!doctype html>
<!--
garden_probe — does cover% predict the garden? [Lane B, SPRINT13 gate 1.2]
The QA pass found one variable sighted three times: the audit's "cover" called
a $20 rig bad and the sim paid it +$97. This page is the measurement behind
that — it flies the SIM's own garden loop (skyfx's gardenExposure /
gardenHailExposure, stepped exactly the way main.js:931-941 steps it) for a
set of rig lines, and prints predicted garden HP beside the audit's cover%.
SPRINT13: the garden model is A's js/garden.js now (the export this page's
first version asked for), so the probe flies the SAME createGarden main.js
flies — the temporary constant copy died the day the export landed, per the
fake → ask → landed → delete-the-fake protocol.
Browser-only by necessity: skyfx needs `document` (verified — node throws
"document is not defined"), so garden prediction can never live in audit.mjs.
tools/site_audit/garden_probe.html?site=site_02_corner_block&storm=storm_03b_earlybuster
-->
<meta charset="utf-8">
<title>garden_probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>garden_probe — does cover% predict the garden?</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { HARDWARE } from '../../web/world/js/contracts.js';
import { auditSweep } from './sweep.js';
import { flyGarden } from './gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'site_02_corner_block';
const stormName = q.get('storm') || 'storm_03b_earlybuster';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
async function run() {
// The yard on a re-pointable wind proxy (C's bench pattern) so world.anchors
// fly THEMSELVES — a static sway remap froze the gum tree and under-read
// every tree corner (C's landmine 2; q4 1.02 frozen vs 1.24 live).
const site = await loadSite(siteName);
const scene = new THREE.Scene();
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(scene, { wind: windProxy, site });
await world.dress();
const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? [];
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
// The audit's own sweep, for its (diagnostic) cover% numbers.
const { rows } = auditSweep({ anchors, bed, stormDef, siteDef: site, use });
/** Fly one line through gardenfly — THE audit scoring chain, no local copy. */
const gardenFor = (ids, hwTier, { bare = false } = {}) =>
flyGarden({ anchors, bed, stormDef, siteDef: site, use,
ids: bare ? null : ids, hw: hwTier });
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain}`).join(' · ') : 'none'} · ` +
`flying gardenfly.js — the sim's own chain (windForSite → attach → skyfx → garden.js), live anchors`;
// The bare bed is the control: what the garden does with NO sail at all.
const bareRes = gardenFor(null, null, { bare: true });
// Probe a spread of lines: the cheapest (the decoy), the best-covering, and a
// few between — sorted by the audit's cover% so any correlation is visible.
const holdable = rows.filter((r) => !r.unholdable.length);
const byCover = [...holdable].sort((a, b) => a.cover - b.cover);
const pick = [];
const want = 8;
for (let i = 0; i < want; i++) pick.push(byCover[Math.floor(i * (byCover.length - 1) / (want - 1))]);
const seen = new Set();
const probes = pick.filter((r) => r && !seen.has(r.ids.join(',')) && seen.add(r.ids.join(',')));
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'audit cover% (static)', 'hw $', 'garden (flown)', 'by hail', 'by rain', 'corners lost', 'vs bare bed'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const results = [];
for (const r of probes) {
const g = gardenFor(r.ids, HARDWARE[2]); // rated shackles: hold it, isolate GEOMETRY
results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g });
const tr = tbl.insertRow();
const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`,
`${g.hp.toFixed(1)} ${g.state.toUpperCase()}${g.marginal.length ? ` ⚠ ${g.marginal.join(',')}` : ''}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
`${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`];
cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c;
if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; });
}
const tr = tbl.insertRow();
['BARE BED (no sail)', '—', '$0', `${bareRes.hp.toFixed(1)} ${bareRes.state.toUpperCase()}`,
bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
// Correlation between the audit's cover% and the sim's garden HP. If the audit
// is telling the truth this is near 1; the QA pass says it is not.
const xs = results.map((r) => r.cover), ys = results.map((r) => r.hp);
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
const mx = mean(xs), my = mean(ys);
const cov = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0);
const sx = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0));
const sy = Math.sqrt(ys.reduce((s, y) => s + (y - my) ** 2, 0));
const r2 = (sx && sy) ? (cov / (sx * sy)) : NaN;
const spread = Math.max(...ys) - Math.min(...ys);
const bestGain = Math.max(...ys) - bareRes.hp;
el('note').textContent =
`cover% → garden HP correlation: r = ${r2.toFixed(3)}\n` +
`garden HP spread across every holdable line: ${spread.toFixed(1)} HP\n` +
`best line vs bare bed: ${bestGain >= 0 ? '+' : ''}${bestGain.toFixed(1)} HP ` +
`(a rig that costs money and holds every corner buys THIS much garden)\n` +
`bare bed finished at ${bareRes.hp.toFixed(1)} HP — hail ${bareRes.byHail.toFixed(1)}, rain ${bareRes.byRain.toFixed(1)}`;
window.__probe = { site: siteName, storm: stormName, bare: bareRes, results, r: r2, spread, bestGain };
document.title = `garden_probe — r=${r2.toFixed(2)}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `garden_probe crashed: ${e.message}\n${e.stack || ''}`;
window.__probe = { error: e.message };
document.title = 'garden_probe — ERROR';
});
</script>

View File

@ -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 commitattachstorm 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 ~515% 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 };
}

View File

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

View File

@ -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 attachstorm 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 ~515% 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] },
};
}

View File

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

View File

@ -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() 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.

View File

@ -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 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 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 t0.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',

View File

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

View File

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

View File

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

View File

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

View File

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