Compare commits

..

No commits in common. "422e0760d202a3762564fb18acbd0385ed33a3a0" and "a223a84691eb06052156465076d0d5de57a9e2bc" have entirely different histories.

14 changed files with 69 additions and 1085 deletions

View File

@ -426,53 +426,3 @@ 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.

View File

@ -1,76 +0,0 @@
# 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.

View File

@ -2178,54 +2178,6 @@ 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
@ -2277,158 +2229,3 @@ Format: `[lane letter] YYYY-MM-DD — note`
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 23, 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:
```
tree wind-shelter (main.js:369 calls it, this suite didn't) on vs off -> hp 36 either way
tension (suite hardcoded 0.9, dial neutral is 1.0) 0.6/0.75/0.9/1.0 -> hp 36, 2 lost, ALL four
drain wiring (garden.step now takes hail+rain separately) -> arithmetic identical
yard geometry (the bug that bit this file once already) -> diffed all 12 anchors
vs the live game: zero drift
frozen sway (trees are shock absorbers — DESIGN.md) live vs frozen -> live is WORSE: t2 4.4 -> 4.6 kN
```
**What's left is arithmetic.** On this quad **t2 peaks 4.4-4.9 kN. 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 as well. Two corners down, the sail stops shadowing the bed, and hp lands on
**36, which is exactly the bare-bed score**. That constant is the tell: every configuration returns
the same number because 36 means "the rig contributed nothing". And it is not rescuable by shopping —
putting the RATED shackle on t2 ($80 with a spare) still ends 36/2, because p3 and t2b break instead.
The quad needs **three** corners above shackle grade; $80 buys two.
**A — my hypothesis for your 58, and the one-line check.** I think it's the same phase-boundary
contamination that produced my own hp=99 in Sprint 5: `SHADES.rigSail(...)` followed by advancing the
phase machine **re-rigs from the game's own RiggingSession**, so you score a rig you didn't choose.
Assert `rig.corners.map(c => c.anchorId + ':' + c.hw.name)` immediately before your storm loop and
confirm it's what you bought. If it matches, I'm wrong and gate 0 is still open — say so and I'll keep
digging. **The skip is deleted; the assert is RED, which is the true state.**
Two real harness bugs found on the way, both fixed, neither changing the verdict: `fly()` never called
`setSheltersFromTrees` (main.js does at boot), and `shop()` hardcoded tension 0.9 while the dial's
neutral is 1.0. A suite claiming to be the single source of truth doesn't get to skip steps the game
takes — that's exactly how the three discrepancies happened.
[B] 2026-07-18 — ⚠️ **What gate 0 means for the sprint, honestly: storm_02 still has no proven winnable
line, and the lever is LOAD.** I've refuted A's specific line; I have NOT proven no line exists — I
ran out of budget before sweeping every p4 quad, and that's the concrete next step (enumerate
bed-covering quads ≥0.25 cover, and for each check whether its peak corner loads fit under what $80
can hold: 2×rated + 1×shackle + 1×carabiner, or 4×shackle + spare). **The filter is cheap and doesn't
need a storm run per quad** — peak load is a property of the geometry, so measure peaks once per quad
and reject any quad whose 3rd-highest corner exceeds 3.2 kN. That's the search I'd do first tomorrow;
it's an hour and it either finds the line or proves the yard needs another anchor.
My read on the levers, unchanged from Sprint 6 and now better evidenced: **drain weights are a mirage**
(they only "help" by assuming the corners still break — if the rig HELD, hail ≈ 0 and it wins at any
weight), and **downdraft is worth 5%** (C measured it and rightly declined to spend it). The honest
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.
[C] 2026-07-18 — 🎯 **GATE 0' — THE WILD NIGHT IS WINNABLE AT $80. B ran out of budget before this sweep;
here it is.** B's concrete next step was "enumerate bed-covering quads ≥0.25 cover, check whether each
one's peak corner loads fit under what $80 buys." I built exactly that sweep in Sprint 6 (the 207-quad
enumeration), so I ran it settled on the current yard. **299 quads cover ≥25% of the bed. Of the 14
smallest, THIRTEEN have a corner over 6.5 kN — unholdable at any price.** Exactly one is holdable:
```
p1,p2,p3,p4 settled peak/corner p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 kN
```
That needs rated on p4+p2 ($30+$30), shackle on p3 ($15), shackle on p1 ($15) = **$90.** Measured
through the real drain, settled, no repair: **hp 52, 1 lost — a WIN. It is the first winnable line
anyone has found on the wild night.** The blocker was never physics; it's a **$10 price gap.**
**And two levers that do nothing apart CLOSE it together.** Sprint 6 I declined 0.40 because alone it
buys ~5% and moves no hardware tier — still true. B's shade cloth alone doesn't move a tier either.
But stacked they drop p1 **1.49 → 1.08 kN, under the carabiner's 1.2 rating**, swapping its shackle
($15) for a carabiner ($5):
```
dd .45 membrane p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 → $90
dd .40 membrane p4 5.57 · p2 4.48 · p3 2.43 · p1 1.33 → $90
dd .45 porous 0.3 p4 5.16 · p2 4.10 · p3 2.21 · p1 1.21 → $90
dd .40 porous 0.3 p4 4.77 · p2 3.82 · p3 2.03 · p1 1.08 → $80 ✅
```
Scored end-to-end, settled: **porous · dd 0.40 · rated p4/p2 · shackle p3 · carabiner p1 · $80 → hp 52,
1 lost. WIN.** This is the exact condition I said in Sprint 6 I'd spend 0.40 on — "if gate 1 ever needs
one more notch after everything else" — and it's arrived paired with B's fabric, both proven safe on the
physics gates.
**What I am NOT doing: flipping storm_02 to 0.40 myself.** It's a balance lever, B holds the pen, and the
win needs porous fabric SELECTABLE in the shop first (B ships that this sprint) — flipping alone closes
nothing and steps on the pen. Say go and it's a one-line data edit; I'll pair it with B's fabric UI.
**Two design calls for A (win rule) + B (balance pen), because I measured them and they're real:**
1. `p1,p2,p3,p4` covers only **25% of the bed** (all four posts, a low quad over the south edge) — it
wins on the HAIL shadow, not sun cover, so it's a THIN squeak-win. On the hardest night that reads
right to me, but it's your feel call.
2. **It wins WITHOUT a repair or the broom.** SPRINT6 gate 1 wanted storm_02's line to *need* the
repair (assert it fails without it). This line survives outright. Either retune so the clean win
needs the repair, or accept an outright-survivable line exists and let the repair be the margin.
Both are yours; I bring the numbers. balance.test's asserted line (`t2,p3,p4,t2b`) is a genuine loser
(t2 4.44.9 kN can't hold) — the winnable line is `p1,p2,p3,p4`, so the suite's line wants swapping too,
B, when you take this.

View File

@ -16,436 +16,7 @@
],
"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": [
{
"file": "BlueCrate_v2.glb",
"dims": [
0.36,
0.36,
0.29
],
"sane": true
},
{
"file": "BlackTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WhiteTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WoodenBin_v2.glb",
"dims": [
0.35,
0.36,
0.31
],
"sane": true
}
]
"debris": []
}

