Compare commits
19 Commits
7dd77775da
...
a0cf23dec8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cf23dec8 | ||
|
|
121e169981 | ||
|
|
40be89c238 | ||
|
|
a53837208d | ||
|
|
2af4662cd7 | ||
|
|
c3967d98e8 | ||
|
|
125411e0df | ||
|
|
b1acbd6668 | ||
|
|
9010a2c784 | ||
|
|
1304be76f6 | ||
|
|
95b0917601 | ||
|
|
c92a7a1b13 | ||
|
|
97ea1b668c | ||
|
|
670443eea0 | ||
|
|
b7f93d6486 | ||
|
|
f2527055a5 | ||
|
|
4e8a98fca5 | ||
|
|
d67126eacf | ||
|
|
718f011e36 |
@ -330,3 +330,55 @@ 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.
|
||||
|
||||
80
SPRINT6.md
Normal file
80
SPRINT6.md
Normal file
@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
353
THREADS.md
353
THREADS.md
@ -825,6 +825,53 @@ 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 2–3 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 23–38 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
|
||||
@ -1543,6 +1590,71 @@ 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
|
||||
@ -1586,3 +1698,244 @@ 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 ~1–3 mm; hailstones are 6–45 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 93–174 km/h change 38–63 s hail possible
|
||||
2 nights out conf 40% gusts 102–151 km/h change 45–60 s hail possible
|
||||
tomorrow conf 75% gusts 110–131 km/h change 51–57 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.
|
||||
|
||||
BIN
docs/yard_day.jpg
Normal file
BIN
docs/yard_day.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/yard_night.jpg
Normal file
BIN
docs/yard_night.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@ -118,6 +118,7 @@ PAL = {
|
||||
"gnome_coat": "#3E6FA8",
|
||||
"gnome_hat": "#B33C36",
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"hail_ice": "#DCEAF2", # hailstone
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
@ -1188,6 +1189,30 @@ 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).
|
||||
|
||||
@ -1515,6 +1540,88 @@ 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."""
|
||||
@ -1638,6 +1745,9 @@ 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.30–0.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"]),
|
||||
@ -1970,6 +2080,7 @@ def main():
|
||||
build_grass_atlas()
|
||||
build_sail_textures()
|
||||
build_pond_textures()
|
||||
build_hail_and_shred_atlases()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
110
tools/yardshot/shot_server.py
Normal file
110
tools/yardshot/shot_server.py
Normal file
@ -0,0 +1,110 @@
|
||||
#!/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()
|
||||
52
web/world/data/storms/storm_02b_icenight.json
Normal file
52
web/world/data/storms/storm_02b_icenight.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
45
web/world/data/storms/storm_03b_earlybuster.json
Normal file
45
web/world/data/storms/storm_03b_earlybuster.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
188
web/world/js/broom.js
Normal file
188
web/world/js/broom.js
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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); },
|
||||
};
|
||||
}
|
||||
@ -191,6 +191,17 @@ 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'.
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -306,7 +317,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' },
|
||||
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function', pondMass: 'function', pondCentroid: 'function', drainPondAt: 'function' },
|
||||
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
|
||||
interact: { register: 'function' },
|
||||
camera: { object: 'object', yaw: 'number', update: 'function' },
|
||||
|
||||
@ -62,7 +62,7 @@ export class Interact {
|
||||
return !target.canUse || !!target.canUse(player);
|
||||
}
|
||||
|
||||
/** Nearest registered target in range whose canUse() passes. */
|
||||
/** Nearest registered target in range whose canUse() passes. This is what a hold-E acts on. */
|
||||
nearest(player) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const target of this.targets.values()) {
|
||||
@ -74,6 +74,31 @@ 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
|
||||
@ -88,7 +113,10 @@ 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}} for hud.js to draw the prompt + radial
|
||||
* @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").
|
||||
*/
|
||||
step(dt, t, player, holding) {
|
||||
// One press, one action: a completed hold latches until E is released. Without this, a held key
|
||||
@ -126,12 +154,22 @@ export class Interact {
|
||||
}
|
||||
}
|
||||
|
||||
const shown = this.active || near;
|
||||
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);
|
||||
return {
|
||||
target: shown,
|
||||
progress: this.progress,
|
||||
label: shown ? this.labelOf(shown, player) : '',
|
||||
holding: !!this.active,
|
||||
target: shown ? shown.target : null,
|
||||
progress: 0,
|
||||
label: shown ? shown.label : '',
|
||||
holding: false,
|
||||
usable: !!(shown && shown.usable),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -158,6 +196,11 @@ 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) => {
|
||||
|
||||
@ -141,7 +141,13 @@ export function createLadder(scene, world, interact, player) {
|
||||
radius: PLACE_RANGE,
|
||||
holdSecs: 1.0,
|
||||
clip: 'PickUp',
|
||||
label: `set the ladder under ${a.id}`,
|
||||
// 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'),
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
|
||||
@ -54,35 +54,129 @@ 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; },
|
||||
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
|
||||
/** 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'); },
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* @param {number} dt
|
||||
* @param {number} hail 0..1 sky.gardenHailExposure(bed, t)
|
||||
* @param {number} rain 0..1 sky.gardenExposure(bed, t)
|
||||
*/
|
||||
step(dt, exposure) {
|
||||
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt);
|
||||
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;
|
||||
}
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -435,6 +529,9 @@ 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,
|
||||
@ -446,13 +543,33 @@ export async function boot(opts = {}) {
|
||||
subtitle: lost.length
|
||||
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
|
||||
: 'Every corner held.',
|
||||
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.',
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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
|
||||
@ -462,6 +579,8 @@ 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();
|
||||
@ -546,7 +665,24 @@ 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') {
|
||||
garden.step(dt, sky?.gardenExposure ? sky.gardenExposure(world.gardenBed, windT) : 0);
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,8 +17,9 @@ 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 };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -287,6 +288,11 @@ 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; },
|
||||
@ -299,6 +305,7 @@ 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);
|
||||
},
|
||||
@ -308,8 +315,13 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
sim,
|
||||
view,
|
||||
ladder,
|
||||
broom,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
|
||||
dispose() {
|
||||
keyboard.dispose(); view.dispose();
|
||||
if (ladder) ladder.dispose();
|
||||
if (broom) broom.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -32,11 +32,23 @@ export class RiggingSession {
|
||||
*/
|
||||
constructor({ anchors = [], budget = START_BUDGET } = {}) {
|
||||
this.anchors = anchors;
|
||||
this.budget = budget;
|
||||
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.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; }
|
||||
|
||||
@ -169,6 +169,20 @@ 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() {
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
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 };
|
||||
|
||||
@ -56,11 +57,45 @@ 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
|
||||
@ -219,6 +254,26 @@ 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
|
||||
@ -345,6 +400,8 @@ 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
|
||||
@ -352,6 +409,31 @@ 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. */
|
||||
@ -400,9 +482,166 @@ 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).
|
||||
*
|
||||
@ -600,6 +839,9 @@ 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 });
|
||||
}
|
||||
}
|
||||
@ -664,8 +906,13 @@ export class SailRig {
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
const was = this.tension;
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
if (this.rigged) this._applyRestLengths();
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
import { SailRig } from './sail.js';
|
||||
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
|
||||
import { createWindField } from './weather.core.js';
|
||||
import { createWindField, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
||||
|
||||
const SIM_DT = FIXED_DT;
|
||||
|
||||
@ -43,6 +43,9 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -67,9 +70,11 @@ const YARD = [
|
||||
*/
|
||||
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
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);
|
||||
};
|
||||
|
||||
// ---------- deterministic stub wind ----------
|
||||
// contracts.js ships createStubWind(), and the integration test below uses it.
|
||||
@ -137,11 +142,31 @@ 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 } = {}) {
|
||||
return new SailRig({ anchors: makeAnchors(heights), gridN: 10, porosity })
|
||||
.attach(ALL_IDS, [hw, hw, hw, hw], tension);
|
||||
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);
|
||||
}
|
||||
|
||||
/** Fixed-dt fast-forward. Returns the peak corner load over the whole run, N. */
|
||||
@ -686,6 +711,196 @@ 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() {
|
||||
|
||||
@ -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 } from '../main.js';
|
||||
import { createGame, createWindRouter, verdictFor } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
@ -97,6 +97,68 @@ 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', () => {
|
||||
@ -172,11 +234,11 @@ export default async function run(t) {
|
||||
|
||||
// --- anchors -------------------------------------------------------------
|
||||
|
||||
t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => {
|
||||
t.test('yard offers 12 anchors: 3 house, 5 tree, 4 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'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
|
||||
assertEq(by('post'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)');
|
||||
const ids = world.anchors.map((a) => a.id);
|
||||
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
|
||||
});
|
||||
|
||||
@ -25,7 +25,12 @@ 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'];
|
||||
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',
|
||||
];
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
*/
|
||||
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';
|
||||
@ -517,6 +518,157 @@ 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
|
||||
|
||||
@ -79,6 +79,7 @@ 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) {
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
import {
|
||||
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||
stormStats, forecastFor,
|
||||
} from '../weather.core.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
@ -599,6 +600,70 @@ 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 };
|
||||
}
|
||||
|
||||
|
||||
@ -511,6 +511,104 @@ 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.
|
||||
|
||||
@ -270,10 +270,32 @@ 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) {
|
||||
|
||||
BIN
web/world/models/hail_stone_01_v1.glb
Normal file
BIN
web/world/models/hail_stone_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/hail_pips.png
Normal file
BIN
web/world/models/textures/hail_pips.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
web/world/models/textures/plant_shred.png
Normal file
BIN
web/world/models/textures/plant_shred.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Loading…
Reference in New Issue
Block a user