Compare commits

..

No commits in common. "a0cf23dec8b8fefbeb89b169f6f9693db7020ce9" and "7dd77775da61ba436d3f850ce2925df29a6015a2" have entirely different histories.

29 changed files with 38 additions and 2130 deletions

View File

@ -330,55 +330,3 @@ Read THREADS' last [I] entry then SPRINT5.md.
> (instanced-friendly, stone mesh only if C asks), plant-shred particle puff
> for hail hits on the bed, and the carried contact-sheet refresh (night +
> daylight) for DESIGN.md.
---
---
# SPRINT 6 prompts (winnable, then a week of nights)
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 5
merged; decision 13 wired with integration-guess weights; the balance gap is
measured in THREADS' last [I] entry). Gate 1 is JOINT and comes first.
## Lane A — Sprint 6
> You are Lane A on SHADES 3D, Sprint 6. Rebase onto main, read THREADS' last
> [I] (the balance numbers) and SPRINT6.md. Gate 1 first, jointly with B+C:
> your levers are the drain weights in main.js, possibly ONE new close anchor
> for a bed-covering small quad (re-run your own smallest-full-coverage assert
> after), and verdict truthfulness (a 4/4 clean hold must never read "you
> skimped"). Then gate 2, the week: five escalating nights, money persists,
> broke = game over, survive = win screen, "play again" becomes "next night".
> Also: lift E's screenshot POST into server.py (delete tools/yardshot/),
> decide the grass atlas (take the recipe or delete it), and land the pond
> ticker + hail banner if Sprint 5 didn't. Shepherd as always.
## Lane B — Sprint 6
> You are Lane B on SHADES 3D, Sprint 6. Rebase onto main, read THREADS' last
> [I] and SPRINT6.md. You hold the pen on balance.test.js: for each storm, an
> enumerable $80 REAL-shop loadout on an in-band quad must end hp≥50 with <2
> corners lost — and storm_02's line must genuinely need the repair or the
> broom (assert it fails without them). Then fabric choice in prep: porous
> shade cloth vs waterproof membrane (porosity exists in the sim) — agree the
> hail-vs-porosity rule with C in THREADS before coding, price the tradeoff.
> And post D the typical storm_02 belly-pond mass for their douse tuning.
## Lane C — Sprint 6
> You are Lane C on SHADES 3D, Sprint 6. Rebase onto main, read THREADS' last
> [I] and SPRINT6.md. Gate 1 partner: downdraft (0.40 also passes both physics
> gates per your own sweep) and hail-burst data are your levers. Then author
> the week's two storm variants (same JSON language, one new trick each) and
> forecast uncertainty: the card shows seeded ± ranges that resolve as the
> night approaches — DESIGN.md's partial-information canon, cheaply.
## Lane D — Sprint 6
> You are Lane D on SHADES 3D, Sprint 6. Rebase onto main, read SPRINT6.md.
> Calibrate the douse comedy to B's real pond masses (thresholds vs ~450 kg
> belly ponds), then play the balanced storms end to end as gate 1 lands and
> file feel notes in THREADS before it ships — you are the playtest lane, and
> after gate 2 you play the whole week.
## Lane E — Sprint 6
> You are Lane E on SHADES 3D, Sprint 6. Rebase onto main, read SPRINT6.md.
> Night dressing for the week's later storms (moon, lit house window),
> win-screen and game-over cards that feel like SHADES, and refresh the
> contact sheets once the balance pass lands.

View File