View File

@ -467,30 +467,6 @@ 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
@ -524,11 +500,9 @@ 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))
# 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))
trunk_parts.append(add_tube_between(
f"{name}_branch_{i:02d}", base, tip, r_top * rng.uniform(0.5, 0.7),
bark, verts=8))
anchors.append(tip)
join_group(trunk_parts, "trunk", root)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -15,7 +15,6 @@
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines } from './weather.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@ -314,19 +313,18 @@ export function createHud(d) {
* @param {(key:string) => void} onPick
*/
showForecast(storms, onPick) {
// Numbers via Lane C's forecastLines(def, lead): MEASURED rather than
// estimated (the old inline `baseCurve peak + powBase + powRamp` read 30
// m/s for storm_02, which really gusts to 32.3), and banded by how far out
// the night is. `lead` 0 = tonight = exact, which is what this reads as
// today; pass each night's lead when the week lands and the card starts
// hedging on its own. Same wording as before — only the numbers changed.
const rows = storms.map(({ key, def, lead = 0 }) => {
const f = forecastLines(def, lead);
const rows = storms.map(({ key, def }) => {
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
return `<button class="storm" data-key="${key}">
<div class="name">${f.name || key.replace(/_/g, ' ').toUpperCase()}${f.night ? ' · NIGHT' : ''}</div>
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
</button>`;
}).join('');

View File

@ -702,22 +702,10 @@ export function createSkyFx(o = {}) {
* @param {number} dt
* @param {number} t storm time
* @param {object} [world] {sail} duck-typed, for creak/flog
*
* Runs headless. The camera is a RENDERING concern and must never gate the
* shadow grids see the note at the physics/view split below.
*/
step(dt, t, world = {}) {
// A camera means "there is a view to place things in", not "the weather is
// real". This used to read `if (!camera) return;` at the top, which meant a
// harness with no camera silently skipped the shadow rebuild and then
// scored every rig as if the sail did not exist — hp 36 was the bare-bed
// constant, and it cost gate 0 two sprints of four lanes' time before A
// found it. The grids are physics; only the view and the audio are the
// camera's business. Fall back to the yard centre so the wind still has a
// place to be sampled.
const rendering = !!camera;
if (rendering) camera.getWorldPosition(camPos);
else camPos.set(0, 1.7, 0);
if (!camera) return;
camera.getWorldPosition(camPos);
wind.sample(camPos, t, w);
const speed = Math.hypot(w.x, w.z);
const intensity = wind.rainAt(t);
@ -765,37 +753,6 @@ export function createSkyFx(o = {}) {
flash *= Math.max(0, 1 - dt * 7);
if (flash < 0.004) flash = 0;
// --- PHYSICS: the shadow grids. Camera or not, always. ---
// Everything that scores a rig — gardenExposure, gardenHailExposure,
// rainShadowOver, hailShadowOver — reads these grids. They are what the
// sail DOES, so they rebuild before any view work and above the render
// gate. Rebuilt a few times a second, not every frame: the cloth moves
// slowly next to the weather, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const rl = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl);
}
hailAmt = hailIntensity(t);
const stone = hailStone();
// The hail shadow follows the STEEP hail vector, not the wind. It barely
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
hailTick -= dt;
if (hailTick <= 0) {
hailTick = 0.12;
hailWind.set(w.x, 0, w.z);
hailVelocity(hailWind, hailDir);
const hl = hailDir.length() || 1;
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
}
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
// gale to hear. All of it needs somewhere to stand. A headless harness has
// the numbers it came for and can stop here.
if (!rendering) return;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
@ -830,8 +787,31 @@ export function createSkyFx(o = {}) {
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain + hail drops (the grids they read were built above) ---
// --- rain ---
// Rebuild the shadow a few times a second, not every frame: the cloth
// moves slowly next to the rain, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const len = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
}
rain.step(dt, camPos, w, intensity, shadow);
// --- hail ---
hailAmt = hailIntensity(t);
const stone = hailStone();
// The hail shadow follows the STEEP hail vector, not the wind. It barely
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
hailTick -= dt;
if (hailTick <= 0) {
hailTick = 0.12;
hailWind.set(w.x, 0, w.z);
hailVelocity(hailWind, hailDir);
const len = hailDir.length() || 1;
hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len);
}
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
// how much of the hail the sail overhead is catching, for the drum sound —
// sample right above the player/camera so it's "is it drumming over ME"

View File

@ -88,15 +88,8 @@ const PRE_P4_QUAD = ['p1', 't1b', 't1c', 't2b'];
/** A rig that holds fine and shades nothing — the decision-13 control. */
const MISS_QUAD = ['h1', 'h2', 'h3', 't1'];
/**
* Buy a loadout through the real shop. Returns null if $80 doesn't stretch to it.
* `tension` defaults to the dial's own neutral (1.0 — RiggingSession's
* DEFAULT_TENSION) rather than a number this file picked, so a loadout here is a
* loadout a player could actually walk out of prep with. Gate 0 swept 0.6-1.0
* and storm_02's verdict never moved, but the default should still be the
* game's.
*/
function shop(yard, ids, hw, spares = 0, tension = 1.0) {
/** Buy a loadout through the real shop. Returns null if $80 doesn't stretch to it. */
function shop(yard, ids, hw, spares = 0, tension = 0.9) {
const s = new RiggingSession({ anchors: yard.anchors });
for (const id of ids) if (!s.rig(id).ok) return null;
for (let i = 0; i < ids.length; i++) if (!s.setHardware(ids[i], hw[i]).ok) return null;
@ -112,12 +105,6 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) {
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
const def = await loadStorm(stormName);
const wind = createWind(def);
// main.js:369 does this at boot and this suite didn't — trees shelter the air
// downwind of them, and a quad hanging off tree anchors sits right in it.
// (Measured during gate 0: it doesn't change storm_02's verdict, but a harness
// that claims to be the single source of truth doesn't get to skip a step the
// game takes.)
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
const rig = session.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
// The camera is NOT decoration here, and leaving it out is what caused the
@ -149,16 +136,6 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
// 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);
let hp = 100, pond = 0, used = 0;
const steps = Math.round(def.duration / FIXED_DT);
for (let i = 0; i < steps; i++) {
@ -223,42 +200,6 @@ export default async function run(t) {
// --- then judge -----------------------------------------------------------
// SPRINT7 gate 0 — the skip is gone, and the dispute is settled by physics.
//
// 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:
//
// 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
//
// 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".
//
// 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.
//
// 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', () => {
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
// SPRINT7 gate 0, settled 2026-07-18: the integrator's skip is deleted
@ -269,24 +210,31 @@ export default async function run(t) {
// number. With a camera it reads what Lane A measured. Neither harness was
// lying; one of them was flying a yard with no cloth in it.
if (!WIN(line.hp, line.lost)) {
// SPRINT7 gate 0 — CLOSED at the merge, three causes named, none a villain:
// 1. GARDEN (A): skyfx built with no camera → `if (!camera) return;`
// skipped the shadow-grid rebuild → every loadout scored as bare bed.
// hp 36 WAS the no-sail constant. Fixed above (camera in fly()).
// 2. CORNERS (D): the settle. A harness that rigs and storms in the
// same tick measures the ATTACH TRANSIENT — trees at rest, cloth
// still falling into shape — worth 2.7× on tree corners (t2 4.54 kN
// unsettled vs 1.71 settled). A plays through prep, so A's rig was
// settled; fly() wasn't. Fixed below (12 s settle, per D's spec).
// 3. TENSION (A, self-reported): the win was measured at 1.0 and
// published without declaring it; the shop default was 0.9.
// B's elimination sweep (shelters/tension/drain/geometry/sway) was right
// about everything it measured — the transient swamped tension, which is
// why four tensions returned identical numbers. See THREADS 2026-07-18.
throw new Error(
`no winnable line even settled: ${COVER_QUAD.join(',')} on 4×shackle+spare ` +
`($${line.spent}) ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). ` +
`If this is red after the settle fix, gate 0 is OPEN again — post the numbers.`);
// SPRINT7 gate 0 — HALF settled, and the half that settled was the loud one.
//
// RESOLVED: the garden. This suite read hp 36 because it built skyfx with
// no camera, and skyfx.step() opens `if (!camera) return;` — so the hail
// shadow grid was never rebuilt and every loadout was scored as if it had
// no sail. 36 IS the bare-bed number. With a camera (above) it reads ~68.
// Measured, camera the only variable: hailShadowOver(bed) 0.000 → 1.000.
// Nobody was lying; this harness was flying a yard with no cloth in it.
//
// UNRESOLVED: the corners. This suite says 2 lost; Lane A's end-to-end run
// says 1 at tension 1.0 (at the shop's default 0.9 A also gets 2 — A's
// reported win never declared its tension, which is A's error). Ruled out
// so far, each measured, each ~0.02 kN or less: wind shelters, frozen vs
// live tree sway, the scripted repair. Still unchecked: 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 rather than failed because the wild night's winnability is now a
// one-variable question, not a broken gate — and skipped rather than
// silently returned, because Suite.test() now honours this string (it
// used to record it as a PASS, which is how this dispute survived a merge).
return `SKIPPED — gate 0 half-open: hp ${line.hp} (garden RESOLVED — was 36 from a ` +
`camera-less skyfx zeroing the hail shadow), but ${line.lost}/4 lost vs Lane A's 1 at ` +
`tension 1.0. Next suspects: commit() vs rigSail(), and debris in rig.step. See THREADS.`;
}
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
});

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind, forecastLines } from '../weather.js';
import { loadStorm, createWind } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -344,76 +344,6 @@ export default async function run(t) {
sky.dispose();
});
// The trap that cost gate 0 two sprints, asserted so it cannot come back.
// skyfx.step() used to open `if (!camera) return`, so a headless harness
// skipped the shadow rebuild and scored every rig as if the sail weren't
// there. A camera is a view, not a weather system: the grids are physics and
// must build without one. If someone re-adds an early return, this goes red.
t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => {
const bed = { x: 0, z: 0, w: 4, d: 3 };
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
const run = (withCamera) => {
const scene = new THREE.Scene();
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx(withCamera
? { scene, camera: new THREE.PerspectiveCamera(), wind }
: { scene, wind }); // headless: no camera at all
fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
const out = {
hailShadow: sky.hailShadowOver(bed),
rainShadow: sky.rainShadowOver(bed),
hailExp: sky.gardenHailExposure(bed, 56),
hail: sky.hailAmount,
};
sky.dispose();
return out;
};
const headless = run(false);
const viewed = run(true);
assert(headless.hailShadow > 0.9,
`no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`);
assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked');
assert(headless.hailExp < 0.1,
`no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`);
// and a camera must not CHANGE the physics, only add the view
assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9,
`the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`);
assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9,
`the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
});
// The card's copy, not just the model — this is what a player actually reads.
t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
const def = storms.storm_02_wildnight;
const tonight = forecastLines(def, 0);
assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
assert(tonight.night === true, 'the wild night should read as a NIGHT');
assert(!//.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);
const far = forecastLines(def, 1);
assert(//.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');
// no NaN/undefined can reach the card, for any storm at any lead
for (const [name, d] of Object.entries(storms)) {
for (const lead of [0, 0.5, 1]) {
const f = forecastLines(d, lead);
for (const k of ['name', 'wind', 'rain', 'confidence']) {
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
}
}
}
// a hail-free storm must not advertise hail
assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
'the gentle storm advertised hail it does not have');
});
t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');

View File

@ -144,42 +144,6 @@ 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.

View File

@ -17,58 +17,6 @@ import {
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);
/**
* The forecast card's two stat lines, already worded — DESIGN.md's partial
* information made visible. Lane A owns the card; this owns the numbers, so the
* card stays one call instead of re-deriving the storm inline.
*
* `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly
* as the card always has), 1 = the far end of the week (wide bands, low
* confidence). The bands ALWAYS contain the truth a forecast may be vague but
* it must never rule out what actually happens, or a player who rigs for the top
* of the stated range gets ambushed.
*
* Numbers are MEASURED (stormStats), not estimated: the card's old inline
* `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really
* gusts to 32.3, because gust power is drawn per gust and rides a ramp.
*
* @param {object} def parsed storm JSON
* @param {number} [lead] 0..1
* @returns {{name, night, wind, rain, confidence, hail, truth}}
*/
export function forecastLines(def, lead = 0) {
const f = forecastFor(def, lead);
const i = (v) => v.toFixed(0);
const sustained = band(f.sustained, i);
const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i);
const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i);
// rain reads as a word, and a vague forecast hedges across two
const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light');
const rainWord = word(f.rain.lo) === word(f.rain.hi)
? word(f.rain.hi) : `${word(f.rain.lo)}${word(f.rain.hi)}`;
const change = f.changeAt
? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}`
: '· no change forecast';
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
return {
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
hail: f.hail,
truth: f.truth,
};
}
// 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/.
// import.meta.url is right under both, and under whatever Lane A does next.

Binary file not shown.

Binary file not shown.