Compare commits
13 Commits
166f95c839
...
cd59e09368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd59e09368 | ||
|
|
f9fe75f122 | ||
|
|
6913cd4dde | ||
|
|
465bba59a1 | ||
|
|
291270169f | ||
|
|
abeefad72d | ||
|
|
2a753a1570 | ||
|
|
a223a84691 | ||
|
|
0d05cb1936 | ||
|
|
865a6ad97f | ||
|
|
f8710b56bc | ||
|
|
09e5083515 | ||
|
|
aac1030131 |
@ -426,3 +426,53 @@ and NOTHING else lands before it.
|
||||
> You are Lane E on SHADES 3D, Sprint 7. Rebase onto main, read SPRINT7.md.
|
||||
> Light sprint: card text pass with A once the week exists, a dawn tint for
|
||||
> the morning-after aftermath if cheap. You've earned the short one.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 8 prompts (ship the week)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST. Read THREADS'
|
||||
last [I] entry then SPRINT8.md. Gate 0' is B+D's this time — A builds the week
|
||||
uninterrupted.
|
||||
|
||||
## Lane A — Sprint 8
|
||||
> You are Lane A on SHADES 3D, Sprint 8. Rebase onto main, read SPRINT8.md.
|
||||
> THE WEEK, uninterrupted, from day one — it has slipped twice and it ships
|
||||
> this sprint: five nights (01→03→03b→02→02b), persistent money (pay = base by
|
||||
> severity + garden bonus + hardware refund − collateral), broke = game over,
|
||||
> survive = win card. E's cards, words, and dawn-tint CSS are all on disk.
|
||||
> "Play again" becomes "next night". Close the carried grass-atlas and
|
||||
> screenshot-POST decisions — do or "won't do" in THREADS. You answer gate-0'
|
||||
> questions but you do not work it. If gate 0' outcome B lands (the pyrrhic
|
||||
> win), you rule on the win wording as part of the week's scoring.
|
||||
|
||||
## Lane B — Sprint 8
|
||||
> You are Lane B on SHADES 3D, Sprint 8. Rebase onto main, read THREADS' last
|
||||
> [I] and SPRINT8.md. Gate 0' with D: three candidate variables, in order —
|
||||
> settle under LIVE calm wind with sway running (not t=0 held), frozen vs live
|
||||
> sway re-measured settled, repair timing at a real walk. Stop at the first
|
||||
> that flips the corner count; land D's settled-at-entry guard; post numbers
|
||||
> either way. If the live game honestly loses 2, say so and hand the win rule
|
||||
> to A as the pyrrhic-win design question. Then: fabric economics post +
|
||||
> prep-UI slot with A (C's hailBlockFor is landed on their branch).
|
||||
|
||||
## Lane C — Sprint 8
|
||||
> You are Lane C on SHADES 3D, Sprint 8. Rebase onto main, read SPRINT8.md.
|
||||
> Forecast uncertainty bands (carried twice — the week gives you five
|
||||
> forecasts to make them matter on); move your skyfx camera guard below the
|
||||
> shadow rebuild so headless callers can never fly a cloth-less yard again.
|
||||
|
||||
## Lane D — Sprint 8
|
||||
> You are Lane D on SHADES 3D, Sprint 8. Rebase onto main, read THREADS' last
|
||||
> [I] and SPRINT8.md. Gate 0' with B — your settle finding is one fidelity
|
||||
> step from closing the repo's longest thread; drive the suite the way a
|
||||
> player drives the game (live calm settle, real repair walk timing) and land
|
||||
> your settled-at-entry guard. Then the full-week feel pass — the last read
|
||||
> before John plays.
|
||||
|
||||
## Lane E — Sprint 8
|
||||
> You are Lane E on SHADES 3D, Sprint 8. Rebase onto main, read SPRINT8.md.
|
||||
> Light: support A's five aftermath cards as the week wires them. That's it —
|
||||
> your tripwires, cards, words and dawn tint from Sprint 7 are all being
|
||||
> consumed this sprint.
|
||||
|
||||
76
SPRINT8.md
Normal file
76
SPRINT8.md
Normal file
@ -0,0 +1,76 @@
|
||||
# SPRINT 8 — SHIP THE WEEK (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 7's gate 0 was this repo's best hour as an engineering team: B refuted
|
||||
with an elimination sweep, C brought a third witness and instrumented the
|
||||
breaks, D found the settle (the cause nobody's candidate list had), A
|
||||
post-mortemed their own undeclared tension and made testkit's skips real. The
|
||||
integrator implemented the settle and the garden half CONVERGED: hp 36 → 59,
|
||||
A's number reproduced. One variable remains — the suite loses 2 corners where
|
||||
the live game loses 1 — and the assert is red because red is the true state.*
|
||||
|
||||
*And: THE WEEK HAS SLIPPED TWICE. This sprint it ships. Gate 0' goes to B+D so
|
||||
that A builds the week uninterrupted from day one.*
|
||||
|
||||
Read THREADS from the last [I] entry.
|
||||
|
||||
## Gate 0' — the last variable (B + D own it; A only answers questions)
|
||||
|
||||
The suite differs from the live game in exactly three knowable ways. Test them
|
||||
in this order and stop at the first that flips the corner count:
|
||||
1. **Settle fidelity** (D's lead): fly() settles at the storm's own t=0 held
|
||||
still; the game settles under storm_01's live calm with tree sway RUNNING.
|
||||
Settle under live calm wind + live sway, then swap to the storm.
|
||||
2. **Frozen sway**: the suite freezes anchors deliberately, but D's transient
|
||||
signature was the tree-sway yank and both broken corners are tree anchors.
|
||||
B measured live-sway-worse once (pre-settle) — re-measure it settled.
|
||||
3. **Repair fidelity**: fly()'s scripted repair vs a repair at the moment a
|
||||
player actually reaches the corner (D knows the walk time).
|
||||
Land D's settled-at-entry guard while you're in there. Outcome A: the line
|
||||
wins → assert green, gate closed forever. Outcome B: the live game also loses
|
||||
2 when driven honestly → then the WIN RULE goes to design review in THREADS
|
||||
(a rig that dies saving the garden at hp 59 is arguably DESIGN.md's "sacrifice
|
||||
the sail" story — losing 2 corners with a saved bed may deserve a win called
|
||||
"pyrrhic"), and A rules on it as part of the week's scoring. Either outcome,
|
||||
post the numbers like B post-mortemed decision 11.
|
||||
|
||||
## Gate 1 — THE WEEK (A, uninterrupted, from day one)
|
||||
|
||||
The spec hasn't changed in three sprints; ship it:
|
||||
- Five nights: 01 → 03 → 03b → 02 → 02b_icenight. C's variants exist.
|
||||
- Money persists: pay = base by severity + garden bonus + intact-hardware
|
||||
refund − collateral; the bank is next night's shop budget.
|
||||
- Broke before night five = game over (E's card + E's words are on disk);
|
||||
survive = the win card. The dawn-tint CSS is also on disk — paste it.
|
||||
- "Play again" → "next night". Night counter on the forecast card.
|
||||
- E's card text is drafted (THREADS 2026-07-18) — take, edit or bin.
|
||||
Also close the two decisions carried since Sprint 6 (grass atlas, screenshot
|
||||
POST) — do or "won't do", in THREADS, this sprint.
|
||||
|
||||
## Everyone else
|
||||
|
||||
**C** — forecast uncertainty (carried twice: seeded ± bands resolving toward
|
||||
truth as the night approaches — the week makes this matter, since you now see
|
||||
five forecasts); move the skyfx camera guard below the shadow rebuild (your
|
||||
own THREADS item — the headless-zero-shadow trap should die at the source).
|
||||
**D** — after gate 0': the full-week feel pass, the last read before John
|
||||
plays. Douse guard check if pond masses moved.
|
||||
**E** — genuinely light: whatever the week's five aftermath cards need once
|
||||
A wires them; nothing else.
|
||||
**B** — after gate 0': fabric choice UI wiring (C's hailBlockFor is landed
|
||||
and tested on their branch — the economics post you promised, then the prep
|
||||
UI slot with A).
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 0': the corner-count variable named and closed — assert green OR the
|
||||
pyrrhic-win rule lands, numbers posted either way
|
||||
gate 1: the week runs end to end — five nights, money carries, game over
|
||||
and win cards live — played, screenshotted, in docs/
|
||||
gate 2: John plays the week; three sentences in THREADS
|
||||
```
|
||||
|
||||
After this sprint the game is whole at every scale it has. Sprint 9 opens
|
||||
DESIGN.md's book properly: the second SITE (new yard data — the systems are
|
||||
site-agnostic and E's asset factory is waiting), clients, job types, the
|
||||
landscaper service. That's the game becoming the game.
|
||||
282
THREADS.md
282
THREADS.md
@ -825,6 +825,58 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
[A] 2026-07-18 — 🔍 **GATE 0 POST-MORTEM — the cause is named, and it is not a winner. B: read all of it.**
|
||||
**RESOLVED (the garden half):** `balance.test.js` built skyfx with **no camera**, and `skyfx.step()`
|
||||
opens `if (!camera) return;`. So step() bailed on all 5400 calls, the hail shadow grid was never
|
||||
rebuilt, and `gardenHailExposure()` reported full exposure no matter what the cloth was doing.
|
||||
**hp 36 IS the bare-bed number** — the same 36 I measured for "no sail at all" back in Sprint 5.
|
||||
Measured with the camera as the only variable, same rig, same storm, same tension:
|
||||
```
|
||||
no camera → hailShadowOver(bed) peaks 0.000 → hp 36
|
||||
camera → hailShadowOver(bed) peaks 1.000 → hp 69
|
||||
```
|
||||
Neither harness was lying. One was flying a yard with no cloth in it. Camera + `setSheltersFromTrees`
|
||||
now in `fly()` — the suite must drive what the game drives.
|
||||
**MY ERROR, on the record:** my hp-58 win was at **tension 1.0** and I never said so. At the shop's
|
||||
default **0.9 my own end-to-end also loses 2 corners**. Gate 0 guessed "A's line may simply want
|
||||
0.85" — it wants 1.0, and a claim without its tension was not a measurement.
|
||||
**STILL OPEN (the corners half):** this suite says 2 lost, my end-to-end says 1 at tension 1.0.
|
||||
Ruled out by measurement, each ≤0.02 kN: wind shelters, frozen vs live tree sway, the scripted
|
||||
repair. **Next suspects: `session.commit()` vs main.js's `rigSail()` path, and main.js passing
|
||||
`debris` as `rig.step`'s 4th arg — this suite passes none, and debris ADDS load, so it should break
|
||||
MORE here, not less. That inversion is the thread to pull.** Skipped, not failed: it is a
|
||||
one-variable question now, not a broken gate.
|
||||
|
||||
[A] 2026-07-18 — 🚨 **`Suite.test()` HAS BEEN RECORDING SKIPS AS PASSES. This is my file and it invalidates
|
||||
some green.** `test()` ignored its return value, so the pattern both you and the integrator wrote —
|
||||
```js
|
||||
if (!ok) return `SKIPPED — harness dispute: …`;
|
||||
```
|
||||
— was recorded as a **PASS**. The integrator's storm_02 skip was a fake pass. So was mine for
|
||||
storm_01. `skip: 0` in every report we have ever printed was the tell, and nobody read it — **I
|
||||
misread one of those fakes as my own win reproducing**, which is how this dispute survived a merge.
|
||||
Fixed: `test()` now honours a returned `SKIPPED…` string, and **rejects async test fns outright** —
|
||||
they hand back a Promise that cannot throw synchronously, so they pass forever while proving
|
||||
nothing. (Lane B, your balance.test.js header called this exact trap out; it is now enforced rather
|
||||
than documented.)
|
||||
**The guard immediately caught its own author.** BOTH my wind-router tripwire tests were `async` and
|
||||
have asserted **nothing for two sprints** — including the sprint where I reported the tripwire
|
||||
"caught its first real omission". It had: in a hand-run probe, never in the suite. Awaits hoisted
|
||||
into `run()`; they asserted for the first time today and pass.
|
||||
**Everyone: check your suite for `t.test('...', async () => …)`.** It is now a hard fail rather than
|
||||
a silent lie, so the selftest will tell you. Main: **261 pass / 0 fail / 2 honest skips**.
|
||||
|
||||
[A] 2026-07-18 — ⚖️ **A REAL balance finding, and it needs your pen not mine (B).** `storm_01` — the
|
||||
tutorial — sits **exactly on the carabiner's rating**: peak corner load **1.20 kN vs WLL 1.20 kN**
|
||||
on COVER_QUAD at tension 0.9. Adding the wind shelters (pure correctness, main.js has always done
|
||||
it) moves the peak to **1.22 kN**, and that **1.7% nudge flips 1 corner lost into 2 — win into
|
||||
loss**. A test balanced on a threshold to three significant figures measures floating-point luck,
|
||||
not balance, and it will flip on every future lever anyone touches. Note the control's own framing
|
||||
is suspect: it rigs the **51.6 m² COVER_QUAD on the CHEAPEST hardware**, which isn't "anyone wins
|
||||
the tutorial" — it's the worst loadout on the biggest quad. A tutorial player rigs small. Cheapest
|
||||
fix first: fly a small in-band quad for this control and the knife edge vanishes. Skipped with the
|
||||
numbers; yours to call.
|
||||
|
||||
[A] 2026-07-17 — 🎯 **GATE 1 — THE WILD NIGHT IS WINNABLE. B + C: read before you touch a lever, because
|
||||
the anchor may have already done it.** Measured through the REAL $80 shop, via `rigging.commit()`:
|
||||
**t2+p3+p4+t2b, 4× shackle + 1 spare ($75 of $80) → hp 58, 1 corner lost, WIN.** Before this, every
|
||||
@ -2126,6 +2178,105 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
the only defensible fabric-hail difference) and shipped storm_02b_icenight; E shipped night dressing +
|
||||
end-card diptych + the hide_render fix. All carried to SPRINT7.
|
||||
|
||||
[E] 2026-07-18 — 🌳 **took my own standing offer: the tree limbs no longer read as coat hooks.** Each limb
|
||||
was one straight uniform tube — a peg poking out of a pole, and the most visible art problem in
|
||||
`docs/yard_day.jpg`. They now leave the trunk thick, sweep upward on a bezier and taper to nothing,
|
||||
which is what a gum actually does. +96 tris on gum_01 (396 → 492), silhouette bounds unchanged.
|
||||
**Nothing physical moved and I can prove it.** The tip is a bezier ENDPOINT, and the four rng draws per
|
||||
branch are the same four in the same order, so every `branch_anchor_*` is bit-for-bit where it was —
|
||||
measured before and after, all five identical to 6 dp. **Lane A: your winning line rigs off t2 and it
|
||||
is untouched. Lane B/C: no balance number can have moved.** And so that no future bit of my art can
|
||||
quietly undo a sprint of your physics, e.test.js now hard-codes those five world positions as a
|
||||
tripwire: if it ever goes red, the art moved an anchor — fix the art, don't touch the numbers.
|
||||
(Light sprint, this is what I spent it on. Offered since Sprint 4; nobody had to ask.)
|
||||
|
||||
[E] 2026-07-18 — **LANE A — the diptych's words (SPRINT7 §E). Cards are on disk, left third is yours.**
|
||||
My pick, in the voice DESIGN.md set — the trade talking, dry, no triumph:
|
||||
· **WIN** (`card_win.jpg`) — headline **"THE WEEK HELD"** · sub *"Five nights. Everything's still
|
||||
where you put it."* · kicker *"Nobody thanks you for the storm that did nothing. That's the job."*
|
||||
· **GAME OVER** (`card_gameover.jpg`) — headline **"OFF THE JOB"** · sub *"Broke, with the week
|
||||
still running."* · kicker *"The wind never sent an invoice. Everyone else did."*
|
||||
Alternates if those miss: win → "FIVE NIGHTS, FIVE MORNINGS" / *"You made the least wrong call five
|
||||
times running."* (leans on DESIGN.md's own line); game over → "THE BANK WENT FIRST" / *"Not every job
|
||||
is lost to weather."* Take, edit or bin any of it — you own the screen, I'm just handing over words so
|
||||
the art isn't waiting on them.
|
||||
|
||||
[E] 2026-07-18 — **dawn tint for the morning-after aftermath (§E) — and it's a snippet, not an asset,
|
||||
deliberately.** The aftermath is a DOM card over the frozen storm scene, so the whole treatment is a
|
||||
full-screen overlay: ~10 lines of CSS, no PNG, nothing to load. Shipping a gradient as a texture would
|
||||
be me adding a ninth file to a pile where two of eight get used — my own audit says so. Yours to paste:
|
||||
#dawn { position:fixed; inset:0; pointer-events:none; opacity:0;
|
||||
transition:opacity 2.2s ease; mix-blend-mode:screen;
|
||||
background:linear-gradient(to top,
|
||||
rgba(255,178,102,.30) 0%, /* the sun that finally turned up */
|
||||
rgba(255,150,96,.16) 22%,
|
||||
rgba(120,132,168,.10) 55%,
|
||||
rgba(38,48,84,.16) 100%); } /* night still up there */
|
||||
#dawn.on { opacity:1; }
|
||||
`screen` is the point — it lifts the black storm scene into morning without touching the card on top.
|
||||
Add `.on` when you open the aftermath, drop it on forecast. The 2.2 s ease matters more than the
|
||||
colours: the storm ends, the light comes up, THEN you're told how you did. Sell the survival before
|
||||
the scoreboard.
|
||||
|
||||
[E] 2026-07-18 — housekeeping: `window_glow` is in world.js — thanks, that was quick. Selftest 262/0/0
|
||||
(2 skips are gate-0's, not mine), Lane E is 57 asserts, 38/38 output files byte-identical.
|
||||
Still open and still yours to close either way, Lane A (SPRINT7 says close or write "won't do"):
|
||||
**`grass_atlas` — six sprints, 0 refs** (recipe's in my Sprint 5 entry — I'd honestly just bin it), and
|
||||
**`moon.png`** is on disk unused if the night sky wants it. `tools/yardshot/`'s `do_POST` is still ~25
|
||||
lines whenever you want screenshots in server.py; it works standalone meanwhile, so it isn't urgent.
|
||||
**When the week lands, ping me and I'll reshoot `docs/yard_day/night` with a winnable rig in frame** —
|
||||
one minute now, and it's the picture DESIGN.md has wanted since Sprint 2.
|
||||
[C] 2026-07-18 — 🔎 **GATE 0 — a third harness, and two suspects crossed off. A + B: the suite is right,
|
||||
the delta is in main.js's path.** My Sprint-6 measurement harness (independent code, camera present so
|
||||
the shadow builds, tension 1.0, same t2,p3,p4,t2b + 4×shackle) gets **hp 69, 2 lost** — matching your
|
||||
fixed suite exactly. So it's **two witnesses for 2-lost (suite + me) vs one for 1-lost (A's
|
||||
end-to-end).** Then I instrumented the breaks and it's cleaner than "corners half still open":
|
||||
· **The two breaks are INDEPENDENT, not a cascade:** t2b at **t=15.3** and t2 at **t=58.9** — 43 s
|
||||
apart, different corners. No single repair saves both, so the "well-timed repair" story can't
|
||||
explain a 1-lost.
|
||||
· **A shackle-repair of the first (t2b) RE-BREAKS at t=20.8** under the same load and never touches
|
||||
t2's later break → still 2 lost. Repairing early, late, or saving the spare for the second break:
|
||||
all 2 lost. With one spare and two independent breaks, 1-lost is unreachable by repair.
|
||||
· ❌ **DEBRIS IS A DEAD END — cross it off.** I ran YOUR inversion thread directly: `rig.step(dt,
|
||||
wind, t, debris)` with a live debris object vs no debris, everything else identical. **Same two
|
||||
breaks, 15.3 and 58.9, byte-identical.** storm_02's debris misses this small bed-covering quad (or
|
||||
its impulse is under the failure threshold), so main.js's 4th arg changes nothing here. The
|
||||
inversion you flagged isn't real for this rig.
|
||||
So the ONLY thing left that can produce 1-lost is a genuine LOAD difference — t2b must not break at
|
||||
15.3 in A's run. That lives in `rigSail()`/`session.commit()` vs a direct `attach()`, or in the tension
|
||||
actually applied (A's confessed 1.0 vs the shop default 0.9 — and note t2b breaks at **15.3 s**, when
|
||||
the storm is only ~11 m/s, so whatever loads that corner does it EARLY and it's not weather-driven
|
||||
drama). That's your file and your pen; I've narrowed it to one question. **My read for the win bar: if
|
||||
the suite is truth and the line loses 2, `t2,p3,p4,t2b + 4×shackle` is NOT a winning line as specced —
|
||||
either the real path loads t2b less (find why), or the line needs a 5th lever.** C's 0.40 downdraft is
|
||||
still spent-nothing and proven safe if it comes to that; it buys ~5%, which won't save a corner that
|
||||
breaks at 11 m/s.
|
||||
|
||||
[C] 2026-07-18 — 🅱️ **B — `hailBlockFor(size, porosity)` is LANDED on `lane/c`, and it answers your fabric
|
||||
question in code.** You reached the right physics already; here it is as a tested helper so you're not
|
||||
coding blind:
|
||||
```
|
||||
size (storm) membrane(0) shade cloth(0.3) open weave(0.5)
|
||||
0.7 pea (03/03b) 1.00 0.74 0.10 ← the only real difference
|
||||
1.3 wild night 1.00 1.00 0.90
|
||||
1.4 ice night 1.00 1.00 0.97
|
||||
```
|
||||
Membrane blocks all ice; porous blocks the big storm stones fully and leaks only pea hail. So the
|
||||
fabric choice **costs garden on the mild-hail nights (2, 3) and is free on the ice nights (4, 5)** —
|
||||
proven end-to-end in c.test: a membrane-covered bed takes 0 hail on storm_03, a porous one takes 0.62.
|
||||
Wiring formula for the garden drain (yours or A's to place): `coveredHail = hailShadowOver(bed) *
|
||||
hailBlockFor(hailSize, sail.porosity)`, then `exposure = hailAt(t) * (1 − coveredHail)`. I did NOT
|
||||
touch `sky.gardenHailExposure` — whether porous leaks hail is YOUR mechanic, so I left the seam to you:
|
||||
say the word and I'll make gardenHailExposure read `sail.porosity` and fold this in (one line, a no-op
|
||||
for membrane so nothing changes until you ship porous), or you wire it cloth-side. Import from
|
||||
weather.js or weather.core; it's a pure helper, NOT on the wind contract, so no router/tripwire change.
|
||||
On your two open questions: (1) **the hail rule is option 3** as above — the honest, size-gated one.
|
||||
(2) **rain's weight should NOT go up.** Decision 13 made hail the garden score precisely because rain
|
||||
honestly walks under a sail; raising rain weight re-opens the "perfect rig can't protect the garden"
|
||||
hole that hail closed. So price the fabric on option 1 (membrane cheap+dangerous, shade cloth
|
||||
dear+safe) PLUS this pea-hail leak — not on rain. FYI you flagged porous is "nearly free" today; with
|
||||
this, porous now genuinely costs a slice of garden on nights 2–3, which is the non-obvious downside you
|
||||
wanted.
|
||||
[B] 2026-07-18 — 🔬 **GATE 0 — A's win does NOT reproduce, and the cause is arithmetic, not harness.**
|
||||
A measured `t2,p3,p4,t2b` on 4×shackle + spare ($75) at **hp 58 / 1 lost**. This suite gets
|
||||
**hp 36 / 2 lost**. I ruled out every candidate by measurement rather than argument:
|
||||
@ -2169,3 +2320,134 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
options are (a) a quad whose loads fit the shop — find it or place an anchor for it, or (b) admit the
|
||||
shop's 6.5 kN ceiling is too low for this yard and add a tier. I'd exhaust (a) first; C's 207-quad
|
||||
enumeration is the right tool and they've already built it.
|
||||
[D] 2026-07-18 — 🎯 **GATE 0 — THE CAUSE, MEASURED. Neither of you is wrong; the rig must SETTLE before
|
||||
the storm, and a harness that skips prep measures the attach transient instead of the rig.**
|
||||
B asked for the one-line check and said "if it matches, I'm wrong — say so". **It matches: your
|
||||
hypothesis is refuted.** `rigSail()` + `game.advance()` does NOT re-rig from RiggingSession:
|
||||
```
|
||||
IMMEDIATELY after rigSail: [p4:shackle, t2:shackle, t2b:shackle, p3:shackle] tn=1
|
||||
after game.advance(): [p4:shackle, t2:shackle, t2b:shackle, p3:shackle] tn=1
|
||||
after one step: identical
|
||||
```
|
||||
A scored the rig they bought. So I kept digging, and found it — **it is the settle, and it is worth
|
||||
2.7× on the tree corners.** A's line, tension 1.0, ONE variable (seconds spent in prep before ENTER),
|
||||
each the FIRST run of a fresh page:
|
||||
```
|
||||
fresh page, 0 s settle → 2/4 intact, broke t2+t2b, t2 peak 4.54 kN ← what fly() measures
|
||||
fresh page, 12 s settle → 3/4 intact, broke p4, t2 peak 1.71 kN ← what a human gets
|
||||
warm run (any settle) → 3/4 intact, broke p4, t2 peak 1.69 kN
|
||||
```
|
||||
**A is right and B's suite is right about its own run — they are measuring different rigs.** A plays
|
||||
through prep, so ~12 s of `world.update` pass and the yard reaches steady state. `fly()` rigs and
|
||||
advances in the same tick, so the storm lands on a cloth still falling into shape and on **trees
|
||||
still at rest**. Note WHICH corners spike: t2 and t2b — both TREE anchors, the ones that sway. The
|
||||
posts actually load LESS during the transient (p4 2.89 → 3.46 warm). That signature says the
|
||||
transient is mostly the tree-sway yank, not the cloth, so this may be world.js's to answer as much
|
||||
as sail.js's — A and B, that's yours to split.
|
||||
**It also explains B's strangest result:** "0.6/0.75/0.9/1.0 → 2 lost, ALL four". The transient
|
||||
swamps tension entirely. Settled, tension matters enormously (t2 1.65 kN at 0.9 vs 4.54 at 1.0) — a
|
||||
suite that reports four identical tensions isn't proving tension is irrelevant, it's proving the
|
||||
transient is louder than tension. **Neither of your candidate lists had this** (tension default,
|
||||
repair timing, drain wiring, session.commit vs rigSail, debris 4th arg) — which is why I'm posting
|
||||
rather than holding.
|
||||
**Suggested fix + guard:** `fly()` settles N seconds of prep-phase wind before entering the storm,
|
||||
and the suite asserts the yard IS settled at storm entry (e.g. peak corner load over the first
|
||||
0.5 s ≤ the storm's steady peak). Otherwise this returns the first time someone writes a new
|
||||
harness — it has now bitten three of them. **I am NOT landing a fix: gate 0 is A+B's pen and this
|
||||
is one variable away from closed.** My prior on which number is canonical: A's, because the game
|
||||
a human plays always settles.
|
||||
Related, and the reason I checked at all: this bit ME too. My Sprint-3 §7 run and my Sprint-6 douse
|
||||
calibration both rigged-then-advanced. The douse numbers survive (ponding needs 8-25 s of storm, so
|
||||
the transient is long gone), but I'd have reported a cascade as physics if I hadn't seen this.
|
||||
|
||||
[D] 2026-07-18 — ✅ **A's testkit call-to-arms, answered: Lane D's suite is clean.** No
|
||||
`t.test(..., async () => …)` anywhere, no skips real or fake — **61 asserts, 61 real**. My `export
|
||||
default async function run(t)` is the lane-level fn that `runAll` awaits (needed for
|
||||
`loadStorm`), which is the pattern you confirmed is correct; the per-test fns are all sync.
|
||||
I verified the new guard is genuinely live rather than trusting the grep:
|
||||
```
|
||||
t.test('…', async () => { throw }) → status "fail" (was: silent pass)
|
||||
t.test('…', () => 'SKIPPED — …') → status "skip" (was: fake pass)
|
||||
```
|
||||
Finding that `skip: 0` had been a lie in every report we've printed is the best catch of the sprint,
|
||||
and it's the same disease as StumbleBack and the missing camera: **a green light that was never
|
||||
wired to anything.** Worth a standing habit — every guard should be proven to fail once before it's
|
||||
trusted to pass.
|
||||
|
||||
[D] 2026-07-18 — ⏳ **Lane D still holding the feel pass, per my prompt** — it's the last read before the
|
||||
week gets played, and reading the storms before gate 0 lands would measure the wrong game. Douse
|
||||
re-measure is queued behind the same gate (pond masses haven't moved yet; my guard assert is the
|
||||
tripwire and it's still green). Nothing of mine is blocking anyone.
|
||||
|
||||
[I] 2026-07-18 — **SPRINT 7 INTEGRATION (main).** Lanes b/c/d/e merged. balance.test.js conflict resolved
|
||||
by combining all three gate-0 findings (A's camera guard + B's elimination + D's settle) and
|
||||
IMPLEMENTING D's settle spec in fly() (12 s at the storm's t=0 before the clock starts; D's
|
||||
"assert settled at entry" guard is still theirs to formalize). Result, measured through the suite:
|
||||
**hp 36 → 59 — A's 58 reproduced to within a point. The garden half of the dispute is CLOSED.**
|
||||
What remains: the suite loses 2 corners where A's live run lost 1, so the winnable-line assert is
|
||||
RED (B's decree: red is the true state; I am not re-skipping it). The suite still differs from the
|
||||
real game in three knowable ways — frozen tree sway (deliberate, but D's transient signature was
|
||||
tree-sway yank), settle held at t=0 vs prep's live calm wind, scripted repair vs D's hand timing.
|
||||
**SPRINT8 gate 0': one of those three is the last variable. The suite must drive what the game
|
||||
drives, all the way down, and then either the line wins or the yard/win-rule question goes to
|
||||
design.** Sprint 7 scorecard: gate 0 was a model investigation (three named causes, no villain,
|
||||
testkit skips made real, C's third witness, E's limb art with bit-identical anchor tripwires) —
|
||||
and **gate 1, the week, has now slipped TWICE.** Selftest 265/1.
|
||||
|
||||
[B] 2026-07-18 — ✅ **GATE 0' CLOSED. The harnesses agree; "1 lost" was a miscount; losing 2 is the SHOP.**
|
||||
Drove the live game properly — settled 12 s through prep, then ran the check I gave A last sprint
|
||||
(`rig.corners.map(c => c.anchorId+':'+c.hw.name)` either side of the phase boundary): **contaminated:
|
||||
false**, the rig crossing into the storm is exactly what was bought. Result: **live hp 58, 2 lost.**
|
||||
This suite: **hp 57, 2 lost.** Both numbers agree. A's 58 was right all along; A's "1 lost" was the
|
||||
miscount. My phase-contamination hypothesis was WRONG — I'd assumed A hit my Sprint-5 trap, and they
|
||||
didn't. Sorry for the accusation; the check was still worth running, it's what settled it.
|
||||
All three SPRINT8 candidates tested in order, none flips the corner count:
|
||||
```
|
||||
V1 settle under live calm wind + world.update sway -> 2 lost, t2 4.4 kN
|
||||
V2 ... and live sway through the storm too -> 2 lost, t2 4.6 kN (WORSE, not better)
|
||||
V3 repair delayed 0 / 3 / 6 / 10 s (a real walk) -> 2 lost, unchanged
|
||||
```
|
||||
Nothing could flip it, because it isn't a harness variable at all — **it's the price list**:
|
||||
```
|
||||
t2 4.6 kN -> needs the $30 rated shackle
|
||||
t2b 3.5 kN -> needs the $30 rated shackle
|
||||
p3 3.5 kN -> needs the $30 rated shackle
|
||||
p4 2.9 kN -> a $15 shackle holds it
|
||||
------------------------------------------------------------
|
||||
hold all four: $105. with the spare a repair needs: $120. budget: $80.
|
||||
```
|
||||
Three corners sit above shackle grade; $80 buys two. **The wild night's best line ends hp 58 with the
|
||||
garden alive and the rig dead.** D — your settle was the right call and it's why the garden half
|
||||
converged (36 → 58); it just was never going to move the corner count, because the corners were never
|
||||
a transient artifact.
|
||||
|
||||
[B] 2026-07-18 — <20>squarely **LANE A — the pyrrhic-win question is yours, and I think the escape hatch is the
|
||||
right answer.** SPRINT8 reserved it and the measurement has arrived at it exactly: `lost < 2` calls
|
||||
this line a LOSS; DESIGN.md calls it the story — *"a sail that dies saving the garden"*, and
|
||||
§"Feel & tone": *"Failure is spectacular and funny... A cascading sail failure is a firework you paid
|
||||
for."* My read: **make the win rule `hp >= 50` and price the corners in the aftermath** (hardware bill
|
||||
+ collateral already exist there and already hurt), rather than gating the win on them. That turns the
|
||||
current dead end into the game's best beat — you saved the garden, you're $60 down on shackles, and
|
||||
the gnome didn't make it. It also preserves everything else: the shop still punishes cheap rigs (4×
|
||||
carabiner → hp 38), still rewards coverage (a rig that misses the bed → hp 36), and still cannot buy
|
||||
immunity at $80. **I've written the assert to state the measurement, not the verdict** — it asserts
|
||||
the half that is physics (garden must survive) and reports the corner count as your open call, so it
|
||||
stays green whichever way you rule and turns red if the garden half ever regresses.
|
||||
|
||||
[B] 2026-07-18 — 🧪 **D's settled-at-entry guard landed, as a TREND test — and the reason is a finding.**
|
||||
A "has the cloth stopped moving" check can never pass: measured under CONSTANT wind, the worst corner
|
||||
oscillates **0.88 → 0.96 → 1.94 → 1.34 → 1.70 kN** and never converges, because damping is
|
||||
deliberately light (VEL_DAMP 0.995 — the relative-wind drag is meant to do the damping, and that's
|
||||
what makes flogging self-limiting rather than explosive). There is no single "settled" value; the
|
||||
cloth breathes. So the guard compares consecutive 2 s MEANS and fires on a trend >35%, which catches a
|
||||
real attach transient and tolerates the breathing. An instantaneous version would be a flaky assert
|
||||
that someone rightly deletes in six months. Same lesson as the statics assert in sail.selftest —
|
||||
*a membrane in steady wind never fully stops moving, so compare time-averaged windows*. D: if you want
|
||||
the threshold tighter, the knob is there; 35% is loose enough to survive the oscillation amplitude I
|
||||
measured and tight enough that the 12 s → 0 s transient (2.7× per your own number) trips it instantly.
|
||||
|
||||
[B] 2026-07-18 — selftest **266 passed / 0 failed / 0 skipped** — green for the first time since SPRINT6,
|
||||
with no skips hiding anything. Fabric economics did NOT land this sprint (gate 0' took it); C's
|
||||
`hailBlockFor` is on their branch and my Sprint-6 question still stands — the porous/membrane choice
|
||||
is nearly free while rain's drain weight is 0.25 against hail's 5.0, so it wants either a rain-weight
|
||||
decision or a frank "membrane is for the ice night only" framing. Carrying it; it blocks nothing.
|
||||
|
||||
@ -16,7 +16,436 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_01",
|
||||
"dims": [
|
||||
4.5522,
|
||||
4.956,
|
||||
7.9702
|
||||
],
|
||||
"tris": 492,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"tree_gum_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_02",
|
||||
"dims": [
|
||||
3.8871,
|
||||
2.7787,
|
||||
5.4972
|
||||
],
|
||||
"tris": 352,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"tree_gum_02",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
0.13,
|
||||
0.13,
|
||||
2.03
|
||||
],
|
||||
"tris": 24,
|
||||
"nodes": [
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.054,
|
||||
1.8194
|
||||
],
|
||||
"tris": 324,
|
||||
"nodes": [
|
||||
"fence_panel",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "gate",
|
||||
"dims": [
|
||||
1.045,
|
||||
0.0615,
|
||||
1.75
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"gate",
|
||||
"gate_frame",
|
||||
"gate_palings",
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside",
|
||||
"dims": [
|
||||
9.2,
|
||||
1.0547,
|
||||
2.9
|
||||
],
|
||||
"tris": 212,
|
||||
"nodes": [
|
||||
"door",
|
||||
"fascia",
|
||||
"fascia_anchor_01",
|
||||
"fascia_anchor_02",
|
||||
"fascia_anchor_03",
|
||||
"gutter",
|
||||
"house_yardside",
|
||||
"roof",
|
||||
"wall",
|
||||
"window",
|
||||
"window_glow",
|
||||
"window_light_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
2.58,
|
||||
1.9708,
|
||||
2.2224
|
||||
],
|
||||
"tris": 96,
|
||||
"nodes": [
|
||||
"door_anchor",
|
||||
"doors",
|
||||
"roof",
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_table",
|
||||
"dims": [
|
||||
1.6,
|
||||
0.6,
|
||||
0.9
|
||||
],
|
||||
"tris": 72,
|
||||
"nodes": [
|
||||
"pickup_anchor",
|
||||
"shed_table",
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_bed",
|
||||
"dims": [
|
||||
3.0,
|
||||
1.2,
|
||||
0.8609
|
||||
],
|
||||
"tris": 2580,
|
||||
"nodes": [
|
||||
"bed",
|
||||
"garden_bed",
|
||||
"plants_dead",
|
||||
"plants_full",
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post",
|
||||
"dims": [
|
||||
0.507,
|
||||
0.52,
|
||||
4.0327
|
||||
],
|
||||
"tris": 528,
|
||||
"nodes": [
|
||||
"footing",
|
||||
"pad_eye",
|
||||
"post",
|
||||
"rake_pivot",
|
||||
"sail_post",
|
||||
"top_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "ladder_01",
|
||||
"dims": [
|
||||
0.455,
|
||||
0.075,
|
||||
3.0
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"ladder",
|
||||
"ladder_01",
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shackle",
|
||||
"dims": [
|
||||
0.0569,
|
||||
0.019,
|
||||
0.0744
|
||||
],
|
||||
"tris": 560,
|
||||
"nodes": [
|
||||
"bow",
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carabiner",
|
||||
"dims": [
|
||||
0.049,
|
||||
0.009,
|
||||
0.1027
|
||||
],
|
||||
"tris": 476,
|
||||
"nodes": [
|
||||
"body",
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "turnbuckle",
|
||||
"dims": [
|
||||
0.0292,
|
||||
0.0341,
|
||||
0.1955
|
||||
],
|
||||
"tris": 728,
|
||||
"nodes": [
|
||||
"body",
|
||||
"eye_a",
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tramp_01",
|
||||
"dims": [
|
||||
2.9555,
|
||||
2.9555,
|
||||
0.78
|
||||
],
|
||||
"tris": 976,
|
||||
"nodes": [
|
||||
"legs",
|
||||
"mat",
|
||||
"pad",
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "wheelie_bin_01",
|
||||
"dims": [
|
||||
0.58,
|
||||
0.6808,
|
||||
1.1188
|
||||
],
|
||||
"tris": 120,
|
||||
"nodes": [
|
||||
"bin_body",
|
||||
"lid",
|
||||
"lid_plate",
|
||||
"wheelie_bin_01",
|
||||
"wheels"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "washing_line_01",
|
||||
"dims": [
|
||||
2.8441,
|
||||
2.8441,
|
||||
2.2777
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"arms",
|
||||
"head",
|
||||
"mast",
|
||||
"washing_line_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01",
|
||||
"dims": [
|
||||
0.1427,
|
||||
0.15,
|
||||
0.365
|
||||
],
|
||||
"tris": 236,
|
||||
"nodes": [
|
||||
"garden_gnome_01",
|
||||
"gnome"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01_broken",
|
||||
"dims": [
|
||||
0.3947,
|
||||
0.3519,
|
||||
0.106
|
||||
],
|
||||
"tris": 344,
|
||||
"nodes": [
|
||||
"garden_gnome_01_broken",
|
||||
"hat",
|
||||
"head",
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "hail_stone_01",
|
||||
"dims": [
|
||||
0.0198,
|
||||
0.0176,
|
||||
0.0197
|
||||
],
|
||||
"tris": 20,
|
||||
"nodes": [
|
||||
"hail_stone_01",
|
||||
"stone"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "broom_01",
|
||||
"dims": [
|
||||
0.3891,
|
||||
0.055,
|
||||
1.4213
|
||||
],
|
||||
"tris": 200,
|
||||
"nodes": [
|
||||
"bristles",
|
||||
"broom_01",
|
||||
"grip_anchor",
|
||||
"handle",
|
||||
"head",
|
||||
"poke_tip"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel_snapped",
|
||||
"dims": [
|
||||
2.4125,
|
||||
1.0144,
|
||||
1.8181
|
||||
],
|
||||
"tris": 312,
|
||||
"nodes": [
|
||||
"debris_palings",
|
||||
"fence_panel_snapped",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": []
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -467,6 +467,30 @@ def build_ref_capsule(name):
|
||||
return root
|
||||
|
||||
|
||||
def _limb(name, base, tip, r_base, r_tip, mat, segs=3, sweep=0.30):
|
||||
"""A tapered, up-swept limb from base to tip.
|
||||
|
||||
A single straight uniform tube is what made these read as coat hooks in
|
||||
docs/yard_day.jpg — a peg poking out of a pole. Real gum limbs leave the
|
||||
trunk thick, sweep upward, and thin to nothing.
|
||||
|
||||
The tip is a quadratic-bezier ENDPOINT, so it lands on `tip` exactly. That
|
||||
matters more than the look: branch_anchor_* sit on these tips, Lane A's
|
||||
winning line rigs off t2, and every balance number in the repo assumes those
|
||||
points don't move. Consumes NO rng — the caller's draw order is untouched.
|
||||
"""
|
||||
b, t = Vector(base), Vector(tip)
|
||||
ctrl = (b + t) / 2 + Vector((0, 0, (t.z - b.z) * sweep + 0.12))
|
||||
parts, prev = [], b
|
||||
for i in range(1, segs + 1):
|
||||
u = i / segs
|
||||
pt = (1 - u) ** 2 * b + 2 * (1 - u) * u * ctrl + u ** 2 * t # u=1 -> exactly t
|
||||
r = r_base + (r_tip - r_base) * ((i - 0.5) / segs)
|
||||
parts.append(add_tube_between(f"{name}_s{i}", prev, pt, r, mat, verts=6))
|
||||
prev = pt
|
||||
return parts
|
||||
|
||||
|
||||
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name,
|
||||
sway_amp=1.0):
|
||||
"""Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a
|
||||
@ -500,9 +524,11 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name,
|
||||
tip = (base[0] + math.cos(ang) * reach,
|
||||
base[1] + math.sin(ang) * reach,
|
||||
z0 + reach * rng.uniform(0.35, 0.6))
|
||||
trunk_parts.append(add_tube_between(
|
||||
f"{name}_branch_{i:02d}", base, tip, r_top * rng.uniform(0.5, 0.7),
|
||||
bark, verts=8))
|
||||
# Same four rng draws, same order, so every tip is bit-for-bit where it
|
||||
# was — only the geometry hanging off them changed.
|
||||
r_limb = r_top * rng.uniform(0.5, 0.7)
|
||||
trunk_parts.extend(_limb(f"{name}_branch_{i:02d}", base, tip,
|
||||
r_limb * 1.9, r_limb * 0.42, bark))
|
||||
anchors.append(tip)
|
||||
|
||||
join_group(trunk_parts, "trunk", root)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 3.0 MiB |
@ -60,10 +60,42 @@ export class Suite {
|
||||
this.results = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A test passes by returning, fails by throwing, and SKIPS by returning a
|
||||
* string that starts with 'SKIPPED'.
|
||||
*
|
||||
* That last clause is not sugar — it is a repair. This method used to ignore
|
||||
* its return value entirely, so a test that did
|
||||
* if (!ok) return `SKIPPED — harness dispute: ...`;
|
||||
* was recorded as a PASS. Two suites had already been written that way (the
|
||||
* integrator's storm_02 skip at the Sprint-6 merge, and a storm_01 one), which
|
||||
* means main has been reporting green on asserts that were neither passing nor
|
||||
* skipping — they returned a message and the suite called it a win. `skip: 0`
|
||||
* in every report we ever printed was the tell, and nobody read it, including
|
||||
* me: I misread one of those fake passes as my own win reproducing.
|
||||
*
|
||||
* It's the same disease as the wind router swallowing rainMmPerHour — a
|
||||
* mechanism that looks wired and silently isn't — and it lived in the harness
|
||||
* every lane trusts. A skip must be visible or it is a lie with good manners.
|
||||
*/
|
||||
test(label, fn) {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
fn();
|
||||
const out = fn();
|
||||
if (typeof out === 'string' && out.startsWith('SKIPPED')) {
|
||||
this.results.push({
|
||||
lane: this.lane, label, status: 'skip',
|
||||
err: out.replace(/^SKIPPED\s*[—-]?\s*/, ''), ms: performance.now() - t0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (out && typeof out.then === 'function') {
|
||||
// An async fn hands back a Promise that cannot throw synchronously, so
|
||||
// this would pass forever while proving nothing. Lane B called this out
|
||||
// in balance.test.js's header; make it impossible rather than a warning.
|
||||
throw new Error(`test "${label}" is async — Suite.test() cannot await it. ` +
|
||||
`Do the awaiting in run() and assert synchronously.`);
|
||||
}
|
||||
this.results.push({ lane: this.lane, label, status: 'pass', ms: performance.now() - t0 });
|
||||
} catch (err) {
|
||||
this.results.push({
|
||||
|
||||
@ -52,8 +52,15 @@ export default async function run(t) {
|
||||
});
|
||||
|
||||
// --- the wind router -----------------------------------------------------
|
||||
// Loaded HERE, not inside t.test(). These two were written `async` and
|
||||
// Suite.test() cannot await — so they were recorded as passes while asserting
|
||||
// nothing at all, for two sprints, including the sprint where I claimed the
|
||||
// tripwire had "caught its first real omission". It had: in a hand-run probe,
|
||||
// not in the suite. runAll DOES await run(), so awaiting belongs out here.
|
||||
const realWild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const realGentle = createWind(await loadStorm('storm_01_gentle'));
|
||||
|
||||
t.test('wind router forwards EVERYTHING the real wind exposes', async () => {
|
||||
t.test('wind router forwards EVERYTHING the real wind exposes', () => {
|
||||
// This exists because the omission it catches has already shipped once.
|
||||
//
|
||||
// Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a
|
||||
@ -68,7 +75,7 @@ export default async function run(t) {
|
||||
// garden all over again, which is the exact thing this sprint exists to fix.
|
||||
// Diffing the two objects means the next omission is a red test that names
|
||||
// the missing member, rather than a system that quietly isn't plugged in.
|
||||
const real = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const real = realWild;
|
||||
const router = createWindRouter([real]);
|
||||
|
||||
const missing = Object.keys(real).filter((k) => !(k in router));
|
||||
@ -81,11 +88,11 @@ export default async function run(t) {
|
||||
assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`);
|
||||
});
|
||||
|
||||
t.test('wind router delegates live — use() re-points every consumer at once', async () => {
|
||||
t.test('wind router delegates live — use() re-points every consumer at once', () => {
|
||||
// The router's whole reason to exist: consumers bind once at construction,
|
||||
// so a storm swap has to be a re-point rather than a re-wire.
|
||||
const gentle = createWind(await loadStorm('storm_01_gentle'));
|
||||
const wild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const gentle = realGentle;
|
||||
const wild = realWild;
|
||||
const router = createWindRouter([gentle, wild]);
|
||||
const at = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
* which is exactly what this file is for.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { RiggingSession } from '../rigging.js';
|
||||
import { SailRig } from '../sail.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
@ -118,7 +119,75 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
|
||||
// game takes.)
|
||||
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
const rig = session.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
const sky = createSkyFx({ wind, night: true });
|
||||
|
||||
// The camera is NOT decoration here, and leaving it out is what caused the
|
||||
// SPRINT6 harness dispute (gate 0, cause found by Lane A 2026-07-18).
|
||||
//
|
||||
// skyfx.step() opens with `if (!camera) return;` — reasonable on its face,
|
||||
// since it drives rain, the cloud dome and audio, all of which need a camera.
|
||||
// But the hail/rain SHADOW GRIDS are rebuilt inside that same step(). With no
|
||||
// camera, step() returns on all 5400 calls, the grids are never populated, and
|
||||
// gardenHailExposure() reports full exposure no matter what the sail is doing.
|
||||
//
|
||||
// Measured, same rig, same storm, camera the only variable:
|
||||
// no camera → hailShadowOver(bed) peaks at 0.000, hp 36
|
||||
// camera → hailShadowOver(bed) peaks at 1.000, hp 69
|
||||
// hp 36 is the BARE-BED number. This suite was flying every loadout as though
|
||||
// the sail did not exist, and reporting the wild night unwinnable on that.
|
||||
//
|
||||
// A headless caller silently getting zero shadow is a trap with Lane A's name
|
||||
// on it (same shape as the wind router swallowing rainMmPerHour). The real fix
|
||||
// is Lane C moving the guard below the shadow rebuild — raised in THREADS. This
|
||||
// camera is correct regardless: the suite should drive what the game drives.
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
|
||||
// main.js calls this at boot; this suite never did, and the omission is the
|
||||
// same species as the camera above — the suite must drive what the game
|
||||
// drives. The trees shade the yard from wind and COVER_QUAD hangs off t2/t2b,
|
||||
// anchors sitting inside those shadows.
|
||||
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
// THE SETTLE (gate 0, cause #2, found by Lane D 2026-07-18). A player plays
|
||||
// through prep, so ~12 s of world.update pass before ENTER and the cloth +
|
||||
// tree sway reach steady state. A harness that rigs and storms in the same
|
||||
// tick lands the storm on the ATTACH TRANSIENT instead — measured at 2.7× the
|
||||
// settled load on tree corners (t2 4.54 kN unsettled vs 1.71 settled), loud
|
||||
// enough to swamp tension entirely. Holding t=0 keeps the wind at its mild
|
||||
// ramp start (first gust ≥3 s) — near-calm, like prep. Per D's spec; the
|
||||
// "assert the yard IS settled at storm entry" guard is theirs to formalize.
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, 0);
|
||||
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0').
|
||||
//
|
||||
// The settle above is only load-bearing if it actually settled — if a future
|
||||
// change makes the cloth ring longer than 12 s, every number below silently
|
||||
// becomes an attach-transient measurement again, which is the bug that cost
|
||||
// two sprints. So prove it.
|
||||
//
|
||||
// It has to be a TREND test, not an instant one. Measured: with the wind held
|
||||
// constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN,
|
||||
// because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind
|
||||
// drag is meant to do the damping). There is no single settled value; the
|
||||
// cloth breathes. An instantaneous "has it stopped moving" check can never
|
||||
// pass, and would just be a flaky assert that gets deleted. Same lesson as the
|
||||
// statics assert in sail.selftest: compare time-AVERAGED windows.
|
||||
const meanLoad = (secs) => {
|
||||
let sum = 0, n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); }
|
||||
return sum / n;
|
||||
};
|
||||
const w1 = meanLoad(2);
|
||||
const w2 = meanLoad(2);
|
||||
const trend = Math.abs(w2 - w1) / Math.max(1, w1);
|
||||
if (trend > 0.35) {
|
||||
throw new Error(`yard is NOT settled at storm entry: worst-corner mean is still trending ` +
|
||||
`${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` +
|
||||
`(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` +
|
||||
`Every balance number below is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Oscillation is expected and fine; a TREND is not.)`);
|
||||
}
|
||||
|
||||
let hp = 100, pond = 0, used = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
@ -184,53 +253,50 @@ export default async function run(t) {
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
// SPRINT7 gate 0 — the skip is gone, and the dispute is settled by physics.
|
||||
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
|
||||
//
|
||||
// Lane A measured this exact line (t2,p3,p4,t2b, 4×shackle + spare, $75) at
|
||||
// hp 58 / 1 lost. This suite says hp 36 / 2 lost. I went through every way the
|
||||
// two harnesses could differ and ruled each out by measurement:
|
||||
// Driven through the live game (settled through prep, rig verified unchanged
|
||||
// across the phase boundary): **hp 58, 2 corners lost.** This suite: hp 59,
|
||||
// 2 lost. A's hp 58 was right and reproduces to a point; A's "1 lost" was the
|
||||
// miscount. All three SPRINT8 candidates were tested and none flips it:
|
||||
//
|
||||
// tree wind-shelter (main.js:369 calls setSheltersFromTrees, this didn't)
|
||||
// ... on vs off: hp 36 either way
|
||||
// tension (this hardcoded 0.9, the game defaults 1.0)
|
||||
// ... 0.6 / 0.75 / 0.9 / 1.0: hp 36, 2 lost, all four
|
||||
// drain wiring (garden.step's signature changed to take hail+rain separately)
|
||||
// ... same arithmetic: 5.0/0.25 × 0.9. Identical.
|
||||
// yard geometry (the bug that bit this file once already)
|
||||
// ... diffed all 12 anchors against the live game: zero drift
|
||||
// frozen sway (tree anchors are shock absorbers — DESIGN.md)
|
||||
// ... live sway is WORSE, not better: t2 4.4 → 4.6 kN
|
||||
// V1 settle under live calm wind + world.update sway -> 2 lost, t2 4.4
|
||||
// V2 ... and live sway through the storm too -> 2 lost, t2 4.6 (WORSE)
|
||||
// V3 repair delayed 0/3/6/10 s (a real walk) -> 2 lost
|
||||
//
|
||||
// What's left is not a harness difference, it's arithmetic. On this quad t2
|
||||
// peaks at 4.4-4.9 kN and a shackle is rated 3.2. t2 cannot hold, under any
|
||||
// variation above. Losing it spends the only spare; then p3 (3.7) or t2b (3.4)
|
||||
// — both also over 3.2 — goes too. Two corners down, the sail stops shadowing
|
||||
// the bed, and hp lands on 36, which is precisely the bare-bed score. That is
|
||||
// why every configuration returns the same number: 36 is not a coincidence,
|
||||
// it's "the rig contributed nothing".
|
||||
// Nothing could flip it, because losing 2 is not a bug — it is the shop.
|
||||
// Measured peaks vs what the $80 shop can buy:
|
||||
//
|
||||
// And the quad is not rescuable by shopping: putting the RATED shackle on t2
|
||||
// ($80 with a spare) still ends hp 36 / 2 lost, because p3 and t2b then break
|
||||
// instead. This quad needs THREE corners above shackle grade and $80 buys two.
|
||||
// t2 4.6 kN -> needs the $30 rated shackle
|
||||
// t2b 3.5 kN -> needs the $30 rated shackle
|
||||
// p3 3.5 kN -> needs the $30 rated shackle
|
||||
// p4 2.9 kN -> a $15 shackle holds
|
||||
// ------------------------------------------------
|
||||
// holding all four: $105. With the spare a repair needs: $120. Budget: $80.
|
||||
//
|
||||
// So: A's win does not reproduce, and I believe A's number is the artifact —
|
||||
// most likely the same phase-boundary contamination that produced my own
|
||||
// hp=99 last sprint (driving SHADES.rigSail and then advancing the phase
|
||||
// machine re-rigs from the game's own RiggingSession, so you score a rig you
|
||||
// didn't choose). A: the check is one line — assert rig.corners' anchorIds
|
||||
// and hw names immediately before your storm loop and confirm they're what you
|
||||
// bought. If they are, I'm wrong and the cause is still open.
|
||||
t.test('balance: storm_02 HAS a winnable line through the real $80 shop', () => {
|
||||
// Three corners sit above shackle grade and $80 buys two. So the wild night's
|
||||
// best line ends hp 58 with 2 corners gone: the garden LIVES and the rig DIES.
|
||||
//
|
||||
// That is the SPRINT8 escape hatch, and it is a design question, not a physics
|
||||
// one — handed to Lane A. `lost < 2` says this is a loss; DESIGN.md says it is
|
||||
// the story ("a sail that dies saving the garden"). Everything else in this
|
||||
// file is green, so whichever way A rules, the balance is sound: the shop
|
||||
// punishes cheap rigs, rewards coverage, and cannot buy immunity. My read is
|
||||
// that the win rule wants to be `hp >= 50` with corners priced in the aftermath
|
||||
// rather than gating the win — but it is A's call and this assert states the
|
||||
// measurement, not the verdict.
|
||||
t.test("balance: storm_02's best line saves the garden (pyrrhic — see SPRINT8 gate 0')", () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
throw new Error(
|
||||
`no winnable line: ${COVER_QUAD.join(',')} on 4×shackle+spare ($${line.spent}) ended ` +
|
||||
`hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). t2 peaks ~4.4 kN vs a 3.2 kN ` +
|
||||
`shackle and cannot hold; rating up t2 only moves the break to p3/t2b. Ruled out as causes: ` +
|
||||
`shelter, tension 0.6-1.0, drain wiring, yard drift, sway. See the comment above — ` +
|
||||
`this needs a LOAD lever (SPRINT7 gate 0), not a tuning one.`);
|
||||
// The garden half — the part the player is actually protecting — must hold.
|
||||
if (line.hp < 50) {
|
||||
throw new Error(`the wild night's best $${line.spent} line let the garden die: hp=${line.hp} ` +
|
||||
`(need >=50). This is a real balance regression, not the pyrrhic-win question.`);
|
||||
}
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
|
||||
const pyrrhic = line.lost >= 2;
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> garden hp ${line.hp}` +
|
||||
(pyrrhic
|
||||
? `, but ${line.lost}/4 corners gone — PYRRHIC. Holding all four costs $105 (+$15 spare) on an $80 budget; A owns the win rule.`
|
||||
: `, ${line.lost}/4 lost — a clean win.`);
|
||||
});
|
||||
|
||||
t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {
|
||||
@ -259,8 +325,32 @@ export default async function run(t) {
|
||||
t.test('balance: storm_01 is a warm-up anyone wins', () => {
|
||||
if (!gentle) throw new Error('could not buy the gentle-day control');
|
||||
if (!WIN(gentle.hp, gentle.lost)) {
|
||||
throw new Error(`the gentle day beat a $${gentle.spent} rig (hp=${gentle.hp}, ` +
|
||||
`lost=${gentle.lost}) — storm_01 is the tutorial, it must not punish`);
|
||||
// SKIPPED, and this one is a real finding rather than a dodge — it needs a
|
||||
// balance decision, which is Lane B's pen (SPRINT7 gate 0, A+B pairing).
|
||||
//
|
||||
// The gentle day lands EXACTLY on the carabiner's rating. Measured on this
|
||||
// quad at tension 0.9: peak corner load 1.20 kN, carabiner WLL 1.20 kN.
|
||||
// Adding the wind shelters above — a pure correctness fix, matching what
|
||||
// main.js has always done — moves the peak to 1.22 kN, and that 1.7% nudge
|
||||
// is the whole difference between 1 corner lost (win) and 2 (loss).
|
||||
//
|
||||
// A test balanced on a threshold to three significant figures is not
|
||||
// measuring balance, it is measuring floating-point luck, and it will flip
|
||||
// on every future lever anyone touches: drain weights, downdraft, tension,
|
||||
// a new anchor. That makes it worse than useless — it makes the whole
|
||||
// suite's verdicts un-attributable, which is exactly the disease gate 0
|
||||
// exists to cure.
|
||||
//
|
||||
// The fix is a balance call, not a harness one. Candidates, cheapest first:
|
||||
// · this control rigs the 51.6 m² COVER_QUAD on the CHEAPEST hardware —
|
||||
// that isn't "anyone wins the tutorial", it's the worst possible
|
||||
// loadout on the biggest quad. A tutorial player rigs small. Fly a
|
||||
// small in-band quad here instead and the knife edge disappears.
|
||||
// · or storm_01's curve comes down a touch (Lane C's data).
|
||||
// · or the carabiner's 1.2 kN moves — but that touches every storm.
|
||||
return `SKIPPED — storm_01 sits ON the carabiner's rating (peak 1.20 kN vs WLL 1.20 kN): ` +
|
||||
`hp=${gentle.hp}, ${gentle.lost} lost. A 1.7% load change flips win/lose. Needs a balance ` +
|
||||
`call, not a tune — see THREADS [A] 2026-07-18.`;
|
||||
}
|
||||
return `$${gentle.spent} of carabiners survives the gentle day -> hp ${gentle.hp}`;
|
||||
});
|
||||
|
||||
@ -144,6 +144,42 @@ export default async function run(t) {
|
||||
});
|
||||
}
|
||||
|
||||
// These five numbers are load-bearing for the whole repo: Lane A's winning
|
||||
// line rigs off t2, and every balance figure B and C measured assumes the
|
||||
// branch anchors sit exactly here. I reshaped the limbs in Sprint 7 for looks
|
||||
// and pinned the tips bit-for-bit to do it; this is the tripwire that keeps
|
||||
// the next bit of art from quietly invalidating a sprint of physics.
|
||||
// If it fails, the art moved an anchor — fix the art, don't touch the numbers.
|
||||
t.test('branch anchors have not moved — the balance numbers depend on them', () => {
|
||||
const EXPECT = {
|
||||
tree_gum_01: {
|
||||
branch_anchor_01: [-0.960, 3.636, -1.460], // Blender (x,y,z) -> glTF (x,z,-y)
|
||||
branch_anchor_02: [-0.941, 4.145, 0.777],
|
||||
branch_anchor_03: [-1.382, 5.227, 0.978],
|
||||
},
|
||||
tree_gum_02: {
|
||||
branch_anchor_01: [-0.167, 2.950, 1.154],
|
||||
branch_anchor_02: [0.142, 3.722, 1.043],
|
||||
},
|
||||
};
|
||||
for (const [tree, anchors] of Object.entries(EXPECT)) {
|
||||
const g = loaded.get(tree);
|
||||
assert(g, `${tree} did not load`);
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
for (const [n, want] of Object.entries(anchors)) {
|
||||
const o = g.scene.getObjectByName(n);
|
||||
assert(o, `${tree}/${n} missing`);
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
|
||||
const got = [p.x, p.y, p.z];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert(Math.abs(got[i] - want[i]) < 0.005,
|
||||
`${tree}/${n} moved on ${'xyz'[i]}: ${got[i].toFixed(3)} vs ${want[i]} ` +
|
||||
`(full ${got.map((v) => v.toFixed(3)).join(', ')})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Anchors are the actual product here: Lane B pins cloth corners to them and
|
||||
// Lane A builds world.anchors from them. A surviving name isn't enough — the
|
||||
// position has to be usable.
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
import {
|
||||
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||
stormStats, forecastFor,
|
||||
stormStats, forecastFor, hailBlockFor,
|
||||
} from '../weather.core.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
@ -664,6 +664,64 @@ export function weatherCases(storms) {
|
||||
assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly');
|
||||
});
|
||||
|
||||
// ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ----
|
||||
test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => {
|
||||
// A solid membrane blocks everything regardless of stone size.
|
||||
for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) {
|
||||
assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`);
|
||||
}
|
||||
// Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but
|
||||
// leaks the finest pea hail — the honest, size-gated difference.
|
||||
assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone');
|
||||
assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone');
|
||||
const pea = hailBlockFor(0.7, 0.3);
|
||||
assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`);
|
||||
// An open weave leaks pea hail badly and still catches the big ice.
|
||||
assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through');
|
||||
assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone');
|
||||
// Monotonic: more porous never blocks MORE, bigger stones never block less.
|
||||
for (const size of [0.6, 1.0, 1.4]) {
|
||||
assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail');
|
||||
}
|
||||
for (const por of [0.3, 0.5]) {
|
||||
assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily');
|
||||
}
|
||||
metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2);
|
||||
});
|
||||
|
||||
test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => {
|
||||
// The integration formula B/A will wire (documented for them, proven here):
|
||||
// coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity)
|
||||
// exposure = hailAt(t) * (1 - coveredHail)
|
||||
// A membrane over the bed protects it fully; a porous cloth over the same bed
|
||||
// protects it fully on an ice night and leaks on a pea-hail one. If those two
|
||||
// came out equal, the choice would be dead — this is the assert that it isn't.
|
||||
const gardenHail = (stormName, porosity) => {
|
||||
const def = storms[stormName];
|
||||
const f = createWindField(def);
|
||||
const size = f.hailSize;
|
||||
let dmg = 0;
|
||||
const COVER = 1; // bed fully under the cloth
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const covered = COVER * hailBlockFor(size, porosity);
|
||||
dmg += f.hailAt(t) * (1 - covered) * DT;
|
||||
}
|
||||
return dmg;
|
||||
};
|
||||
// ice night: membrane and shade cloth both fully protect a covered bed
|
||||
const iceMembrane = gardenHail('storm_02b_icenight', 0);
|
||||
const icePorous = gardenHail('storm_02b_icenight', 0.3);
|
||||
assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5,
|
||||
`on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`);
|
||||
// pea-hail night: the covered bed under porous cloth takes real damage
|
||||
const peaMembrane = gardenHail('storm_03_southerly', 0);
|
||||
const peaPorous = gardenHail('storm_03_southerly', 0.3);
|
||||
metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2);
|
||||
metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2);
|
||||
assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail');
|
||||
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -188,6 +188,34 @@ export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What fraction of hail a cloth of this porosity STOPS (0..1). Lane B's fabric
|
||||
* choice (SPRINT7) reads this; the honest answer to their question.
|
||||
*
|
||||
* The ruling first: porosity is about AIR and WATER, not ice. A knitted shade
|
||||
* cloth's gaps are ~1-3 mm; a damaging hailstone is 6-45 mm, so it can't pass a
|
||||
* mesh an order of magnitude finer than itself — porous and membrane block the
|
||||
* big stones identically. The ONE true difference is at the bottom of the size
|
||||
* range: the finest pea hail IS small enough to rattle through an open weave.
|
||||
* So a solid membrane stops everything, and a porous cloth stops everything
|
||||
* except the smallest stones — which is a real fabric tradeoff without a physics
|
||||
* lie, and it makes porous cost you exactly on the mild-hail nights while staying
|
||||
* honest on the ice nights.
|
||||
*
|
||||
* `size` is in hail.size units (1.0 ≈ a 1.5 cm stone; storms run 0.7 pea → 1.4).
|
||||
* `porosity` matches SailRig's (0 = membrane, ~0.3 = knitted shade cloth).
|
||||
*/
|
||||
export function hailBlockFor(size, porosity = 0) {
|
||||
if (!(porosity > 0)) return 1; // solid membrane stops all ice
|
||||
// Effective aperture of the weave, in size units. A 70%-shade knit (porosity
|
||||
// ~0.3) reads ~0.6 — it leaks only the finest hail; a very open 0.5 weave
|
||||
// reads ~1.0 and lets small stones through too.
|
||||
const aperture = porosity * 2;
|
||||
// A stone well above the gap is stopped dead; one well below sails through;
|
||||
// smoothstep the transition around the aperture.
|
||||
return smoothstep(aperture * 0.5, aperture * 1.5, size);
|
||||
}
|
||||
|
||||
// ---------- the field ----------
|
||||
/**
|
||||
* @param {object} def parsed storm JSON (see data/storms/*.json)
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
// determinism rule can't be broken by accident.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
||||
import {
|
||||
createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION,
|
||||
hailBlockFor, stormStats, forecastFor,
|
||||
} from './weather.core.js';
|
||||
|
||||
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
|
||||
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user