@ -1,80 +0,0 @@
# SPRINT 6 — WINNABLE, THEN A WEEK OF NIGHTS (instructions for Opus 4.8 lanes)
*Sprint 5 verdict: every system is real — ponding kills flat sails and the broom
saves them (B and D's halves met on merge without a single coordination bug),
hail is steep and honest and proven 4.4× rig-responsive, the router tripwire
caught its first real omission. But the integrator measured the merged game and
the wild night has NO winnable line: survivable rigs don't cover the bed,
bed-covering rigs don't survive, and the $80 shop can't buy both. The systems
sprint is over. Sprint 6 makes the game FAIR, then makes it a CAMPAIGN.*
Read THREADS from the last [I] entry — the balance numbers are there.
## Gate 1 — THE BALANCE PASS (joint A+B+C, do it FIRST, everything else waits)
Target, stated as play: **storm_01 is a warm-up anyone wins · storm_03 is
winnable at $80 with decent rigging · storm_02 is winnable with excellent
rigging + one mid-storm repair + the broom.** Written as asserts (a new
`js/tests/balance.test.js`, owned jointly, B holds the pen): for each storm,
at least one enumerable $80 shop loadout on an in-band quad ends with
hp ≥ 50 and < 2 corners lost, driven through the REAL shop economy and for
storm_02 that loadout must genuinely need the repair or the broom (assert it
fails without them).
Levers, in the order I'd reach for them (agree the final mix in THREADS):
- hail/rain drain weights in main.js (integration guesses: 5.0 / 0.25);
- bed-coverage geometry: A may add ONE close anchor (e.g. a low hook on the
shed roof) so a small bed-covering quad exists — re-run A's own
smallest-full-coverage assert after, the >45 m² tension must survive;
- downdraftOfTotal 0.45 → C measured 0.40 also passes both physics gates;
- win bar (hp ≥ 50) and hardware prices last — they move everything.
Also: **verdict truthfulness** (A) — a 4/4 no-break run must never read "the
rain found what you skimped on". Verdict picks from actual failure modes:
corners lost / garden hailed / ponded and dumped / clean hold.
## Gate 2 — THE WEEK (A owns, the campaign seed)
Five nights, escalating: 01, 03, 03-variant, 02, 02-variant (C authors the two
variants — same JSON language, one new trick each: an earlier change, a longer
hail burst). Money persists: aftermath pay = base per storm severity + garden
bonus + intact-hardware refund collateral; bank carries to the next night's
shop. Broke before night five = game over (verdict card); survive the week =
the win screen SHADES has never had. Keep it one file if you can — it's a
phase-machine wrapper, not a new system. "Play again" becomes "next night".
## Lane assignments
**A** — gate 1 levers + verdicts; gate 2 (the week); lift E's screenshot POST
into server.py (delete tools/yardshot/); take E's grass recipe or delete the
atlas (your call, stop carrying it); pond ticker + hail banner if they didn't
land in Sprint 5's HUD.
**B** — balance.test.js pen-holder; fabric choice in prep (porosity exists in
the sim — expose shade cloth vs waterproof membrane as a per-sail choice with
DESIGN.md's tradeoff: porous sheds wind but lets hail through harder? No —
keep it honest: porous halves wind load AND ponds nothing, membrane blocks
hail fully; price the difference. Coordinate the hail-porosity question with
C before coding); tell D the typical storm_02 belly-pond mass so the douse
comedy lands.
**C** — gate 1 partner (downdraft/hail data); the two storm variants for the
week; forecast UNCERTAINTY (DESIGN.md canon: early forecasts are partial —
card shows ranges that resolve as the night approaches; cheap: render bands
from a seeded ± on the real numbers).
**D** — douse calibration to B's real masses (splash/stagger/knockdown
thresholds vs ~450 kg belly ponds); feel pass on the balanced storms
(you're the player); surface anything gate 1 got wrong before it ships.
**E** — night-variant dressing (moon, house window light for the night
storms); win-screen and game-over art cards (text-on-canvas is fine, make
them feel like SHADES); refresh contact sheets after the balance pass.
## Gates
```
gate 1: balance.test.js green — every storm has a real winnable line, and
storm_02's needs the repair or the broom; verdicts tell the truth
gate 2: the week runs: five nights, money carries, broke = game over,
survive = win screen — played end to end, screenshotted
gate 3: John plays the week and his three sentences go in THREADS
```
Definition of done = gate 2 (gate 3 is John's, not yours). After this SHADES
is a GAME — Sprint 7 starts DESIGN.md's real campaign: sites, clients,
job types, the landscaper service.

View File

@ -825,53 +825,6 @@ 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-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
bed-covering rig lost 23 corners and ended ~36%. Hail damage on the bed falls **51 → 34 HP** vs an
uncovered rig, so decision 13 is visibly earning its place. Selftest **244/0/0**, both yard tensions
still green. **I have NOT touched the drain weights (5.0/0.25) or asked C to move downdraft — the
anchor alone may be the whole balance fix.** Please re-measure before spending the other levers;
stacking them blind is how we end up unable to attribute anything.
[A] 2026-07-17 — 📐 **The p4 placement is swept, not chosen — and the sweep is the interesting part.**
Why the wild night was unwinnable was geometric, not physical: **every anchor near the bed
(p1/p2/p3) is SOUTH of it**, and nothing stands north short of the house 10 m away. Covering rigs had
to span the yard and died; rigs small enough to survive sat *beside* the bed rather than over it.
p4 supplies the missing north-west corner. Smallest quad covering 90% of the bed, by p4 position:
```
(-2.2, -1.2) → 44.4 m² ← DO NOT: collapses the tradeoff
(-3.2, -1.2) → 48.6 m² ← landed here (rake puts the top anchor at -3.72,-1.40 → 51.6 m²)
(-3.2, -2.0) → 51.7 m²
(-4.2, -3.0) → 60.0 m²
beyond z=-4 → 63.5 m², i.e. no effect at all
```
Full coverage costs **51.6 m² now vs 63.5 before** — 19% cheaper, still bigger than the 2338 m²
rigs that survive unaided. **If anyone is tempted to pull p4 closer to the bed: don't.** At
(-2.2,-1.2) full coverage lands at 44.4 m², *inside* the survivable band, and covering the bed stops
costing risk — which is DESIGN.md's whole central tension. a.test.js's >45 m² floor goes red if you
do, and that assert is the tension's only guard. My first three candidate placements (shed roof,
house-low, bed-north) ALL failed — I nearly reported "one anchor cannot work" before sweeping the
frontier properly. It was placement, not count.
[A] 2026-07-17 — ✅ **Verdicts now read the actual failure mode** (gate 1's other half). The integrator's
exact case — B's twisted quad, 4/4 held, garden 36 — now reads *"THE GARDEN IS GONE — and every
corner held. The hail fell where your sail wasn't."* instead of accusing them of skimping. The
garden keeps its damage split by cause, because the caller was pre-summing hail+rain and that sum is
exactly what the verdict needs and can never recover afterwards. `verdictFor()` is pure, exported and
asserted directly: cascade (**naming the weakest link as going first**, not whichever was listed
first) · single corner · uncovered · rain · broomed ("you put 380 kg of water on your own head to do
it") · ponded · clean. Aftermath also gained decision 13's headline: "51 HP to hail, 13 to rain".
**Lane D** — the mode string is on `scoreRun().verdictMode` if your feel pass wants it.
[A] 2026-07-17 — 🙏 **Lane B — thank you for `session.reset()`**, it's in and my five-line reach into your
state machine is gone. Also: **`rigging.commit()` is the ONLY path that attaches a sail.** I burned a
measurement forgetting that — `setPhase('storm')` after setting session picks runs the storm with NO
SAIL, and every loadout then scores an identical bare-bed 36%, which looks exactly like "decision 13
doesn't work". Anyone hand-driving a balance run: go through `commit()` and await it, it's async.
That's my sixth harness bug of the project and the sixth time the lanes' code was right and my test
was wrong — worth the reminder that a cross-lane failure is usually the harness.
[A] 2026-07-17 — 🧰 **SPRINT 5 START — router tripwire landed, and it is aimed straight at THIS sprint's
headline system.** The Sprint 4 integration note (router silently swallowing `rainMmPerHour`/
`rainDepthMm`, ponding would have passed every assert and done nothing in the yard) was **my file's
@ -1590,71 +1543,6 @@ Format: `[lane letter] YYYY-MM-DD — note`
wind → the garden score becomes rig-responsive without faking physics. Rain demotes to a small drain
(and ponding load); drainage stays future content. SPRINT5 wires it.
[E] 2026-07-17 — 📊 **A finding worth more than this sprint's assets: only ONE of my five textures is
consumed.** I grepped `web/world/js/` for every texture I've shipped:
· `sail_weave`**live**, and Lane B took the recipe verbatim, down to keeping my comment.
· `pond_water` / `pond_normal` — 0 refs (fair, B's ponding is this sprint).
· `sail_tears` — 0 refs (fair, M3 isn't scoped).
· **`grass_atlas` — 0 refs, four sprints.** PLAN3D §5-E-9 asked for it, SPRINT3 §A-6 and SPRINT4
listed it, and nothing has ever loaded it. It's 28 KB of dead weight in the repo.
The one texture that got used is the one I wrote an exact copy-paste recipe for. That's not a
coincidence and it's the reason both of this sprint's textures ship with one below. **Lane A: either
take the grass recipe or tell me to delete the atlas — I'd rather bin it than keep shipping it.**
[E] 2026-07-17 — **LANE C — hail juice (SPRINT5 §E-1), shaped to fit YOUR pattern, not mine.** Your rain is
a `BoxGeometry` + flat `MeshBasicMaterial` and skyfx loads no external texture at all, so I've given
you both options and you should ignore whichever is wrong:
· `hail_stone_01_v1.glb` — 22 mm, 20 tris, lumpy (a sphere at that size reads as a bubble). Drops
into `new THREE.InstancedMesh(stoneGeo, stoneMat, n)` exactly like your streaks. If you'd rather
stones stay a box, bin it, no feelings.
· `models/textures/hail_pips.png` — 256², **2×2 atlas, cells: 0 sharp pip, 1 spiked burst, 2 splash
ring (the ground decal), 3 soft/dying**. A flat quad can't be round and an impact is round, which
is the only reason this is a texture. Cell → UV, and note row 0 is the BOTTOM so this matches
three's v-up directly:
const A = await new THREE.TextureLoader().loadAsync('/world/models/textures/hail_pips.png');
A.colorSpace = THREE.SRGBColorSpace;
const cellUV = (i) => [(i % 2) * 0.5, Math.floor(i / 2) * 0.5]; // [u0, v0], each 0.5 wide
// per instance: offset the quad's uv by cellUV(age < .05 ? 0 : age < .12 ? 1 : 3)
// ground hits: cell 2, flat on the grass, scale up as it ages
const mat = new THREE.MeshBasicMaterial({ map: A, transparent: true,
depthWrite: false, fog: false }); // same flags as your rain
Ice is near-white with a cold rim so it reads on both the sand cloth and dark wet grass. Sizes:
hail_pips 39 KB, stone 3 KB.
[E] 2026-07-17 — **LANE A/C — plant shred (§E-2):** `models/textures/plant_shred.png`, 256², 2×2, four torn
blade-scraps with a darker midrib, same cell→UV as above. Elongated on purpose — my first pass was
radial and read as green potatoes; it's the long axis plus the rib that says "leaf". Fire a dozen on a
hail burst over the bed, random spin, ~0.6 s, gravity + a little wind drift, and pair it with the
existing `plants_full``plants_tattered``plants_dead` swap so the puff explains the state change
instead of the bed just quietly becoming worse.
[E] 2026-07-17 — 🔧 **THE YARD PICTURES EXIST. `docs/yard_day.jpg` + `docs/yard_night.jpg`** (carried since
Sprint 2; I stopped waiting). I did **not** touch server.py or main.js — instead
`tools/yardshot/shot_server.py` is a Lane E tool: it serves the repo like server.py and additionally
takes `POST /shot?name=<n>`, writing the body to `docs/<n>.png|jpg`. The browser posts a Blob, the
bytes go straight to disk, and no base64 crosses a text channel.
Three gotchas worth knowing, because they cost me the afternoon:
· `canvas.toBlob` is **async** — the WebGL buffer is cleared by the time it encodes, so it hands
back null. `toDataURL` is sync and reads the frame you just drew. Render in the SAME tick.
· a **backgrounded tab lays the canvas out at 0×0**, and `toDataURL` then returns the string
`"data:,"`. Force `renderer.setSize(w, h, false)` before capturing.
· rAF is paused there too, so drive `SHADES.step()` yourself — which the harness already supports.
**Lane A: `do_POST` is ~25 lines and it's yours for the taking** — lift it into server.py, delete
`tools/yardshot/`, and anyone can screenshot the game forever. I'm not going to keep asking; the tool
works standalone in the meantime.
[E] 2026-07-17 — the pictures are the yard UNRIGGED (no sail): `game.setPhase('storm')` fast-forwards time
and the sky beautifully — C's night pass is genuinely atmospheric, 27 m/s of driving rain over a dark
yard — but it doesn't rig a sail, and I wasn't going to drive B's rigging session from the console to
fake one. **When gate 3 lands, ping me and I'll reshoot both with a rigged hypar in the frame** — that's
the picture DESIGN.md actually wants, and it's now a one-minute job rather than a four-sprint one.
(Also: `SHADES.wind` is a stale snapshot — it still pointed at the calm wind while the game was
genuinely running the wild night. Cost me a wrong conclusion for a minute. Worth a getter, A.)
[E] 2026-07-17 — 👀 the tree branch stubs still read as coat hooks, and now there's a picture of it —
`docs/yard_day.jpg`, left-hand gum. Standing offer from Sprint 4: I can taper and re-angle the limbs
while pinning the `branch_anchor_*` tips so **not one anchor moves** and none of B's §7 numbers shift.
It's contained and it's the most visible art problem in the hero shot. Say the word.
[C] 2026-07-17 — **HAIL LANDED — decision 13's engine. Selftest 216/0/0.** Storm hail carries the garden
score, and it works because hail falls STEEP where rain doesn't: a raindrop's terminal velocity is
~9 m/s (a 30 m/s gale blows it in at ~73° off vertical, which is why it walks under a sail and a
@ -1698,244 +1586,3 @@ Format: `[lane letter] YYYY-MM-DD — note`
point is cheap). Plant-shred puff wants a trigger: fire it when `gardenHailExposure(bed,t)` crosses a
threshold — that's the frame the bed actually takes a hit. Tell me the hook shape and I'll match it;
`sky.hailAmount` and `hailSize` are live for scaling pip rate/size.
[B] 2026-07-18 — 💧 **PONDING v1 LANDED — the water arc is closed.** sail.js holds rainwater and it kills
flat sails. 37/37 sail + 14/14 rigging + **219/0/0 on the merged browser selftest**, `checkContract`
conforms. Verified in the assembled game, not just node: a 123 m² flat quad ponds **780 kg** with the
belly sagged below ground, then dumps the instant a corner blows.
· **Model is FLOW, not a drain coefficient.** Rain lands on each node's horizontal projection, runs
down its steepest of **8** neighbours, pools where it can't get out. The 8-way graph is
load-bearing: a 4-way one can't follow a hypar's diagonal saddle ridge, so it trapped water in the
gravity belly and a hypar pooled as much as a flat sail. On real yard quads: flat 12 kg/m² vs
twisted 1.7. **Ponding cannot pincer §7** — a hypar has nowhere to pool, by construction.
· **Rain uses C's real-units API** (`rainMmPerHour` × the exported `RAIN_TIME_COMPRESSION`, never a
hardcoded 40). C — this is exactly what I asked for last sprint and it dropped straight in. Thank
you.
[B] 2026-07-18 — 🔌 **LANE D — your broom API is frozen in contracts.js. Let's confirm the shape.**
`pondCentroid() -> {x,y,z,mass,node} | null` tells you where to walk and which node to poke.
`drainPondAt(node, dt, radius=2)` — call it every frame of your ~1.5 s hold; it drains a taper around
the node and **RETURNS the kg shed this call**. Sum that over the hold and THAT is what lands on the
player's head — you decide what the number does (I'd say: >~150 kg in one poke = a stagger via your
knockdown machinery, which is the comedy). It emits `pondDump` on the events bus too. Measured: a
1.5 s poke on a loaded belly sheds ~290 of ~310 kg. Shout if you'd rather it drained slower/faster or
you want the radius exposed differently — this is the "meet in the middle" you flagged, and it's
easier to move now than after you've built against it.
[B] 2026-07-18 — 🎯 **LANE D — the tn-1.04 cliff: found it, and it's NOT a cloth instability. Good catch,
wrong diagnosis (mine too, at first).** Investigating your report is literally what surfaced the
ponding load regime. The 10 kN "spike" is REAL PHYSICS: a 155 m² flat sail holding 2100 kg of ponded
water genuinely pulls ~21 kN on a corner — it stays finite, and the load tracks the water kilo-for-kilo
(measured the trace: 15→21 kN as the pond went 1900→2140 kg, belly sagging to 5 m). It is not the
solver diverging; it's an absurd rig doing an absurd-but-correct thing. Your own read — "cloth going
unstable" — is what I chased for an afternoon, including a per-node displacement clamp that I
**reverted** because it moved the thesis 39→34%: clamping real motion to fix a real load is the wrong
trade. What landed instead:
· a **belly-tear** at 4 m of sag — the sail physically fails and dumps (DESIGN.md "sudden dump…
tear"), which bounds the runaway without touching the solver;
· an **opt-in `rig.watchDivergence`** tripwire that throws with corner/load/tension/time above 80 kN
(true blow-up territory, ~4x any real load) — it's ON in every selftest rig and never false-trips,
so if a GENUINE instability ever appears it appears with a repro instead of a mystery.
Net for your §7 tuning: nothing changed for good rigs. Your mixed rig still survives, your cascade
still cascades. The only rigs that reach 20 kN are oversized flat ones that were already losing to
wind — the decision-2 problem, not a cloth bug. **The guard-assert instinct you had (a wind rebalance
shouldn't silently kill a mechanic) is exactly right — `watchDivergence` is the same idea for loads.**
[B] 2026-07-18 — ⚠️ **LANE A — pond HUD API + a bug I only caught by running YOUR game.** `rig.pondMass()`
is your ticker number ("SAIL PONDING — get the broom" at threshold; I'd fire it around 200 kg — a
123 m² sail hits 780 kg before it dumps, and a right-sized one tops out ~450 kg). `pondCentroid()`
gives you a world point to hang a warning marker on. **The bug:** pondMass/centroid/drain/dump all
threw when `this.water` didn't exist — i.e. before the sail is rigged, which is every frame of
forecast+prep. My node tests always attach first so they never saw it; your HUD reads pondMass() on
frame 1 and it crashed. Guarded now (return 0 / null / no-op pre-attach) with an assert so it can't
come back — but it's a clean example of why the by-hand play matters: nothing in 37 green asserts
caught it.
Also: `session.reset()` landed for your "play again" — restores budget/picks/tension/spares and is
rig-able again, asserted.
[B] 2026-07-18 — 🔭 **DESIGN FINDING for whoever tunes storms — flat rigs die to WIND before water on this
yard.** Every flat quad big enough to pond (88-155 m²) is also big enough that storm_02's wind breaks
it first — measured, on rated shackles, breaks land at t=6-15 s. So in the *actual game* today, ponding
is a real load but rarely the PROXIMATE cause of death on a flat rig; the oversize wind cascade beats
it to it. Ponding's clean kill (water alone, wind can't) only shows on a right-sized ~25 m² level
"carport", which the current 8-anchor yard can't quite build (the level anchors — h1/h2/h3 — are
collinear). Two ways to read this: (a) fine, ponding is the *twist-tax* — it punishes you for going
flat-and-big to chase coverage, stacking with the wind penalty; or (b) if you want ponding to be a
STANDALONE threat (the "poke it with a broom" beat as its own mini-crisis), the yard needs a level
anchor trio that isn't collinear, or a storm with rain but gentle wind (a "steady soaker", DESIGN.md's
slow-pressure level type). Not my call — flagging with numbers. The broom mechanic works regardless;
this is about whether a player ever NEEDS it.
[D] 2026-07-17 — 🤝 **LANE B — the broom↔pond seam, posted EARLY as §Lane B-2 asks. This is what I need,
and it's three members.** I'm building against exactly this shape right now, duck-typed, so land it
whenever and my side lights up with no further coordination:
· `sailRig.ponds -> [{ node, mass, pos }]` — every pool worth pointing a broom at. `node` is your
grid index (whatever `drainPondAt` wants back), `mass` in kg, `pos` a LIVE world Vector3 of the
pooled node. **`pos` is the one I really need**: the player has to physically walk under the
belly, so I need to know where the belly IS. Empty array when nothing's pooled.
· `sailRig.drainPondAt(node) -> number`**please return the kg actually dumped.** That number is
the whole joke: it's what decides whether the water is a splash, a stagger, or puts the player
on their back. If it returns void I have to read `pondMass()` before and after and diff it,
which works but is silly.
· `sailRig.pondMass() -> number` — you already have this for A's HUD; I use it for "is there any
point picking the broom up".
Your spec says drain takes ~1.5 s — mine is a 1.5 s hold-E, so if you'd rather drain gradually over
the hold than dump on completion, say so and I'll drive it per-frame instead. **Dump-on-completion is
my default** because the comedy needs a single moment, not a trickle.
Not blocked on you: the broom carries, walks and pokes today against a local stub, and self-skips
when `drainPondAt` is absent (same pattern as decision 4 — my call sites landed first and you
conformed, which worked well for both of us).
[D] 2026-07-17 — 🧹 **BROOM LANDED (§Lane D-1) — the loop runs in the real game against a stub pond.**
`web/world/js/broom.js` (Lane D). Driven by hand: take broom off the shed wall → walk under the
belly → prompt reads **"push the water off (78 kg)"** → hold-E poke → `pondMass=0`, and **78 kg lands
on the player → stagger.** Self-wires from createPlayer; reads the live rig off `interact.sailRig`
(see the wireYardActions note — published there so it survives rigSail's attach() swap). Selftest
**217/0/0** (was 207); 13 of the new asserts are the broom's + the greyed prompt's.
Everything deferred to E's baked metadata — `carry_type`, `poke_tip.use` (bristle end, "won't hole
the cloth"), `anim_hint` (reuse Crank). Nothing invented. E, the asset did the design work; thank you.
[D] 2026-07-17 — 👋 **LANE B — my side of the seam is LIVE and waiting, exactly as I posted it.** The
broom already calls `sailRig.ponds` (reads `{node, mass, pos}`) and `sailRig.drainPondAt(node)`
(uses the returned kg to size the douse). Confirmed end-to-end in the real game against a stub in
your shape — so **when you land ponding, the broom lights up with zero further coordination.** One
calibration note for your tuning pass: my douse thresholds are `splash<15kg`, `stagger≥60`,
`knockdown≥120` — pure physical guesses (a full bucket ≈10 kg). Once your pond masses are real,
tell me a typical storm_02 belly-pond mass and I'll tune the comedy to it. Right now a 78 kg pond
staggers; I don't yet know if 78 kg is a normal night or a biblical one.
[D] 2026-07-17 — ✅ **GREYED PROMPTS (§Lane D-2) — the vanishing-prompt confusion is dead. LANE A, your
HUD hook is ready.** `interact.step()` now returns `{target, label, progress, holding, usable}`. When
`usable:false` the prompt is a REASON, not an offer — grey it, no radial. The label is already the
sentence: "hands full", "out of reach — needs the ladder", "the fascia needs the ladder — it's by
the shed", "you need the broom". `interact.visible(player)` is the standalone version if you'd rather
call it directly. Verified in the real game: walk to the shed table holding the ladder and it now says
**"hands full" greyed** instead of showing nothing, while `nearest()` (what hold-E fires on) still
correctly refuses it — display and action stay separate. Asserted three ways.
[D] 2026-07-17 — 💬 **FEEL PASS — I played a round (the §Lane D-3 hail+ponding pass can't fully run yet;
hailApi and pondApi are both still false on main, so this is the loop that exists + a stub pond):**
1. **The face is real and it's good.** Forecast → prep table (11 anchors, budget, tension, anchor
rings) → 90 s wild night → a proper aftermath card (garden %, corners, hardware, collateral,
verdict, PLAY AGAIN). It plays like a game now, not a tech demo.
2. **The feel pass immediately re-confirmed decision 13's necessity, live.** A flawless rig — 4/4,
"Every corner held", nothing lost — scored **garden 50%.** Half the garden gone with a perfect
run, because rain walks under the cloth and nothing rig-responsive is landed yet. This is EXACTLY
A's 54-vs-48 finding, and it reads as broken from the player's chair: "I did everything right and
the card still says I half-failed." Hail (C) + the score-truth wiring (A) are the fix and they're
in flight; flagging that **until they land, a perfect round feels like a loss**, which is the one
thing that most needs to be true-or-false by gate 3.
3. **The broom is funny even solo.** 78 kg on the head → stagger → the player reels back from under
the sail. It'll be funnier when it's a pond you watched grow and dreaded, not one I injected —
but the beat already lands. Reserve judgement on the douse SIZES until B's masses are real (see
my note to B); the *shape* (walk under, poke, wear it) is right.
4. **Greyed prompts fixed the thing that read as broken last sprint.** Carrying the ladder past the
shed table now says "hands full" instead of going silent. Small change, and the game stopped
feeling buggy in exactly the spot it did.
[D] 2026-07-17 — 🧵 **wireYardActions now publishes `interact.sailRig`** (the live rig, refreshed on every
rigSail/attach) so Lane-D systems that need the rig but are built by createPlayer — the broom needs
`ponds`/`drainPondAt` — can read it without main.js threading it through. Same pattern and same reason
as `interact.ladder`. B/C/E: if you ever need the current rig from an interact-wired system, it's there.
[I] 2026-07-18 — **SPRINT 5 INTEGRATION (main).** Lanes b/c/d/e merged; selftest **240/0/0**. Wired
decision 13 into A's one-term seam (hail×5.0 + rain×0.25 feeding garden.step; weights are integration
guesses, tune freely). B's ponding + D's broom, built independently against the agreed shape, lit up
together on merge exactly as designed. C's router edit was correct (A's tripwire caught it, C followed
the standing instruction). E's texture-consumption audit stands: A takes the grass recipe in SPRINT6
or the atlas gets deleted; ditto lifting the screenshot POST into server.py.
**BALANCE, measured live in the merged game (storm_02, real $80-shop loadouts):** bare bed 36% ·
bed-covering rigs (cover .29-.67) all lost 2-3 corners and ended 36% or dead · B's 23 m² twisted quad
with the exact-$80 loadout held 4/4 but covers 0% of the bed → 39%. Win bar is hp≥50 && lost<2:
**the wild night currently has no winnable line through the real shop.** Nothing is broken — C
flagged exactly this joint call when holding 0.45 — but it is now measured and it is SPRINT6 gate 1.
Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run — verdicts must
read the actual failure mode.
[C] 2026-07-18 — 🎯 **GATE 1 — A, YOU'RE RIGHT, AND I'M DECLINING MY OWN LEVER. Don't spend the
downdraft.** You asked us to re-measure before stacking levers; I'd already swept mine independently
and it says what your p4 sweep says. Measured clean on a 43 m² bed-covering quad through storm_02 with
UNBREAKABLE hardware (nothing breaks → pure aerodynamics, no cascade noise):
```
lever peak corner load cut
baseline (dd 0.45) 10325 N —
downdraft 0.40 9824 N 4.9% ← my lever
downdraft 0.30 9003 N 12.8%
porosity 0.5 (B's fabric) 8741 N 15%
tension 0.7 10400 N ~0%
```
**My downdraft is the weakest lever on the board** — 0.45→0.40 buys 5% of load, and even 0.30 only
reaches 13%. It cannot close a gap where the smallest bed-covering quad needed ~$120 of rated hardware
against an $80 shop. Spending it would cost real physics fidelity (the no-free-lunch ratio drops
69%→63%, and decision 11 was settled at 0.45) to move balance almost not at all. **storm_02 stays at
downdraftOfTotal 0.45.** If gate 1 ever needs one more notch after everything else, 0.40 is proven safe
on both physics gates and I'll spend it then — but not blind, and not first.
Corroborating your diagnosis from the other side: I enumerated all **207** bed-covering quads in the
pre-p4 yard and the smallest was **54 m²**; it wins on all-rated ($120) and loses 3 corners on the
exact-$80 loadout. Area was always the lever — first break moved 14.5 s → 35.1 s as I shrank a
synthetic bed-covering quad 63 → 31 m². That's your p4 sweep, found independently. Nice call.
[C] 2026-07-18 — 🅱️ **B — the hail-porosity ruling you were told to get from me before coding fabric.
Short answer: porosity does NOT block less hail, and the honest tradeoff has to come from somewhere
else.** Knitted shade cloth apertures are ~13 mm; hailstones are 645 mm. A stone cannot pass a mesh
an order of magnitude finer than it is — it gets stopped and the cloth wears the impact. So physically:
**porosity is about AIR (blows through → less load) and WATER (drains → no ponding). It is not about
ice.** My hail shadow already models that correctly by construction: it projects `rig.pos`/`rig.tris`
geometry and never reads porosity, so porous and membrane block hail identically today. No code change
needed for the honest answer.
⚠️ **Which leaves you a real design problem, and it's yours not mine:** if porous halves wind load AND
ponds nothing AND blocks hail, it's strictly dominant and the choice is dead. SPRINT6's sketch
("membrane blocks hail fully") would fix that by making porous leak ice — the one thing the physics
won't support. Three honest places to put the cost instead, in the order I'd try:
1. **Price.** Membrane cheap and dangerous, shade cloth dear and safe. "You get what you pay for" is
a real tradeoff and needs no physics lie.
2. **Shade.** Porous is ~70% shade by construction, membrane 100%. Worth nothing tonight, worth
everything the moment Sprint 7 scores a heatwave — which DESIGN.md already promises.
3. **Stone size, if you want a hail difference that's true.** The smallest ice DOES pass a coarse
mesh. I own `hail.size` (storm_01 none · storm_03 0.7 pea · storm_02 1.3 · storm_02b 1.4), so
"porous passes hail below ~0.8, blocks the rest" is defensible and data-driven, and it makes
porous lose exactly on the mild-hail nights while staying honest on the ice nights. Say the word
and I'll land it as `hailBlockFor(size, porosity)` in weather.core — your mechanic, your call, I'll
match whatever shape you want.
FYI for your balance pen: porosity 0.5 is the **strongest single lever I measured** (15% peak load, 3×
my downdraft). It may be doing gate-1 work you don't intend it to.
[C] 2026-07-18 — 🗓️ **THE WEEK'S TWO VARIANTS ARE ON `lane/c`. A — yours to sequence.** Each has ONE new
trick and neither is "the same storm but harder":
```
night storm gustPk sust hailSec change@ the one new trick
1 storm_01_gentle 11.3 6.5 0.0 — —
2 storm_03_southerly 21.4 13.0 2.4 30 the swing
3 storm_03b_earlybuster 21.2 13.0 2.4 18 same force, HALF the warning
4 storm_02_wildnight 32.3 20.0 11.4 55 the wild night
5 storm_02b_icenight 28.3 19.0 21.1 53 LESS wind, 1.6× the hail
```
Force ramps then deliberately **plateaus** (32.3 → 28.3) while the question changes underneath it.
Night 3 tests a habit, not a budget: you rig for a hot NW'er and the southerly lands a third of the way
in. Night 5 tests what hail was built to test — is the bed actually covered, and does the cover HOLD —
so a rig that survives night 4 by hiding in a small quad off the bed loses the week on it.
⚠️ **Ice Night is tuned, and I want the tuning on record because I got it wrong first.** The draft ran a
26 s hail hold = 32 hail-seconds = 2.8× storm_02, and measured **~39 HP below storm_02 under your exact
gate-1 winning line**. That's not a harder night, it's a wall — nobody recovers 39 HP while already
flying the best rig the shop sells. Now 1.6×, **13 HP below storm_02**. That gap is deliberate: night 5
arrives with four nights of banked money, so **your economy is what should answer it** — if the bank
can't, tell me and I'll drop the hold again. **B: please put both variants in balance.test.js's storm
list** — "every storm has a winnable line" has to mean all five nights, and my harness can't reproduce
your `rigging.commit()` numbers exactly (I read ~11 HP high and one corner worse than A's on the same
rig), so the canonical answer needs to come from your pen, not mine.
[C] 2026-07-18 — 🔮 **FORECAST UNCERTAINTY landed — A, it's a pure function on a def, so it costs you no
router and no wiring.** `forecastFor(def, lead)` + `stormStats(def)` in weather.core (lead 0 =
tonight/exact, 1 = far end of the week/vague). What your card can render for the wild night as the week
closes in:
```
4 nights out conf 0% gusts 93174 km/h change 3863 s hail possible
2 nights out conf 40% gusts 102151 km/h change 4560 s hail possible
tomorrow conf 75% gusts 110131 km/h change 5157 s hail likely
tonight conf 100% gusts 116 km/h change 55 s hail likely
```
**The invariant, asserted across every storm at five leads: the band ALWAYS contains the truth.** That's
the line between partial information and a lie — a player who rigs for the top of the stated range must
never be ambushed. Deterministic per storm, so re-reading the card can't reroll it.
Two notes. `stormStats` MEASURES rather than estimates: your card computes the gust peak as `baseCurve
peak + powBase + powRamp` = 30 m/s for storm_02, and it actually gusts to **32.3**, because gust power
is drawn per gust and rides a ramp. `stormStats(def).gustPeak` is the real number, cached per def. And
`truth` is on the forecast object, so an "actual vs forecast" reveal on the aftermath card is free.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

View File

@ -118,7 +118,6 @@ PAL = {
"gnome_coat": "#3E6FA8",
"gnome_hat": "#B33C36",
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -1189,30 +1188,6 @@ def build_garden_gnome_01_broken(name):
return root
def build_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
Lane C this is offered, not imposed: your rain is a BoxGeometry with a flat
material and no texture, and this drops into that exact pattern
(`new THREE.InstancedMesh(stoneGeo, stoneMat, n)`). If you'd rather stones be
a box like the streaks, ignore this and the pip atlas still stands on its own.
Lumpy on purpose a sphere at this size reads as a bubble, and real stones
are accreted knobbles. 80 tris.
"""
rng = rng_for(name)
root = add_empty(name)
ice = get_material("Mat_Ice", PAL["hail_ice"], 0.25)
stone = add_ico(f"{name}_stone", 0.011, (0, 0, 0.011), ice, subdiv=1,
scale=(1.0, rng.uniform(0.82, 0.95), rng.uniform(0.78, 0.92)),
jitter=0.0018, rng=rng)
join_group([stone], "stone", root)
stamp(root, name, "weather")
root["diameter_m"] = 0.022
root["mass_hint"] = 0.006
return root
def build_broom_01(name):
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
@ -1540,88 +1515,6 @@ def build_pond_textures():
return [p1, p2]
def build_hail_and_shred_atlases():
"""Hail impact pips + plant shred fragments (SPRINT5 §Lane E-1/2).
Both are 2x2 atlases of alpha sprites for InstancedMesh billboards, which is
the shape Lane C's rain already has — instanced quads, DynamicDrawUsage,
depthWrite off. The one thing a flat-coloured quad cannot do is be round, and
an impact is round, which is the whole reason these are textures at all.
Cells (hail_pips): 0 sharp pip, 1 spiked burst, 2 splash ring (the ground
decal), 3 soft fading pip. Pick per age so one impact can play 0 -> 1 -> 3
and a ground hit can just use 2.
"""
import numpy as np
SIZE, CELL = 256, 128
pips = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
th = np.arctan2(ny, nx)
if idx == 0: # sharp pip: hot core, fast falloff
a = np.clip(1.0 - r, 0, 1) ** 3.2
elif idx == 1: # burst: core plus radiating spikes
spikes = 0.5 + 0.5 * np.cos(th * 8.0)
a = np.clip(1.0 - r, 0, 1) ** 2.6 + 0.5 * spikes * np.clip(1.0 - r, 0, 1) ** 5.0
elif idx == 2: # splash ring — the ground decal
a = np.exp(-((r - 0.62) ** 2) / 0.012) * np.clip(1.0 - r, 0, 1) ** 0.4
else: # soft, dying
a = np.exp(-(r ** 2) / 0.20) * 0.75
a = np.clip(a, 0, 1)
# Hail is ice: near-white with a cold rim, so it reads against both the
# sand-coloured cloth and dark wet grass.
pips[cy:cy + CELL, cx:cx + CELL, 0] = 0.88 + 0.12 * a
pips[cy:cy + CELL, cx:cx + CELL, 1] = 0.94 + 0.06 * a
pips[cy:cy + CELL, cx:cx + CELL, 2] = 1.0
pips[cy:cy + CELL, cx:cx + CELL, 3] = a
p1, kb1 = save_png(pips, "hail_pips")
print(f" hail_pips.png {SIZE}x{SIZE}, 4 cells (pip/burst/ring/soft), {kb1} KB")
# --- plant shred ------------------------------------------------------
# Torn leaf fragments, not dots: the bed is being shredded, and a green dot
# reads as a bug. Each cell is one ragged blade-scrap with a darker midrib.
shred = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
r_ = rng_for(f"plant_shred_{idx}")
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
th = np.arctan2(ny, nx)
r = np.sqrt(nx * nx + ny * ny)
# A torn blade-scrap: genuinely elongated, then ripped along the edge.
# A radial lobe alone gives a teardrop, which at particle size reads as a
# green potato — it's the long axis plus the midrib that says "leaf".
# Aspect varies per cell so one burst isn't four copies of a shape.
aspect = r_.uniform(0.38, 0.62)
ex, ey = nx * aspect, ny / aspect
er = np.sqrt(ex * ex + ey * ey)
tear = np.zeros_like(th)
for k in range(1, 5):
tear += (0.06 / k) * np.sin(th * (2 * k + 1) + r_.uniform(0, math.tau))
inside = er < (0.42 + tear)
g = r_.uniform(0.42, 0.62)
rib = np.abs(ny) < 0.035 # the midrib, darker
col = np.where(rib, 0.65, 1.0)
shred[cy:cy + CELL, cx:cx + CELL, 0] = np.where(inside, g * 0.55 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 1] = np.where(inside, g * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 2] = np.where(inside, g * 0.34 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 3] = np.where(inside, 1.0, 0.0)
p2, kb2 = save_png(shred, "plant_shred")
print(f" plant_shred.png {SIZE}x{SIZE}, 4 leaf scraps, {kb2} KB")
return [p1, p2]
def build_grass_atlas():
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
python) and saved through bpy's image API. Lane A instances quads with this."""
@ -1745,9 +1638,6 @@ ASSETS = [
# whatever is behind it.
# Wider than the 0.30 head: the bristles splay past it, which is what a worn
# broom does. A real yard broom is 0.300.45 m across.
dict(name="hail_stone_01", fn=build_hail_stone_01,
dims=((0.015, 0.030), (0.012, 0.028), (0.012, 0.028)),
nodes=["stone"]),
dict(name="broom_01", fn=build_broom_01,
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
@ -2080,7 +1970,6 @@ def main():
build_grass_atlas()
build_sail_textures()
build_pond_textures()
build_hail_and_shred_atlases()
debris = [] if no_debris else copy_debris()
failures = []

View File

@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""
SHADES Lane E screenshot capture. Python stdlib only, same house rule as
server.py: no pip, no venv, no build step.
WHY THIS EXISTS
---------------
There is no way to get a game screenshot onto disk. The canvas is WebGL, so
`toDataURL` hands back a blank buffer unless you render and read in the same
tick, and even then the only channel out of the page is text moving one
900x506 JPEG as base64 costs ~60 KB of round-trip to save one picture.
DESIGN.md has wanted a shot of the assembled yard since Sprint 2 and it has been
carried four sprints waiting on a five-line fix in a file Lane E doesn't own.
So this serves the repo exactly like server.py, and additionally accepts
`POST /shot?name=<n>`, writing the raw body to docs/<n>.png. The browser posts a
Blob and the bytes go straight to disk they never touch a text channel.
python3 tools/yardshot/shot_server.py --port 8815
then, from the page:
SHADES.render(); // same tick as the read!
document.getElementById('c').toBlob(
(b) => fetch('/shot?name=yard_day', { method: 'POST', body: b }));
LANE A: this is deliberately NOT an edit to server.py that's your file, and
§6 says post the need rather than reach into it. I posted it twice; nobody had
the spare hands, which is fair. `do_POST` below is the whole fix: lift it
verbatim into server.py, delete this directory, and anyone can screenshot the
game forever after. Until then this stays a Lane E tool and touches nothing.
Dev tool, and it writes files, so: binds loopback only, the name is restricted
to a safe charset (no traversal), the body is size-capped, and it only ever
writes .png into docs/.
"""
from __future__ import annotations
import argparse
import http.server
import re
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "docs"
SAFE_NAME = re.compile(r"^[a-z0-9_\-]{1,64}$")
MAX_BYTES = 20 * 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def do_POST(self): # noqa: N802 (stdlib naming)
url = urlparse(self.path)
if url.path != "/shot":
self.send_error(404, "only POST /shot")
return
name = (parse_qs(url.query).get("name") or ["shot"])[0]
if not SAFE_NAME.match(name):
self.send_error(400, "name must match [a-z0-9_-]{1,64}")
return
length = int(self.headers.get("Content-Length") or 0)
if not 0 < length <= MAX_BYTES:
self.send_error(413, "empty or too large")
return
data = self.rfile.read(length)
# A blank WebGL read is the failure mode this tool exists to dodge, so
# refuse to write one rather than quietly commit an empty picture.
if data.startswith(b"\x89PNG"):
ext = "png"
elif data.startswith(b"\xff\xd8\xff"):
ext = "jpg"
else:
self.send_error(415, "body is neither PNG nor JPEG")
return
OUT_DIR.mkdir(exist_ok=True)
out = OUT_DIR / f"{name}.{ext}"
out.write_bytes(data)
print(f" shot -> {out.relative_to(ROOT)} ({len(data) // 1024} KB)")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
pass # the shot line above is the only output worth having
def main():
ap = argparse.ArgumentParser(description="Serve the repo + accept POST /shot")
ap.add_argument("--port", type=int, default=8815)
args = ap.parse_args()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"serving {ROOT} on http://127.0.0.1:{args.port}/")
print(f" game http://127.0.0.1:{args.port}/web/world/index.html")
print(f" shots -> {OUT_DIR}")
srv.serve_forever()
if __name__ == "__main__":
main()

View File

@ -1,52 +0,0 @@
{
"name": "Ice Night",
"blurb": "Same front as the wild night, but it stalls overhead and the hail just keeps coming. Cover the bed or lose it.",
"rating": 5,
"seed": 20818,
"duration": 90,
"_variant_comment": "Week night 5 — the storm_02 variant, and the week's last word. ONE new trick: the hail doesn't stop. The wild night's burst is 9 s long; this one holds for 26 s straight across the change (t=48-74) at full intensity, plus ice on every big gust. Wind is deliberately NOT raised — it sits a touch UNDER storm_02 (sustained 19 vs 20) so this is not 'the wild night but harder'. It asks the one question the wild night only asks for 9 seconds: is the bed actually covered, and does the cover hold? A rig that survives storm_02 by hiding in a small quad off the bed loses the garden here. Hail is the score (decision 13), so this is the storm that scores rigging hardest.",
"baseCurve": [[0, 7.0], [15, 11.0], [40, 16.5], [60, 19.0], [78, 18.0], [90, 15.0]],
"gusts": {
"firstAt": 3,
"minGap": 5.5,
"maxGap": 11,
"powBase": 3,
"powRand": 5,
"powRamp": 6.5,
"downdraftOfTotal": 0.45
},
"dirCurve": [[0, 0.85], [48, 0.95], [53, 0.6], [57, -1.25], [70, -1.45], [90, -1.35]],
"dirWander": { "amp": 0.25, "rate": 0.13 },
"spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 36, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" },
{ "t": 50, "type": "lightning", "power": 0.7 },
{ "t": 53, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" },
{ "t": 62, "type": "lightning", "power": 1.0 },
{ "t": 68, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" },
{ "t": 80, "type": "lightning", "power": 0.5 }
],
"_rain_comment": "Same 80 mm/hr severe scale as storm_02 and a near-identical curve — the water story (ponding, the broom) is unchanged on purpose. The variant's whole difference is the ice.",
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
"_hail_comment": "The trick, and it is TUNED not maxed. storm_02 hails ~11.4 seconds all night; this holds 12 s at full intensity straight through the change plus ice on every gust ≥9 (a lower bar than storm_02's 10, so more carry it), landing ~1.6x storm_02's hail-seconds with size 1.4 stones. The first draft ran a 26 s hold for 32 hail-seconds — 2.8x — and measured ~39 HP below storm_02 under the SAME rig, which is not a harder night, it's a wall: no amount of play recovers 39 HP when you're already flying the best rig in the shop. 1.6x is a real step up that excellent rigging + the repair + the broom can still answer. Winnability across all five nights is balance.test.js's job (B holds the pen) — these variants need to be in its storm list.",
"hail": {
"size": 1.4,
"withGustsAbove": 9,
"gustBurstIntensity": 0.9,
"bursts": [
{ "t": 50, "ramp": 2, "hold": 12, "fade": 3, "intensity": 1.0 }
]
},
"sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 }
}

View File

@ -1,45 +0,0 @@
{
"name": "Early Buster",
"blurb": "The change comes through before you've finished your coffee. Same southerly, half the warning.",
"rating": 3,
"seed": 30818,
"duration": 90,
"_variant_comment": "Week night 3 — the storm_03 variant. ONE new trick: the change lands at t=18 instead of t=30, before the wind has built enough to teach you what it's going to do. You rig for a hot NW'er and get a southerly a third of the way in, so the corners you left slack are loaded while you're still watching the sky. Everything else is deliberately storm_03's: same peak (~21 m/s gust, 13 sustained), same rain scale, same mild hail. The lesson is TIMING, not force — night 3 of five should test a habit, not a hardware budget.",
"baseCurve": [[0, 5.0], [10, 8.0], [22, 11.0], [45, 13.0], [65, 12.5], [90, 10.0]],
"gusts": {
"firstAt": 4,
"minGap": 6,
"maxGap": 12,
"powBase": 3,
"powRand": 4,
"powRamp": 4,
"downdraftOfTotal": 0.35
},
"_dir_comment": "Blowing toward the SE (a warm NW'er) for the first 16 s only, then the slew: 16-24 s swings ~90 deg to blow toward the NNE (a southerly off the open side). storm_03 gives you 30 s to read it; this gives you 16.",
"dirCurve": [[0, 0.8], [16, 0.85], [18, 0.5], [24, -0.7], [50, -0.85], [90, -0.75]],
"dirWander": { "amp": 0.3, "rate": 0.11 },
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 18, "type": "windchange", "telegraph": 5, "over": 6, "text": "that's the change already" },
{ "t": 40, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" },
{ "t": 58, "type": "lightning", "power": 0.4 }
],
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [16, 0.05], [22, 0.4], [50, 0.55], [80, 0.3], [90, 0.15]] },
"hail": {
"size": 0.7,
"bursts": [
{ "t": 24, "ramp": 1.5, "hold": 3, "fade": 2, "intensity": 0.5 }
]
},
"sky": { "darkness": 0.55, "cloudScroll": 0.06, "night": true }
}

View File

@ -1,188 +0,0 @@
/**
* broom.js DESIGN.md's funniest correct mechanic. (Lane D, SPRINT5 §Lane D-1)
*
* A flat sail pools water. The water is heavier than anything else in the game and it will pull the
* rig down. The fix is a bloke with a broom walking under the belly and poking it upward at which
* point forty kilos of cold water arrives on his head. That is both the correct engineering answer
* and the joke, and they are the same thing, which is the best kind of mechanic.
*
* Everything here defers to the asset. E baked the intent into broom_01_v1.glb:
* grip_anchor extras.carry_type = "broom"
* poke_tip extras.use = "push the pond up from under the sail; soft end, won't hole the cloth"
* on the BRISTLE end, deliberately: a broomstick jabbed at a loaded sail holes it.
* root extras.anim_hint = "reuse Crank/Dig for the poke — no new Mixamo needed"
* so the carry type, the working end and the animation are all read, not invented.
*
* Seam with Lane B (posted in THREADS before either of us built): sailRig.ponds -> [{node,mass,pos}]
* and drainPondAt(node) -> kg dumped. Duck-typed: with no ponding landed the broom still carries,
* walks and refuses to poke thin air, and the moment B lands it the whole thing lights up.
*/
// No top-level THREE/GLTFLoader import, deliberately: the vendored addons import the bare specifier
// 'three', which only resolves under index.html's importmap — so importing them here would drag the
// whole GL chain into d.test.js and cost this lane its headless suite (node resolves relative paths
// only). The pond logic below is the part worth asserting and it is pure; the view is loaded
// dynamically, which never happens outside a browser.
export const BROOM_URL = './models/broom_01_v1.glb';
export const BROOM_TUNE = {
pokeSecs: 1.5, // matches B's "drain over ~1.5 s"
reachUp: 3.2, // m — how high overhead a pond can be and still be pokeable from the grass
standRadius: 2.0, // m — how near the pond's ground shadow you must be
// What lands on you. Calibrate once B's masses are real — these are physical guesses, not measured:
// a full bucket is ~10 kg, so a splash is nothing, half a bathtub staggers you, and a bathtub
// puts you down. Flagged in THREADS for the tuning pass.
splashKg: 15, // below this it's just cold and funny
staggerKg: 60, // above this you lose your footing
// above staggerKg*2 → flat on your back
};
/**
* @param {THREE.Object3D} scene
* @param {object} world contracts World (dressed)
* @param {object} interact Lane D's Interact
* @param {object} player PlayerSim
* @param {object} getRig () => sailRig a getter, because rigSail() REPLACES the rig object
*/
export function createBroom(scene, world, interact, player, getRig) {
const state = { carried: false, view: null, tune: { ...BROOM_TUNE } };
// Home: against the shed wall. E ships it standing on its head, which is how it lives there.
const home = { x: 8.2, y: 0, z: 7.0 };
if (world.shedTable && world.shedTable.pos) {
home.x = world.shedTable.pos.x - 0.8;
home.z = world.shedTable.pos.z + 1.0;
}
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
if (scene) {
import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {
new GLTFLoader().load(BROOM_URL, (g) => {
g.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
state.view = g.scene;
scene.add(g.scene);
sync();
}, undefined, () => { /* no asset: the mechanic still runs, you just can't see the broom */ });
}).catch(() => { /* headless (selftest/node): logic only, no view */ });
}
function sync() {
if (!state.view) return;
state.view.visible = !state.carried;
state.view.position.set(home.x, home.y, home.z);
state.view.rotation.set(0, 0.9, 0.16); // slouched against the shed wall
}
/** Every pond Lane B is reporting, or [] until they land it. */
const ponds = () => {
const rig = getRig && getRig();
return (rig && Array.isArray(rig.ponds)) ? rig.ponds : [];
};
/**
* The pond this player could actually poke: near enough in plan, low enough overhead.
* Picks the HEAVIEST reachable one rather than the nearest if you're standing under two, the
* one about to break the rig is the one you meant.
*/
function targetPond() {
let best = null;
for (const p of ponds()) {
if (!p || !p.pos || !(p.mass > 0)) continue;
const d = Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z);
const up = p.pos.y - (player.pos.y + player.climbY);
if (d > state.tune.standRadius || up > state.tune.reachUp || up < 0) continue;
if (!best || p.mass > best.mass) best = p;
}
return best;
}
/** Where to stand: the pond's shadow on the grass. */
const pokeSpot = () => {
const p = ponds().reduce((a, b) => (!a || (b && b.mass > a.mass) ? b : a), null);
if (!p || !p.pos || !(p.mass > 0)) return null;
return { x: p.pos.x, y: 0, z: p.pos.z };
};
const wired = [];
// 1. take the broom off the shed wall — a third carry type, so it queues behind the same hands
wired.push(interact.register({
id: 'broom_take',
pos: () => (state.carried ? null : home),
radius: 1.5,
holdSecs: 0.7,
clip: 'PickUp',
label: (p) => (p.carrying ? 'hands full' : 'take the broom'),
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
onDone: (p, t) => { state.carried = true; p.pickUp('broom', t); sync(); },
}));
// 2. put it back
wired.push(interact.register({
id: 'broom_drop',
pos: () => (state.carried ? { x: home.x, y: home.y, z: home.z } : null),
radius: 1.5,
holdSecs: 0.4,
clip: 'PickUp',
label: 'put the broom back',
canUse: (p) => state.carried && p.carrying === 'broom',
onDone: (p, t) => { state.carried = false; p.drop(t); sync(); },
}));
// 3. THE POKE. Stand under the belly, push up, wear it.
wired.push(interact.register({
id: 'broom_poke',
pos: pokeSpot,
radius: state.tune.standRadius,
holdSecs: state.tune.pokeSecs,
clip: 'Crank', // E's anim_hint — no new Mixamo needed
label: (p) => {
if (p.carrying !== 'broom') return 'you need the broom';
const pond = targetPond();
if (!pond) return 'nothing pooling here';
return `push the water off (${Math.round(pond.mass)} kg)`;
},
// physical gates only — never player.state (see interact.register's note; it cancels its own hold)
canUse: (p) => {
const rig = getRig && getRig();
return p.carrying === 'broom' && !!(rig && rig.drainPondAt) && !!targetPond();
},
onDone: (p, t) => {
const rig = getRig && getRig();
const pond = targetPond();
if (!rig || !pond) return;
// B returns the kg actually dumped; fall back to diffing pondMass() if they'd rather not
let kg = rig.drainPondAt(pond.node);
if (typeof kg !== 'number') kg = pond.mass;
onWater(p, kg, pond, t);
},
}));
/**
* The payoff. All of it lands on the player, because they are standing directly underneath it
* that is not a bug in the plan, it IS the plan.
*/
function onWater(p, kg, pond, t) {
p.events.push({ type: 'doused', kg, t });
const T = state.tune;
if (kg >= T.staggerKg * 2) {
// downward and behind: a bathtub arriving on your head does not blow you downwind
p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing));
} else if (kg >= T.staggerKg) {
p.staggerHit(t);
}
// below splashKg: you just get wet, which is its own reward
}
return {
get carried() { return state.carried; },
get home() { return home; },
tune: state.tune,
ponds,
targetPond,
pokeSpot,
onWater,
update() { sync(); },
dispose() { wired.forEach((un) => un()); if (state.view) scene.remove(state.view); },
};
}

View File

@ -191,17 +191,6 @@ export class Emitter {
* LIVE world position of corner i, as a fresh vector safe to keep. A blown
* corner's node is flying, so an interaction prompt anchored to this chases
* the flogging corner instead of sitting on the dead anchor. null if unrigged.
* @property {() => number} pondMass
* Kilograms of rainwater pooled on the sail (SPRINT5 ponding). Lane A's HUD
* warning threshold and "SAIL PONDING — get the broom" ticker read this.
* @property {() => ({x:number,y:number,z:number,mass:number,node:number}|null)} pondCentroid
* Where the pond sits in world space, its mass, and the heaviest grid node
* or null if there's nothing worth pointing at. Lane D walks the player to
* this; Lane E draws the water here.
* @property {(node:number, dt:number, radius?:number) => number} drainPondAt
* Lane D's broom: poke node `node` (from pondCentroid().node) for one frame of
* the ~1.5 s hold; drains a radius around it and RETURNS the kg shed this call.
* Sum over the hold = what lands on the player's head. Emits 'pondDump'.
*/
/**
@ -317,7 +306,7 @@ export class Emitter {
export const CONTRACT = {
wind: { sample: 'function', gustTelegraph: 'function' },
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function', pondMass: 'function', pondCentroid: 'function', drainPondAt: 'function' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' },
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
interact: { register: 'function' },
camera: { object: 'object', yaw: 'number', update: 'function' },

View File

@ -62,7 +62,7 @@ export class Interact {
return !target.canUse || !!target.canUse(player);
}
/** Nearest registered target in range whose canUse() passes. This is what a hold-E acts on. */
/** Nearest registered target in range whose canUse() passes. */
nearest(player) {
let best = null, bestD = Infinity;
for (const target of this.targets.values()) {
@ -74,31 +74,6 @@ export class Interact {
return best;
}
/**
* What the HUD should SHOW, which is not the same question as what E acts on.
*
* A usable action always wins. But when nothing is usable, this returns the nearest action that
* is merely unavailable, so the prompt can say WHY instead of vanishing. That distinction came
* out of playing it: walking to the shed table with the ladder in your hands made the prompt
* disappear, which reads as a broken game rather than a full pair of hands and every target
* already had a perfectly good sentence sitting in its `label`, unreachable, because canUse had
* filtered it out before the label was ever asked.
*
* @returns {{target, usable, label}|null}
*/
visible(player) {
const usable = this.nearest(player);
if (usable) return { target: usable, usable: true, label: this.labelOf(usable, player) };
let best = null, bestD = Infinity;
for (const target of this.targets.values()) {
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
if (!p) continue;
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
if (d <= target.radius && d < bestD) { best = target; bestD = d; }
}
return best ? { target: best, usable: false, label: this.labelOf(best, player) } : null;
}
cancel(t, player) {
if (!this.active) return;
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
@ -113,10 +88,7 @@ export class Interact {
* @param {number} dt @param {number} t
* @param {PlayerSim} player
* @param {boolean} holding is E held this frame
* @returns {{target, progress, label, holding, usable}} for hud.js to draw the prompt + radial.
* `usable:false` means the prompt is a REASON, not an offer grey it out and don't show a
* radial. Lane A: this is the greyed-prompt surface I offered; `label` is already the sentence
* ("hands full", "out of reach — needs the ladder", "you need the broom").
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
*/
step(dt, t, player, holding) {
// One press, one action: a completed hold latches until E is released. Without this, a held key
@ -154,22 +126,12 @@ export class Interact {
}
}
if (this.active) {
return {
target: this.active,
progress: this.progress,
label: this.labelOf(this.active, player),
holding: true,
usable: true,
};
}
const shown = this.visible(player);
const shown = this.active || near;
return {
target: shown ? shown.target : null,
progress: 0,
label: shown ? shown.label : '',
holding: false,
usable: !!(shown && shown.usable),
target: shown,
progress: this.progress,
label: shown ? this.labelOf(shown, player) : '',
holding: !!this.active,
};
}
}
@ -196,11 +158,6 @@ export function wireYardActions(interact, deps = {}) {
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
const ladder = deps.ladder || interact.ladder || null;
// Publish the CURRENT rig for lane-D systems built before it exists (the broom needs ponds +
// drainPondAt). main.js re-calls wireYardActions through rigSail() every time attach() replaces
// the rig object, so reading `interact.sailRig` is always the live one — which is the same reason
// the corner closures below read by index rather than capturing.
interact.sailRig = sailRig || null;
const wired = [];
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
const anchorOf = (i) => {

View File

@ -141,13 +141,7 @@ export function createLadder(scene, world, interact, player) {
radius: PLACE_RANGE,
holdSecs: 1.0,
clip: 'PickUp',
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
// single most useful sentence in the game.
label: (p) => (p.carrying === 'ladder'
? `set the ladder under ${a.id}`
: 'the fascia needs the ladder — it\'s by the shed'),
label: `set the ladder under ${a.id}`,
canUse: (p) => p.carrying === 'ladder',
onDone: (p, t) => {
state.carried = false;

View File

@ -54,129 +54,35 @@ const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
*/
const GARDEN_DRAIN = 0.9;
/**
* Decision 13's weights. Hail is what actually kills a garden and cloth honestly
* stops it (stones fall 20° off vertical; rain leans 73° in a gale and walks
* straight under the sail Lane C's numbers). Rain is demoted to a drizzle of
* damage so the night still costs you something when it isn't hailing.
*
* SPRINT6 gate 1 lists these first among the balance levers.
*/
const HAIL_WEIGHT = 5.0;
const RAIN_WEIGHT = 0.25;
/**
* The garden: the thing you are actually protecting, and the only score that
* matters. Deliberately not inside hud.js the HUD reads, it doesn't decide.
*
* It keeps its damage split by CAUSE, and that isn't bookkeeping for its own
* sake: the aftermath verdict is the game's entire feedback channel, and a
* verdict that guesses teaches the wrong lesson. A garden that only knows it
* went from 100 to 39 cannot tell a player whether their hardware let go or
* their perfectly-held sail simply wasn't over the bed and those are opposite
* mistakes with opposite fixes.
*/
function createGarden(world) {
let hp = 100;
let state = 'full';
let byHail = 0;
let byRain = 0;
return {
get hp() { return hp; },
get state() { return state; },
/** HP lost to each cause this run. The verdict reads these. */
get damage() { return { hail: byHail, rain: byRain }; },
reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); },
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
/**
* Decision 13, and the two terms stay SEPARATE on the way in rather than
* being pre-summed by the caller that sum is exactly the information the
* verdict needs and can never recover afterwards.
*
* @param {number} dt
* @param {number} hail 0..1 sky.gardenHailExposure(bed, t)
* @param {number} rain 0..1 sky.gardenExposure(bed, t)
* @param {number} exposure 0..1 how hard the bed is being hit right now.
* 0 = nothing reaching it (no weather, or cloth is over it); 1 = taking it
* full in the open. Lane C's sky.gardenExposure() computes it.
*
* SPRINT5 decision 13 lands here and nowhere else: garden damage becomes
* hail exposure + a small rain drain, so this stays one number and the
* only change is which helper(s) feed it.
*/
step(dt, hail, rain) {
const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt;
const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt;
const total = Math.min(hp, dHail + dRain);
if (total > 0) {
// Attribute proportionally, so the split still adds up on the last tick
// when the garden bottoms out at 0 mid-step.
const scale = total / (dHail + dRain);
byHail += dHail * scale;
byRain += dRain * scale;
hp -= total;
}
step(dt, exposure) {
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt);
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
if (next !== state) { state = next; world.setPlants(next); }
},
};
}
/**
* Why the night went the way it did read from what ACTUALLY happened.
*
* This is the whole of the game's feedback channel, and it was lying: any run
* ending under 50 read "the rain found what you skimped on", including a run
* that held 4/4 corners and skimped on nothing. That is not a typo-grade bug.
* DESIGN.md wants every disaster to replay in the player's head as "the
* shackle, I knew about the shackle" a verdict that blames the wrong thing
* teaches the opposite of the lesson the storm just spent 90 seconds giving.
*
* So: pick from the failure modes the run can prove, in the order the player
* most needs to hear them. The two garden losses are deliberately different
* sentences, because they are opposite mistakes hardware you under-bought
* versus a rig that held perfectly and simply wasn't over the bed.
*
* @returns {{verdict: string, mode: string}}
*/
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) {
const worst = lost.length
? lost.reduce((a, c) => (a && a.hw.rating <= c.hw.rating ? a : c))
: null;
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
const hailKilled = dmg.hail > dmg.rain;
if (lost.length >= 2) {
return { mode: 'cascade',
verdict: `THE SAIL LOST. The ${named} went first — and took ${lost.length - 1} more with it.` };
}
if (lost.length === 1 && !win) {
return { mode: 'corner',
verdict: `THE SAIL LOST. One corner short: the ${named} let go.` };
}
if (!win && hailKilled) {
// The lesson the old verdict destroyed. Every corner held; the rig was
// simply not over the thing it was paid to protect.
return { mode: 'uncovered',
verdict: 'THE GARDEN IS GONE — and every corner held. The hail fell where your sail wasn\'t.' };
}
if (!win) {
return { mode: 'rain',
verdict: 'THE GARDEN IS GONE. Not dramatic — just a long night of rain on open ground.' };
}
if (pondDumped > 50) {
return { mode: 'broomed',
verdict: `THE GARDEN MADE IT. You put ${Math.round(pondDumped)} kg of water on your own head to do it.` };
}
if (pondPeak > 80) {
return { mode: 'ponded',
verdict: 'THE GARDEN MADE IT — with a belly full of water. That flat spot will find you.' };
}
if (lost.length === 1) {
return { mode: 'held-one-down',
verdict: `THE GARDEN MADE IT. The ${named} let go and the rest carried it.` };
}
if (hp >= 85) {
return { mode: 'clean', verdict: 'THE GARDEN MADE IT — not a leaf out of place.' };
}
return { mode: 'mostly', verdict: 'THE GARDEN MADE IT. Mostly.' };
}
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
@ -529,9 +435,6 @@ export async function boot(opts = {}) {
const s = rigging.summary;
const hp = garden.hp;
const win = hp >= 50 && lost.length < 2;
const dmg = garden.damage;
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped });
return {
hp,
cornersLost: lost.length,
@ -543,33 +446,13 @@ export async function boot(opts = {}) {
subtitle: lost.length
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: 'Every corner held.',
/** Decision 13's headline: how much of the bed's damage the cloth stopped. */
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
: 'Nothing reached the bed.',
pondPeak,
pondDumped,
verdict,
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
verdictMode: mode,
verdict: hp >= 85 && !lost.length ? 'THE GARDEN MADE IT — not a leaf out of place.'
: win ? 'THE GARDEN MADE IT. Mostly.'
: hp < 50 ? 'THE GARDEN IS GONE. The rain found what you skimped on.'
: 'THE SAIL LOST. Warranty callout in the rain for you.',
};
}
// --- the night's evidence ------------------------------------------------
// What the sail carried and what the player took on the head, so the verdict
// can point at things that actually happened rather than infer from the score.
let pondPeak = 0;
let pondDumped = 0;
// Subscribed once: rigSail() calls attach() on the same rig object, so the
// Emitter survives a re-rig. Lane B tags a dump with `reason` when the water
// left because something failed (corner break, belly tear) and leaves it off
// when the player poked it out with the broom — which is the difference
// between "the sail lost its water" and "you wore it".
rig.events.on('pondDump', (e) => {
if (!e.reason) pondDumped += e.kg;
});
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
// Prep and forecast happen on the calm day; the storm you picked only
@ -579,8 +462,6 @@ export async function boot(opts = {}) {
events.length = 0;
rigging.setActive(to === 'prep');
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'forecast') {
garden.reset();
resetRig();
@ -665,24 +546,7 @@ export async function boot(opts = {}) {
// extends exactly this shape (+ gardenHailExposure) — so when hail lands
// this is one added term here, not a rewrite.
if (game.phase === 'storm') {
// Decision 13 (SPRINT5): hail is the headline garden threat — it falls
// steep, so the sail blocks it and the score finally rewards rigging
// (C proved 4.4× separation). Rain stays as the small honest drain that
// walks under a sail in a gale. Weights chosen so storm_02 unprotected
// loses ~50 HP to its hail bursts (11.4 hail-seconds × 5.0 × 0.9/s) and
// ~10 to rain, while a bed-covering rig cuts the hail term ~4.4×.
const rainExp = sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0;
const hailExp = sky?.gardenHailExposure ? sky.gardenHailExposure(world.gardenBed, windT) : 0;
// Passed separately, not pre-summed: the split is what makes the verdict
// able to tell "your hardware went" from "your sail wasn't over the bed".
garden.step(dt, hailExp, rainExp);
// Pond evidence for the aftermath. Peak is what the sail carried at its
// worst; dumped is what the player took on the head with the broom. Both
// are things the verdict can honestly point at.
if (typeof rig.pondMass === 'function') {
pondPeak = Math.max(pondPeak, rig.pondMass());
}
garden.step(dt, sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0);
}
}

View File

@ -17,9 +17,8 @@ import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js'
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
import { createBroom } from './broom.js';
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
@ -288,11 +287,6 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
const ladder = (opts.ladder === false || !opts.interact)
? null : createLadder(scene, world, opts.interact, sim);
// The broom needs the sail rig, which createPlayer isn't handed — but wireYardActions is, and
// main.js re-calls it through rigSail() whenever attach() swaps the rig. So read it live off
// interact rather than capturing a rig that's about to be replaced.
const broom = (opts.broom === false || !opts.interact)
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
return {
get pos() { return sim.pos; },
@ -305,7 +299,6 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
sim.step(dt, t, input, opts.wind);
if (ladder) ladder.update(dt, t, input);
if (broom) broom.update(dt, t, input);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
view.sync(sim, dt);
},
@ -315,13 +308,8 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
sim,
view,
ladder,
broom,
keyboard,
dispose() {
keyboard.dispose(); view.dispose();
if (ladder) ladder.dispose();
if (broom) broom.dispose();
},
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
};
}

View File

@ -32,23 +32,11 @@ export class RiggingSession {
*/
constructor({ anchors = [], budget = START_BUDGET } = {}) {
this.anchors = anchors;
this._startBudget = budget;
this.reset();
}
/**
* Back to an empty prep phase, same anchors and starting budget. Lane A's
* "play again" reaches into the state machine to fake a fresh round rather
* than rebuilding the session, so this owns the field list add a field
* above, reset it here.
*/
reset() {
this.budget = this._startBudget;
this.budget = budget;
this.tension = DEFAULT_TENSION;
this.spares = 0;
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
this.picks = [];
return this;
}
get spent() { return START_BUDGET - this.budget; }

View File

@ -169,20 +169,6 @@ test('summary names the weak link for the HUD', () => {
return `weak link flagged: ${sum.weakest}, $${sum.budget} left`;
});
test('reset() returns a used session to a fresh prep phase', () => {
const s = session();
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
s.setHardware('h1', RATED); s.setTension(1.2); s.setSpares(1);
assert(s.budget < START_BUDGET, 'setup should have spent money');
s.reset();
assert(s.budget === START_BUDGET, `budget not restored: $${s.budget}`);
assert(s.picks.length === 0, 'picks not cleared');
assert(s.tension === 1.0 && s.spares === 0, 'tension/spares not reset');
// and it's actually usable again, not just zeroed
assert(s.rig('t1').ok && s.canStart === false, 'session not rig-able after reset');
return 'budget, picks, tension, spares all fresh; rig-able again';
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -20,7 +20,6 @@
import * as THREE from '../vendor/three.module.js';
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
import { RAIN_TIME_COMPRESSION } from './weather.core.js';
export { HARDWARE };
@ -57,45 +56,11 @@ const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
// ---------- ponding (SPRINT4 decision 10 / SPRINT5) ----------
// DESIGN.md §"Rain → ponding": "Flat sails collect water; water is heavy; the
// belly collects more (positive feedback) until sudden dump, tear, or corner
// failure." Lane C owns how hard it rains (rainMmPerHour + RAIN_TIME_COMPRESSION);
// this owns how much of it a sail holds.
//
// The model is FLOW, not a drain coefficient. Water leaves a sail because it has
// somewhere to GO, not because the fabric is tilted: each node pushes water down
// its steepest neighbour, rim nodes pour it over the edge. The neighbourhood is
// 8-way ON PURPOSE — on a hypar the water runs along the saddle's DIAGONAL ridge
// to the two low corners, and a 4-way graph can't follow that, so it traps water
// in the shallow gravity belly the cloth sags into between grid lines and a hypar
// pools as much as a flat sail (measured: it did, until diagonals). So a flat
// sail's belly is a basin water flows INTO and can't climb out of, while a hypar
// drains to its low corners and off — which is why ponding cannot pincer §7.
//
// (My reverted Sprint-3 prototype drained by slope magnitude instead and pooled
// 1 kg — it measured the coefficient I'd invented, not the sail.)
const POND_FLOW = 6.0; // 1/s per unit of downhill gradient — how fast water finds the low spot
const POND_SPILL = 3.0; // 1/s — a rim node pouring over the edge, when the edge is downhill
// Depth cap per node. Water funnels into ~10 belly nodes, not evenly, so this is
// the depth AT THE DEEPEST POINT (a torn-sail extreme), not the average — set
// too low and the whole sail saturates shallow and never reaches a kill load.
const POND_MAX_KG_M2 = 900; // ~90 cm at the single deepest node
const BROOM_DRAIN = 2.5; // 1/s at the poke — a ~1.5 s hold clears the belly
// ---------- debris (SPRINT2 decision 5) ----------
const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces
const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
// ---------- failure ----------
// Genuine solver-blowup threshold. NOT a "loads shouldn't get this high" cap:
// investigating Lane D's tn-1.04 report showed a 155 m² flat sail holding
// 2100 kg of ponded water really does put ~21 kN on a corner, and it stays
// finite and tracks the water — that's correct physics for an absurd rig, not
// divergence. Real divergence is 100 kN+ and NaN. So this sits well above any
// real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first.
const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up
const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear")
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
@ -254,26 +219,6 @@ export class SailRig {
// XPBD Lagrange multipliers, one per spring, reset every substep
this.lambda = new Float64Array(this.springs.length);
// ---- ponding state ----
this.water = new Float64Array(nodeCount); // kg on each node
this._nodeFlat = new Float64Array(nodeCount); // area-weighted |ny|, refilled by the wind pass
this._nodeArea = new Float64Array(nodeCount);
this._wFlow = new Float64Array(nodeCount); // per-step transfer buffer
// 8-neighbour graph, -1 padded (see the ponding note for why diagonals).
this._nbr = new Int32Array(nodeCount * 8).fill(-1);
this._isRim = new Uint8Array(nodeCount);
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const n = idx(u, v);
let k = 0;
for (const [du, dv] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, -1], [-1, 1], [1, 1]]) {
const uu = u + du, vv = v + dv;
this._nbr[n * 8 + k++] = (uu >= 0 && uu < N && vv >= 0 && vv < N) ? idx(uu, vv) : -1;
}
this._isRim[n] = (u === 0 || u === N - 1 || v === 0 || v === N - 1) ? 1 : 0;
}
}
// Springs meeting each corner, kept as {spring, index} so the load meter can
// look up each one's multiplier. Bend springs are included: the hardware
// physically carries every element that touches it, and leaving them out
@ -400,8 +345,6 @@ export class SailRig {
_substep(dt, wind, t, pieces) {
this._accumulateWind(wind, t, dt);
// after the wind pass — it fills the per-node flatness/area ponding reads
this._applyPonding(wind.rainMmPerHour ? wind.rainMmPerHour(t) : 0, dt);
if (pieces && pieces.length) this._applyDebris(pieces, dt);
this._integrate(dt);
this.lambda.fill(0); // XPBD multipliers are per-substep
@ -409,31 +352,6 @@ export class SailRig {
this._pinCorners(t);
this._measureLoads(dt);
this._checkFailure(dt);
if (this.watchDivergence) this._checkDivergence();
}
/**
* Optional tripwire for Lane D's tn-1.04 report. That spike does NOT reproduce
* on current main with swaying tree anchors, 3xrated+1carabiner, loads climb
* smoothly 7.2->8.7 kN across the whole tension range, no discontinuity at
* 1.04 (decision 11's downdraft bump and the §7 re-point changed the load
* regime under it). So rather than clamp real physics to fix a bug I can't
* demonstrate the displacement clamp I tried moved the thesis 39->34% this
* just WATCHES. Set `rig.watchDivergence = true` and it throws with the corner,
* load and time the instant a corner exceeds a physically-impossible load, so
* if it ever comes back it comes back with a repro instead of a mystery.
*/
_checkDivergence() {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.load > DIVERGENCE_N || !Number.isFinite(c.load)) {
throw new Error(
`sail divergence: corner ${c.anchorId} at ${(c.load / 1000).toFixed(1)} kN, ` +
`t=${this.t.toFixed(2)}s, tension=${this.tension.toFixed(3)}. This is the tn-1.04 cliff ` +
`(THREADS [D] 2026-07-17) recurring — capture this rig and ping Lane B.`,
);
}
}
}
/** Wind force per FACE — the hypar mechanic lives here. */
@ -482,166 +400,9 @@ export class SailRig {
F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz;
F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz;
F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz;
// |ny| is how horizontal the face is (1 flat, 0 on edge), which is also
// exactly the fraction of its area rain sees from straight up — so the
// ponding catch area is free here rather than a second geometry pass.
const share = area / 3, flat = Math.abs(ny) * share;
const na = this.tris[i], nb = this.tris[i + 1], nc = this.tris[i + 2];
this._nodeFlat[na] += flat; this._nodeFlat[nb] += flat; this._nodeFlat[nc] += flat;
this._nodeArea[na] += share; this._nodeArea[nb] += share; this._nodeArea[nc] += share;
}
}
/**
* Rain lands, runs downhill, and pools where it can't get out. Called after
* the wind pass (which fills _nodeFlat / _nodeArea).
* @param {number} mmPerHour wind.rainMmPerHour(t) REAL-world rate, Lane C's data
*/
_applyPonding(mmPerHour, dt) {
const pos = this.pos, F = this.force, w = this.water, flow = this._wFlow;
const N2 = w.length;
// 1 mm over 1 m² is 1 kg. Lane C owns the rate and the 40× compression
// constant; multiplying them here is the whole of "how much a sail holds".
const kgPerM2PerSec = (mmPerHour * RAIN_TIME_COMPRESSION) / 3600;
for (let n = 0; n < N2; n++) {
const a = this._nodeArea[n];
if (a > 1e-9 && kgPerM2PerSec > 0) {
w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection
const cap = POND_MAX_KG_M2 * a;
if (w[n] > cap) w[n] = cap;
}
flow[n] = 0;
}
// steepest-descent transfer: water leaves down the biggest GRADIENT, not the
// biggest drop — a diagonal is √2 farther, so the same drop across it is a
// gentler slope than straight down. This is what lets the hypar drain along
// its ridge and the flat belly trap its water.
for (let n = 0; n < N2; n++) {
if (w[n] <= 1e-9) continue;
const nx = pos[n * 3], y = pos[n * 3 + 1], nz = pos[n * 3 + 2];
let best = -1, bestGrad = 0;
for (let k = 0; k < 8; k++) {
const m = this._nbr[n * 8 + k];
if (m < 0) continue;
const drop = y - pos[m * 3 + 1];
if (drop <= 0) continue;
const dx = nx - pos[m * 3], dz = nz - pos[m * 3 + 2];
const grad = drop / (Math.hypot(dx, dz) || 1e-6);
if (grad > bestGrad) { bestGrad = grad; best = m; }
}
if (best < 0) continue; // a basin: nowhere lower to go
const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt);
flow[n] -= moved; flow[best] += moved;
}
for (let n = 0; n < N2; n++) w[n] += flow[n];
// A rim node spills over the edge ONLY when the edge is downhill — i.e. it
// has no lower interior neighbour to send water to. A hypar's low corners
// are exactly that, so it empties; a flat sail's rim is a LIP above the
// belly, water flows inward away from it, and it never spills.
for (let n = 0; n < N2; n++) {
if (!this._isRim[n] || w[n] <= 0) continue;
const y = pos[n * 3 + 1];
let lowerInside = false;
for (let k = 0; k < 8; k++) {
const m = this._nbr[n * 8 + k];
if (m >= 0 && pos[m * 3 + 1] < y - 1e-4) { lowerInside = true; break; }
}
if (!lowerInside) w[n] = Math.max(0, w[n] - w[n] * POND_SPILL * dt);
}
// the weight. Pinned corners can't move, so their water would be silently
// dropped by the integrator — leave it summed into pondMass but don't push a
// pinned node; a real corner runs its water off the hardware into the cloth,
// which the flow step above already does.
let lowestNode = 1e9, lowestCorner = 1e9;
for (let n = 0; n < N2; n++) {
if (w[n] > 0 && this.invMass[n] > 0) F[n * 3 + 1] += GRAVITY * w[n];
const y = pos[n * 3 + 1];
if (y < lowestNode) lowestNode = y;
this._nodeFlat[n] = 0;
this._nodeArea[n] = 0;
}
for (let k = 0; k < 4; k++) {
const cy = pos[this.cornerIdx[k] * 3 + 1];
if (cy < lowestCorner) lowestCorner = cy;
}
// If the belly has sagged more than POND_BELLY_MAX below the lowest corner,
// the sail has physically failed — it tears, or the pool sheets off the low
// edge. Either way the water goes. This is what bounds the ponding runaway
// (a bare flat sail otherwise bellies to 5 m and puts 20 kN on a corner),
// and it's DESIGN.md's "sudden dump" — a corner right at its limit gets the
// reprieve, or doesn't, depending on whether the pond tips first.
if (lowestCorner - lowestNode > POND_BELLY_MAX && this.pondMass() > 1) {
this.dumpPond('belly tore');
}
}
/** Total water on the sail, kg. Lane A's "SAIL PONDING — get the broom" number. */
pondMass() {
if (!this.water) return 0; // the HUD may read this before the sail is rigged
let m = 0;
for (let n = 0; n < this.water.length; n++) m += this.water[n];
return m;
}
/**
* Where the pond sits, world space, plus its mass and heaviest node. Null if
* there's nothing worth pointing at. Lane D walks to this; Lane E draws it.
* @returns {{x:number,y:number,z:number,mass:number,node:number}|null}
*/
pondCentroid() {
if (!this.water) return null;
let m = 0, x = 0, y = 0, z = 0, node = -1, hw = 0;
for (let n = 0; n < this.water.length; n++) {
const q = this.water[n];
if (q <= 0) continue;
m += q; x += this.pos[n * 3] * q; y += this.pos[n * 3 + 1] * q; z += this.pos[n * 3 + 2] * q;
if (q > hw) { hw = q; node = n; }
}
if (m < 1) return null;
return { x: x / m, y: y / m, z: z / m, mass: m, node };
}
/**
* Lane D's broom: poke the belly and the water goes somewhere else mostly
* onto whoever poked it. Call every frame of the ~1.5 s hold; drains a radius
* around `node` progressively rather than teleporting the pond away.
* @param {number} node grid node index aim at pondCentroid().node
* @returns {number} kg dumped THIS call. Sum over the hold = what lands on the
* player's head; Lane D decides what that does to them.
*/
drainPondAt(node, dt, radius = 2) {
if (!this.rigged || node == null || node < 0 || node >= this.water.length) return 0;
const N = this.N, cu = node % N, cv = (node / N) | 0;
let dumped = 0;
for (let v = Math.max(0, cv - radius); v <= Math.min(N - 1, cv + radius); v++) {
for (let u = Math.max(0, cu - radius); u <= Math.min(N - 1, cu + radius); u++) {
const n = v * N + u;
if (this.water[n] <= 0) continue;
const fall = 1 - Math.hypot(u - cu, v - cv) / (radius + 1); // full at the poke, tapering out
if (fall <= 0) continue;
const take = Math.min(this.water[n], this.water[n] * BROOM_DRAIN * fall * dt);
this.water[n] -= take;
dumped += take;
}
}
if (dumped > 0) this.events.emit('pondDump', { type: 'pondDump', kg: dumped, node, t: this.t });
return dumped;
}
/** Tip the lot off — a corner let go, or the tension changed under the belly. */
dumpPond(reason = 'dump') {
const kg = this.pondMass();
if (kg <= 0) return 0;
this.water.fill(0);
this.events.emit('pondDump', { type: 'pondDump', kg, reason, t: this.t });
return kg;
}
/**
* Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b).
*
@ -839,9 +600,6 @@ export class SailRig {
// on the wind and the flogging is emergent rather than animated.
// Without it a "blown" corner stays welded in mid-air.
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
// the belly loses its shape the instant a corner goes, so any pond goes
// with it — DESIGN.md's "sudden dump", onto whatever is below.
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
}
@ -906,13 +664,8 @@ export class SailRig {
}
setTension(tension) {
const was = this.tension;
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
if (!this.rigged) return;
this._applyRestLengths();
// Winching a ponded sail up tips the belly and the water comes off — the
// real counter-play, and why the turnbuckle is a tool and not a slider.
if (this.tension > was + 0.02) this.dumpPond('tensioned up');
if (this.rigged) this._applyRestLengths();
}
/**

View File

@ -12,7 +12,7 @@
import { SailRig } from './sail.js';
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
import { createWindField, RAIN_TIME_COMPRESSION } from './weather.core.js';
import { createWindField } from './weather.core.js';
const SIM_DT = FIXED_DT;
@ -43,9 +43,6 @@ function realWind(def = STORM_02, opts = {}) {
sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
// ponding reads this — the whole point of C exporting it in real units
rainAt: (t) => field.rainAt(t),
rainMmPerHour: (t) => field.rainMmPerHour(t),
};
}
@ -70,11 +67,9 @@ const YARD = [
*/
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
const yardRig = (ids, hw, tension) => {
const r = new SailRig({ anchors: YARD, gridN: 10 });
r.watchDivergence = true;
return r.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
};
const yardRig = (ids, hw, tension) =>
new SailRig({ anchors: YARD, gridN: 10 })
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
@ -142,31 +137,11 @@ export const makeAnchors = (heights, theta = 0) =>
});
const ALL_IDS = ['a0', 'a1', 'a2', 'a3'];
// A right-sized LEVEL sail — a 5x5 m carport roof, all corners at one height.
// This is the rig ponding is really about: small enough that the storm's wind
// alone never breaks it (the dry control proves 4/4), flat enough that rain
// pools in the belly, so water is the ONLY variable that can push it over.
// Measured: dry 4/4, wet loses a corner at t~85 s holding ~440 kg. The big
// yard quads can't play this role — they break to wind first (decision-2
// oversize), which is a true finding, logged, not a test to force.
const LEVEL_CARPORT = [3.2, 3.2, 3.2, 3.2];
const carportAnchors = (S = 2.5) =>
[[-S, -S], [S, -S], [S, S], [-S, S]].map(([x, z], i) => {
const pos = { x, y: LEVEL_CARPORT[i], z };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const carportRig = (hw) => {
const r = new SailRig({ anchors: carportAnchors(), gridN: 10 });
r.watchDivergence = true;
return r.attach(ALL_IDS, Array(4).fill(hw), 1.0);
};
const UNBREAKABLE = { name: 'test rig', cost: 0, rating: Infinity };
function rig(heights, { hw = UNBREAKABLE, tension = 1.0, porosity = 0 } = {}) {
const r = new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity });
r.watchDivergence = true; // every test run also proves the guard never false-trips
return r.attach(ALL_IDS, [hw, hw, hw, hw], tension);
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
}
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
@ -711,196 +686,6 @@ test('runs against the shared contracts.js stub wind', () => {
return `90 s on contracts.js stub wind, peak ${kN(peak)}, ${r.corners.filter((c) => c.broken).length}/4 corners lost`;
});
// --- SPRINT4 decision 10 / SPRINT5: ponding -------------------------------
const STORM_01 = await loadStormDef('storm_01_gentle');
test('ponding: a flat rig pools water and a twisted one sheds it', () => {
// Real yard quads, not the synthetic level saddle: HEIGHTS_HYPAR is a
// symmetric two-up-two-down at one footprint, which sags into a central belly
// under a night of rain and ponds like a flat sail — an artifact of the test
// rig, not the sim. The game builds rigs like these two, where the twisted
// quad's corners sit at genuinely different heights so water has a downhill
// path off one side. Measured 50x apart (1.7 vs 13 kg/m²).
const flat = yardRig(['h1', 'h3', 'p2', 'p1'], UNBREAKABLE, 1.0); // 2.6/2.6/4.0/4.0 — a roof
const twisted = yardRig(TWISTED_QUAD, UNBREAKABLE, 0.85); // §7's own survivor
runStorm(flat, realWind(), STORM_02.duration);
runStorm(twisted, realWind(), STORM_02.duration);
const fm = flat.pondMass(), tm = twisted.pondMass();
const fpm = fm / flat.area, tpm = tm / twisted.area; // per m², since areas differ
assert(fpm > 8, `flat rig only held ${fpm.toFixed(1)} kg/m² after a night of rain — it isn't ponding`);
assert(tpm < fpm * 0.25, `twisted rig held ${tpm.toFixed(1)} kg/m² vs the flat rig's ${fpm.toFixed(1)} — it should shed`);
return `flat ${fpm.toFixed(1)} kg/m² vs twisted ${tpm.toFixed(1)} kg/m² (${(tpm / fpm * 100).toFixed(0)}%)`;
});
// THE POINT OF THE WHOLE WATER ARC. Wind provably cannot punish a flat sail
// (SPRINT3 [B]: a horizontal plate catches less than any tilted one, at any
// downdraft). Water can, it's DESIGN.md's stated mechanism, and unlike a
// downdraft it cannot touch the twisted rig — asserted directly above.
// A CONTROLLED experiment isolating water as the killer. On the real yard every
// flat quad big enough to pond is also big enough for the storm's WIND to break
// first (measured — it's the decision-2 oversize problem), so "flat rig dies in
// storm_02" can't cleanly attribute the death to water there. Instead: same rig,
// same rain, but the horizontal wind is capped below the rig's breaking load.
// Then rain is the ONLY thing that can push it over — which is exactly the claim.
const cappedWetWind = (capMs) => {
const base = realWind();
const o = { x: 0, y: 0, z: 0 };
return {
sample(pos, t) {
const v = base.sample(pos, t);
const h = Math.hypot(v.x, v.z);
if (h > capMs) { const s = capMs / h; o.x = v.x * s; o.y = v.y; o.z = v.z * s; return o; }
o.x = v.x; o.y = v.y; o.z = v.z; return o;
},
rainAt: (t) => base.rainAt(t),
rainMmPerHour: (t) => base.rainMmPerHour(t),
};
};
const cappedDryWind = (capMs) => {
const wet = cappedWetWind(capMs);
return { sample: wet.sample, rainAt: () => 0, rainMmPerHour: () => 0 };
};
test('ponding: rain alone kills a flat rig the capped wind cannot', () => {
const CAP = 16; // m/s — the dry control proves this rig holds 4/4 against it
const r = carportRig(HARDWARE[1]); // shackle: holds the capped wind, not a night of water
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, cappedWetWind(CAP), STORM_02.duration);
assert(broke.length > 0, `flat rated rig survived — peak pond was only ${r.pondMass().toFixed(0)} kg, rain isn't loading it`);
return `${broke.length} corner(s) blew to water under a ${CAP} m/s wind cap, first at t=${broke[0].t.toFixed(1)}s`;
});
test('ponding: the same rig under the same capped wind survives with rain OFF', () => {
// The control that makes the test above mean "water", not "wind": identical
// rig, identical capped wind, rain turned off -> it must hold 4/4.
const CAP = 16;
const r = carportRig(HARDWARE[1]);
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, cappedDryWind(CAP), STORM_02.duration);
assert(broke.length === 0, `the rig lost ${broke.length} corner(s) to ${CAP} m/s WIND alone — raise nothing, the wet test isn't isolating water`);
assert(r.pondMass() === 0, 'no rain should mean no pond');
return `dry, ${CAP} m/s cap: 4/4 held — so the kill above is the water`;
});
test('ponding: storm_01 gentle cannot hurt anyone', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
const broke = [];
r.events.on('break', (e) => broke.push(e));
runStorm(r, realWind(STORM_01), STORM_01.duration);
assert(broke.length === 0, `a gentle day blew ${broke.length} corner(s) — storm_01 is the tutorial`);
return `4/4 held, ${r.pondMass().toFixed(0)} kg of water on the cloth`;
});
test('ponding: mass conserves until something dumps it', () => {
const r = rig(HEIGHTS_FLAT);
const w = realWind();
runStorm(r, w, 40);
const held = r.pondMass();
assert(held > 50, `only ${held.toFixed(0)} kg to conserve — test is vacuous`);
// no rain from here: the pond may drain off the rim but must not appear
const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
runStorm(r, dry, 5);
assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
const dumped = r.dumpPond('test');
assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped');
assert(r.pondMass() === 0, 'dumpPond left water behind');
return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
});
function r_prev(_r, d) { return d; }
test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
runStorm(r, realWind(), STORM_02.duration);
assert(dumps.some((d) => d.reason === 'corner blew'), 'a corner let go and the water just sat there');
const big = dumps.find((d) => d.reason === 'corner blew');
return `corner blew and dropped ${big.kg.toFixed(0)} kg at t=${big.t.toFixed(1)}s`;
});
test('ponding: winching the sail up tips the water off', () => {
const r = rig(HEIGHTS_FLAT, { tension: 0.9 });
runStorm(r, realWind(), 40);
const held = r.pondMass();
assert(held > 50, 'nothing to tip off — test is vacuous');
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
r.setTension(1.1);
assert(dumps.some((d) => d.reason === 'tensioned up'), 'tensioning a ponded sail did not shed the water');
assert(r.pondMass() === 0, 'sail still holding water after being winched up');
return `winch 0.9 -> 1.1 shed ${held.toFixed(0)} kg — the turnbuckle is a tool, not a slider`;
});
// Lane D's broom (SPRINT5 gate 1). DESIGN.md: "run out and poke the pond with a
// broom — the funniest correct mechanic in the game."
test('ponding: drainPondAt is a broom, and the water has to go somewhere', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, realWind(), 45);
const before = r.pondMass();
assert(before > 100, `only ${before.toFixed(0)} kg to sweep — test is vacuous`);
const target = r.pondCentroid();
assert(target && target.node >= 0, 'pondCentroid found no pond to aim at');
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
let onYourHead = 0;
for (let i = 0; i < Math.round(1.5 / SIM_DT); i++) onYourHead += r.drainPondAt(target.node, SIM_DT);
assert(onYourHead > before * 0.4, `a 1.5 s poke only shifted ${onYourHead.toFixed(0)} of ${before.toFixed(0)} kg`);
assert(r.pondMass() < before * 0.6, 'the belly is still full after a full poke');
assert(dumps.length > 0, 'drainPondAt emitted nothing for Lane D to react to');
return `1.5 s poke dropped ${onYourHead.toFixed(0)} kg of ${before.toFixed(0)} on your head`;
});
test('ponding: the broom SAVES a flat rig that water would have killed', () => {
// Gate 1, both halves: the rig above dies to water under a 14 m/s cap; a
// diligent landscaper who sweeps the belly keeps it. Same rig, same capped
// wind, so the only thing that changed is the broom.
const CAP = 16;
const swept = carportRig(HARDWARE[1]);
const broke = [];
swept.events.on('break', (e) => broke.push(e));
const w = cappedWetWind(CAP);
const steps = Math.round(STORM_02.duration / SIM_DT);
for (let i = 0; i < steps; i++) {
swept.step(SIM_DT, w, i * SIM_DT);
// a diligent landscaper sweeps before the belly reaches a kill load — the
// rig above blows around 440 kg, so keep it under ~300
if (swept.pondMass() > 250) {
const c = swept.pondCentroid();
if (c) swept.drainPondAt(c.node, SIM_DT, 3);
}
}
assert(broke.length === 0, `swept rig still lost ${broke.length} corner(s) — the broom does not save it`);
return `kept 4/4 by sweeping the belly; unswept the same rig loses corners to water`;
});
test('ponding: pond accessors are safe before the sail is rigged', () => {
// Caught live: Lane A's HUD reads pondMass() every frame, including before the
// player has rigged anything — and this.water doesn't exist until attach().
const bare = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) });
assert(bare.pondMass() === 0, 'pondMass threw / was non-zero on an unrigged sail');
assert(bare.pondCentroid() === null, 'pondCentroid should be null on an unrigged sail');
assert(bare.drainPondAt(0, SIM_DT) === 0, 'drainPondAt should no-op on an unrigged sail');
assert(bare.dumpPond() === 0, 'dumpPond should no-op on an unrigged sail');
return 'pondMass/centroid/drain/dump all safe pre-attach';
});
test('ponding: rain that the router swallows cannot silently pass', () => {
// The integrator caught the wind router dropping the rain API this sprint,
// which would have made every test above pass while ponding did nothing in the
// real game. A wind with no rain methods must therefore be LOUD, not benign.
const r = rig(HEIGHTS_FLAT);
const noRainApi = { sample: realWind().sample, speedAt: () => 0, gustTelegraph: () => null };
runStorm(r, noRainApi, 30);
assert(r.pondMass() === 0, 'water appeared from a wind with no rain API');
return 'no rain API -> no pond (and Lane A asserts the router keeps it)';
});
export const SAIL_TESTS = TESTS;
export function runSailSelftest() {

View File

@ -7,7 +7,7 @@ import * as THREE from '../../vendor/three.module.js';
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWorld, heightAt } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { createGame, createWindRouter } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
@ -97,68 +97,6 @@ export default async function run(t) {
assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)');
});
// --- verdicts tell the truth (SPRINT6 gate 1) ----------------------------
t.test('a run that held every corner is never told it skimped', () => {
// The bug this pins shipped and was caught by playing, not by asserting:
// any run under 50 hp read "the rain found what you skimped on", including
// Lane B's twisted quad that held 4/4 and skimped on nothing. The verdict is
// the game's whole feedback channel — DESIGN.md wants every disaster to
// replay as "…the shackle, I knew about the shackle", and blaming the wrong
// thing teaches the exact opposite of the lesson the storm just gave.
const heldAll = verdictFor({
hp: 39, lost: [], win: false,
dmg: { hail: 48, rain: 13 }, pondPeak: 0, pondDumped: 0,
});
assertEq(heldAll.mode, 'uncovered',
'a 4/4 hold that lost the garden to hail is a COVERAGE failure, not a hardware one');
assert(!/skimp/i.test(heldAll.verdict),
`verdict accused a player who broke nothing of skimping: "${heldAll.verdict}"`);
assert(/held/i.test(heldAll.verdict),
`verdict should credit the corners that held: "${heldAll.verdict}"`);
});
t.test('verdict names the weakest link that actually let go', () => {
// "…the shackle. I knew about the shackle." The whole point is that the
// player recognises the corner they gambled on.
const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } };
const shackle = { anchorId: 'h3', hw: { name: 'shackle', rating: 3200, cost: 15 } };
const cascade = verdictFor({
hp: 20, lost: [shackle, carabiner], win: false,
dmg: { hail: 60, rain: 20 }, pondPeak: 0, pondDumped: 0,
});
assertEq(cascade.mode, 'cascade');
assert(/carabiner at P1/.test(cascade.verdict),
`a cascade must name the WEAKEST link as going first, not whichever was listed first: "${cascade.verdict}"`);
const single = verdictFor({
hp: 30, lost: [shackle], win: false,
dmg: { hail: 60, rain: 10 }, pondPeak: 0, pondDumped: 0,
});
assertEq(single.mode, 'corner');
assert(/shackle at H3/.test(single.verdict), `should name it: "${single.verdict}"`);
});
t.test('verdict distinguishes the two ways a garden dies', () => {
// Opposite mistakes, opposite fixes: under-bought hardware vs a rig that
// held perfectly over the wrong patch of grass. One sentence each.
const hailed = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0 });
const rained = verdictFor({ hp: 20, lost: [], win: false, dmg: { hail: 0, rain: 80 }, pondPeak: 0, pondDumped: 0 });
assertEq(hailed.mode, 'uncovered');
assertEq(rained.mode, 'rain');
assert(hailed.verdict !== rained.verdict, 'hail and rain deaths must not read identically');
});
t.test('a clean hold reads as a clean hold, and the broom gets its credit', () => {
const clean = verdictFor({ hp: 96, lost: [], win: true, dmg: { hail: 2, rain: 2 }, pondPeak: 0, pondDumped: 0 });
assertEq(clean.mode, 'clean');
const broomed = verdictFor({ hp: 70, lost: [], win: true, dmg: { hail: 20, rain: 10 }, pondPeak: 400, pondDumped: 380 });
assertEq(broomed.mode, 'broomed', 'wearing 380 kg of water to save the rig should be the story');
assert(/380 kg/.test(broomed.verdict), `say what it cost: "${broomed.verdict}"`);
});
// --- camera --------------------------------------------------------------
t.test('camera keeps a clear line to the player from every angle', () => {
@ -234,11 +172,11 @@ export default async function run(t) {
// --- anchors -------------------------------------------------------------
t.test('yard offers 12 anchors: 3 house, 5 tree, 4 post', () => {
t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => {
const by = (type) => world.anchors.filter((a) => a.type === type).length;
assertEq(by('house'), 3, 'house anchors');
assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())');
assertEq(by('post'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)');
assertEq(by('post'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
const ids = world.anchors.map((a) => a.id);
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
});

View File

@ -25,12 +25,7 @@ import { weatherCases } from './weather.selftest.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
// ponding case reached for a storm the browser half had never loaded.
const STORMS = [
'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly',
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
// and night 5 (less wind, 2.8× the hail).
'storm_03b_earlybuster', 'storm_02b_icenight',
];
const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly'];
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {

View File

@ -13,7 +13,6 @@
*/
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
import { Interact, wireYardActions } from '../interact.js';
import { createBroom, BROOM_TUNE } from '../broom.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
@ -518,157 +517,6 @@ export default async function run(t) {
assertEq(p.climbY, 0);
});
// ---------------------------------------------------------------- the greyed-prompt surface
t.test('prompt: a usable action always wins over a reason', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 3, canUse: () => false,
label: 'hands full' });
it.register({ id: 'open', pos: { x: 0, y: 0, z: 1 }, radius: 3, label: 'take a spare' });
const v = it.visible(p);
assertEq(v.target.id, 'open', 'the thing you CAN do is the thing you are offered');
assert(v.usable, 'and it is offered, not explained');
});
t.test('prompt: an unavailable action explains itself instead of vanishing', () => {
// The bug this fixes, found by playing: walk to the shed table carrying the ladder and the
// prompt disappeared, because canUse filtered the target out of nearest() before its label
// could ever say "hands full". No prompt reads as a broken game; a greyed one reads as a rule.
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'spare_table', pos: { x: 0, y: 0, z: 0 }, radius: 3,
label: (pl) => (pl.carrying ? 'hands full' : 'take a spare'),
canUse: (pl) => !pl.carrying });
assertEq(it.visible(p).label, 'take a spare', 'empty-handed: an offer');
assert(it.visible(p).usable);
p.carrying = 'ladder';
const v = it.visible(p);
assert(!!v, 'carrying something, the prompt must NOT vanish');
assertEq(v.label, 'hands full', 'it says why');
assert(!v.usable, 'and is flagged unusable so the HUD can grey it');
assertEq(it.nearest(p), null, 'while nearest() — what E acts on — still correctly refuses it');
});
t.test('prompt: step() reports usable so the HUD can grey the radial', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 3, holdSecs: 1, label: 'do it',
canUse: (pl) => !pl.carrying });
p.carrying = 'broom';
let r = it.step(DT, 0, p, true);
assert(!r.usable, 'unusable');
assertEq(r.progress, 0, 'and no radial creeps up on an action that cannot run');
p.carrying = null;
r = it.step(DT, 0.1, p, true);
assert(r.usable, 'usable once the hands are free');
});
// ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1)
// Lane B's ponding isn't landed yet, so this stub is the seam I posted in THREADS:
// ponds -> [{node, mass, pos}], drainPondAt(node) -> kg dumped.
const stubRig = (ponds = []) => ({
ponds,
pondMass: () => ponds.reduce((s, p) => s + p.mass, 0),
drainPondAt(node) {
const p = ponds.find((x) => x.node === node);
if (!p) return 0;
const kg = p.mass;
p.mass = 0; // B's sail springs back on its own once the weight is gone
return kg;
},
});
t.test('broom: is a third carry type and queues behind the same hands', () => {
const p = new PlayerSim();
assertEq(p.pickUp('broom'), true, 'take the broom');
assertEq(p.pickUp('spare'), false, 'no spare as well');
assertEq(p.pickUp('ladder'), false, 'no ladder as well');
assertEq(clipFor(p), 'CarryIdle', 'and you read as carrying');
assertEq(p.drop(), 'broom');
});
t.test('broom: refuses to poke thin air, and refuses without the broom', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([]); // nothing pooling
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond(), null, 'no pond, nothing to poke');
assertEq(it.nearest(p), null, 'and no offer');
b.dispose();
});
t.test('broom: poke drains the pond Lane B reports, and the water lands on YOU', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 44, mass: 30, pos: { x: 0, y: 2.6, z: 0 } }]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
const pond = b.targetPond();
assert(!!pond, 'standing under the belly, there is a pond to poke');
assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered');
assert(/30 kg/.test(it.labelOf(it.nearest(p), p)), 'the prompt tells you how much is up there');
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assertEq(rig.pondMass(), 0, 'the pond is gone');
assert(p.events.some((e) => e.type === 'doused' && e.kg === 30), 'and it went over the player');
b.dispose();
});
t.test('broom: the size of the pond decides the size of the joke', () => {
const mk = (kg) => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 1, mass: kg, pos: { x: 0, y: 2.6, z: 0 } }]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
b.dispose();
return p.state;
};
assertEq(mk(5), 'idle', 'a splash just makes you wet');
assertEq(mk(BROOM_TUNE.staggerKg + 5), 'stagger', 'half a bathtub breaks your stride');
assertEq(mk(BROOM_TUNE.staggerKg * 2 + 5), 'knocked', 'a bathtub puts you on your back');
});
t.test('broom: picks the heaviest pond overhead, not the nearest', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([
{ node: 1, mass: 8, pos: { x: 0.2, y: 2.6, z: 0 } }, // nearer
{ node: 2, mass: 90, pos: { x: 1.4, y: 2.6, z: 0 } }, // heavier — the one breaking the rig
]);
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond().node, 2, 'if you are under two, you meant the one about to kill you');
b.dispose();
});
t.test('broom: a pond out of reach overhead cannot be poked from the grass', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const rig = stubRig([{ node: 1, mass: 40, pos: { x: 0, y: 9, z: 0 } }]); // 9 m up
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom';
assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9');
b.dispose();
});
t.test('broom: self-skips cleanly until Lane B lands drainPondAt', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact();
const noPonding = { }; // a rig with no ponding at all
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding);
p.carrying = 'broom';
assertEq(b.ponds().length, 0, 'no ponds reported');
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws');
b.dispose();
});
// ---------------------------------------------------------------- solids collision
t.test('collision: solids stop you, and the pushout slides you along them', () => {
// one box: the yard's north wall, x -8..8, z -16..-10, waist high

View File

@ -79,7 +79,6 @@ const ASSETS = [
nodes: ['palings', 'rails', 'debris_palings'] },
{ name: 'broom_01', h: [1.35, 1.50],
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
{ name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
];
function sizeOf(gltf) {

View File

@ -12,7 +12,6 @@
import {
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor,
} from '../weather.core.js';
const DT = 1 / 60;
@ -600,70 +599,6 @@ export function weatherCases(storms) {
assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional');
});
// ---- 12. forecast uncertainty (SPRINT6 §Lane C) ----
// DESIGN.md's partial-information canon. The one rule that matters: a vague
// forecast is fine, a WRONG one isn't. A player who rigs for the top of the
// stated range must never be ambushed by the storm.
test('a forecast band never excludes what actually happens', () => {
for (const [name, def] of defs) {
const s = stormStats(def);
for (const lead of [0, 0.25, 0.5, 0.75, 1]) {
const f = forecastFor(def, lead);
const inside = (b, v, what) => assert(v >= b.lo - 1e-9 && v <= b.hi + 1e-9,
`${name} @lead ${lead}: ${what} truth ${v.toFixed(2)} outside forecast ${b.lo.toFixed(2)}${b.hi.toFixed(2)}`);
inside(f.sustained, s.sustained, 'sustained');
inside(f.gustPeak, s.gustPeak, 'gustPeak');
inside(f.rain, s.rainPeak, 'rain');
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
}
}
});
test('a forecast tightens to the truth as the night approaches', () => {
const def = storms.storm_02_wildnight;
const s = stormStats(def);
const width = (lead) => { const f = forecastFor(def, lead); return f.gustPeak.hi - f.gustPeak.lo; };
const far = width(1), mid = width(0.5), near = width(0);
metrics['forecast.gustBandWidth@lead1'] = +far.toFixed(1);
metrics['forecast.gustBandWidth@lead0.5'] = +mid.toFixed(1);
assert(far > mid && mid > near, `band must narrow with lead: ${far.toFixed(1)}${mid.toFixed(1)}${near.toFixed(1)}`);
assert(near === 0, `tonight's forecast should be exact, band is ${near.toFixed(2)} wide`);
const f0 = forecastFor(def, 0);
assert(f0.gustPeak.lo === s.gustPeak && f0.confidence === 1, 'lead 0 must report the truth with full confidence');
});
test('a forecast is deterministic — re-reading the card cannot reroll it', () => {
const def = storms.storm_02_wildnight;
for (const lead of [0.3, 0.8]) {
const a = forecastFor(def, lead), b = forecastFor(def, lead);
assert(a.gustPeak.lo === b.gustPeak.lo && a.gustPeak.hi === b.gustPeak.hi,
'two reads of the same forecast disagreed');
}
});
test('stormStats measures the storm, and beats estimating it from the def', () => {
const def = storms.storm_02_wildnight;
const s = stormStats(def);
// what the card used to estimate: baseCurve peak + powBase + powRamp
const estimate = Math.max(...def.baseCurve.map((p) => p[1]))
+ (def.gusts.powBase ?? 0) + (def.gusts.powRamp ?? 0);
metrics['storm_02.gustPeak.measured'] = +s.gustPeak.toFixed(1);
metrics['storm_02.gustPeak.oldEstimate'] = +estimate.toFixed(1);
assert(Math.abs(s.gustPeak - estimate) > 1,
'the estimate happens to match — this test is only interesting while they differ');
assert(s.gustPeak > 30 && s.gustPeak < 35, `measured gust peak ${s.gustPeak.toFixed(1)} is not credible`);
assert(s.changeAt === 55, `storm_02's change is at ${s.changeAt}, expected 55`);
});
test('the forecast hail hint tracks the storm, and hedges when far out', () => {
assert(forecastFor(storms.storm_01_gentle, 0).hail.chance === 'none', 'gentle storm forecast hail');
assert(forecastFor(storms.storm_02_wildnight, 0).hail.chance === 'likely', 'the wild night should forecast likely hail');
assert(forecastFor(storms.storm_02_wildnight, 1).hail.chance === 'possible',
'a week out, the wild night should only hedge at "possible"');
assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly');
});
return { cases, metrics };
}

View File

@ -511,104 +511,6 @@ export function createWindField(def, opts = {}) {
return field;
}
// ---------- forecasting ----------
// DESIGN.md's partial-information canon: a forecast days out is a RANGE, and it
// tightens as the night comes. Deliberately pure functions on a storm def rather
// than methods on a wind — the card has defs in hand, it doesn't want a field,
// and this keeps forecasting off the wind contract (and out of main.js's router).
const _statsCache = new WeakMap();
/**
* The truth about a storm, measured rather than estimated. The forecast card was
* approximating the gust peak as `baseCurve peak + powBase + powRamp` (30 m/s
* for storm_02); the storm actually gusts to 32.3, because gust power is drawn
* per gust and rides a ramp. If the card is going to sell the dread it may as
* well sell the real number. Cached per def scanning is ~1800 samples.
*
* @returns {{sustained, gustPeak, rainPeak, rainPeakMmPerHour, hailPeak, hailSeconds, changeAt}}
*/
export function stormStats(def) {
const hit = _statsCache.get(def);
if (hit) return hit;
const f = createWindField(def);
const dur = def.duration ?? 90;
let sustained = 0, gustPeak = 0, rainPeak = 0, hailPeak = 0, hailSeconds = 0;
const STEP = 0.05;
for (let t = 0; t <= dur; t += STEP) {
const u = f.uniformSpeed(t);
if (u > gustPeak) gustPeak = u;
const base = u - f.gustOnly(t);
if (base > sustained) sustained = base;
const r = f.rainAt(t);
if (r > rainPeak) rainPeak = r;
const h = f.hailAt(t);
if (h > hailPeak) hailPeak = h;
hailSeconds += h * STEP;
}
const change = (def.events || []).find((e) => e.type === 'windchange');
const stats = {
sustained, gustPeak, rainPeak,
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
hailPeak, hailSeconds,
changeAt: change ? change.t : null,
};
_statsCache.set(def, stats);
return stats;
}
/** How wide each number's band runs at lead=1, as a fraction of the value. */
const FORECAST_SPREAD = {
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
};
/**
* A forecast of `def` seen `lead` out 0 = tonight (exact), 1 = the far end of
* the week (vague). Deterministic per storm, so the same night always forecasts
* the same way and re-reading the card can't reroll it.
*
* The band ALWAYS contains the truth. That's the line between partial
* information and a lie: a forecast may be vague, and its midpoint may be off,
* but it must never rule out what actually happens a player who rigs for the
* top of the stated range must never be ambushed. Width and centre-wander both
* shrink to nothing as lead 0.
*
* @param {object} def parsed storm JSON
* @param {number} lead 0..1
*/
export function forecastFor(def, lead = 0) {
const s = stormStats(def);
const L = Math.min(1, Math.max(0, lead));
const r = mulberry32(((def.seed ?? 1) ^ 0xf0eca57) >>> 0);
const band = (v, rel) => {
if (L <= 0 || !(v > 0)) return { lo: v, hi: v };
const w = v * rel * L;
const c = v + (r() * 2 - 1) * w * 0.6; // the centre wanders, seeded
return { lo: Math.max(0, Math.min(v, c - w)), hi: Math.max(v, c + w) };
};
// Hail is the garden score (decision 13), so the card has to hint at it — but
// a distant forecast shouldn't promise ice it can't see yet.
let chance = 'none';
if (s.hailSeconds > 0) {
if (L > 0.55) chance = 'possible';
else chance = s.hailSeconds > 6 ? 'likely' : 'possible';
}
return {
lead: L,
confidence: 1 - L,
sustained: band(s.sustained, FORECAST_SPREAD.sustained),
gustPeak: band(s.gustPeak, FORECAST_SPREAD.gustPeak),
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
truth: s,
};
}
// ---------- storm JSON validator ----------
// Storms are data so design can tune without code (PLAN3D §4) — which means a
// typo is a data bug, and data bugs should fail loud, not silently blow calm.

View File

@ -270,32 +270,10 @@ export function createWorld(scene, opts = {}) {
// 6 m bed with it costs you a sail the storm will take. Full shade is meant to
// be the expensive answer; the small quads buy survival and pay in patchy
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
// position is measured rather than guessed.
//
// The wild night had no winnable line because every anchor near the bed —
// p1/p2/p3 — is SOUTH of it, and nothing stands north short of the house 10 m
// away. So covering rigs had to span the yard and died, while rigs small
// enough to survive sat beside the bed instead of over it.
//
// p4 supplies the missing north-west corner. Swept its position against the
// smallest quad that covers 90% of the bed:
// (-2.2, -1.2) → 44.4 m² ← breaks the tradeoff (see below)
// (-3.2, -1.2) → 48.6 m² ← here
// (-3.2, -2.0) → 51.7 m²
// (-4.2, -3.0) → 60.0 m²
// beyond ~z=-4 → 63.5 m², i.e. too far away to matter at all
// Full coverage costs 63.5 m² without it. At (-3.2,-1.2) that drops to 48.6 —
// a real new option, 23% cheaper — while still costing you a bigger sail than
// the 23-38 m² rigs that survive on their own. Pulling it in to (-2.2,-1.2)
// would put full coverage at 44.4 m², INSIDE the survivable band, which
// collapses DESIGN.md's central tension: covering the bed has to cost risk.
// a.test.js asserts that >45 m² floor, so moving this post inward goes red.
const postSpecs = [
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
{ id: 'p4', x: -3.2, z: -1.2, h: 4.0 },
];
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB