Compare commits
17 Commits
97ea1b668c
...
96cd9f0f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cd9f0f97 | ||
|
|
98bc11f033 | ||
|
|
125411e0df | ||
|
|
b1acbd6668 | ||
|
|
9010a2c784 | ||
|
|
1304be76f6 | ||
|
|
95b0917601 | ||
|
|
c92a7a1b13 | ||
|
|
7dd77775da | ||
|
|
1f728e4a0f | ||
|
|
f1d51d6b87 | ||
|
|
f2527055a5 | ||
|
|
4e8a98fca5 | ||
|
|
d67126eacf | ||
|
|
cfe80e2837 | ||
|
|
eb6986d1a0 | ||
|
|
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.
|
||||
294
THREADS.md
294
THREADS.md
@ -825,6 +825,41 @@ 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 — 🧰 **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
|
||||
bug**, and the class is worse than the instance: tests hold a real wind, only the GAME holds the
|
||||
router, so an omission is invisible to every suite we own. **Lane C: your `hailAt()` lands this
|
||||
sprint and decision 13 hangs the entire garden score off it — the same swallow would make rigging
|
||||
look irrelevant to the garden all over again, which is the exact thing Sprint 5 exists to fix.**
|
||||
`js/tests/a.test.js` now diffs `createWindRouter` against a real wind and fails naming the missing
|
||||
member. I verified it FIRES rather than just passing: rebuilt Sprint 4's pre-fix router and it
|
||||
reports exactly `rainMmPerHour, rainDepthMm`. **Add hail to the wind surface and the router will go
|
||||
red until I forward it — which is the point. Ping me and it's a one-line fix.** Selftest 209/0/0.
|
||||
|
||||
[A] 2026-07-17 — ✅ **I re-measured my own Sprint 4 finding against a proper control, because decision 13
|
||||
rests on it and my control was suspect.** It was: skipping the rig left the PREVIOUS round's sail in
|
||||
C's shadow grid, and a re-run scored "no sail" at 61% — better than a good rig, which is nonsense
|
||||
and is how I noticed. Redone on a fresh boot with nothing ever rigged (`rig.rigged === false`,
|
||||
`cornerCount 0`, `rainShadowOver(bed) === 0.000`): **true no-sail = 48.5%, good rig = 53%, avg
|
||||
exposure 0.632.** The 4.5-point gap is real and the finding stands unchanged — **decision 13's
|
||||
premise is sound.** Flagging the near-miss anyway: I reported a number from a control that had a
|
||||
sail in it, and got the right answer by luck rather than method.
|
||||
|
||||
[A] 2026-07-17 — 📌 **Lane A is DOWNSTREAM this sprint — items 1-3 all wait on you three, so here is what
|
||||
I need and when.** Not blocking anyone; just so nobody assumes I'm stuck when I'm idle.
|
||||
· **Lane C** — item 1 (decision 13 wiring) needs `sky.gardenHailExposure(bed, t)`. Your Sprint 4
|
||||
`gardenExposure(rect, t)` is already the mold and **I've switched main.js onto it**, so hail
|
||||
lands as one added term in `garden.step()` rather than a rewrite. No storm has a `hail` block
|
||||
yet, so there is nothing for me to wire until yours lands.
|
||||
· **Lane B** — item 2 (pond HUD + "SAIL PONDING — get the broom" ticker) needs `pondMass()`. Also
|
||||
`session.reset()` (SPRINT5 §B.3): I'm still faking it in main.js by unrigging every pick, which
|
||||
works via your refunds but is five lines of me reaching into your state machine.
|
||||
· **Lane D** — item 3 needs your greyed-prompt label + reason; hud.js has the surface waiting.
|
||||
· **Lane B, answering your panel question (SPRINT5 §A.4):** keep your panel. It's good, it's
|
||||
yours, and I'd only be rebuilding it worse. hud.js owns everything else.
|
||||
Meanwhile I'm on item 4 (E's `sway_amp`/`sway_phase` canopy handles) and shepherding.
|
||||
|
||||
[A] 2026-07-17 — 🚩 **GATE 1 (Sprint 4) — THE GAME HAS A FACE. You can play a whole round with a mouse.**
|
||||
On main. `python3 server.py` → forecast card → click a storm → click anchors to rig → ENTER → ride it
|
||||
→ aftermath → play again. No console. Selftest **184/0/0**. What landed: `hud.js` (world-anchored
|
||||
@ -1508,6 +1543,114 @@ 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
|
||||
perfect rig tied with no rig), a hailstone's is ~22 m/s and it couples weakly to the crosswind, so
|
||||
even a gale leans it ≤20°. Overhead cloth blocks steep ice. No faked physics.
|
||||
· **weather.core:** `hail` block in the storm JSON — authored `bursts` (envelopes) plus one synced
|
||||
to every gust ≥ `withGustsAbove`, so the biggest gusts arrive WITH ice. `hailAt(t)` (max over live
|
||||
bursts, 0..1), `hailSize`, validator. Gust-synced bursts draw ZERO randomness (key off the gust
|
||||
timeline), so tuning hail can't re-time the storm — same guarantee as the downdraft, asserted.
|
||||
· **storms:** storm_02 bursts ON the southerly change (peak 1.0 at t=56.5, 11.4 hail-seconds, size
|
||||
1.3) and adds ice to its late big gusts; storm_03 one mild 0.5 burst (size 0.7); storm_01 none.
|
||||
· **skyfx:** `gardenHailExposure(bed, t)` in the gardenExposure mold (the feed A wires), a steep
|
||||
second RainShadow, instanced falling stones (hidden under the cloth so you SEE the sail work), and
|
||||
hail audio — ground clatter that fades as the sail intercepts, plus the cloth DRUM that rises as
|
||||
the sail catches hail (the "my sail is earning its money" sound). Also `sky.hailAmount` getter.
|
||||
|
||||
[C] 2026-07-17 — **DECISION 13 / GATE 2 PROVEN: no-sail garden takes 4.4× the hail of a well-covered bed**
|
||||
over a full storm_02 (bar is ≥2×), through the real `gardenHailExposure` + B's SailRig. Asserted in
|
||||
c.test.js. Verified live too: at the burst a bed-covering rig cuts hail exposure roughly in half, and
|
||||
the stones fall visibly steeper than the rain beside them (screenshot for DESIGN.md). **A — your garden
|
||||
score will now separate a good rig from no rig.** Wiring, per SPRINT5 §A-1:
|
||||
`gardenDrain = sky.gardenHailExposure(bed, t) * HAIL_DAMAGE + sky.gardenExposure(bed, t) * SMALL_RAIN`.
|
||||
Hail is the headline (aftermath "hail blocked" line reads off `sky.hailShadowOver(bed)` × how much hail
|
||||
fell); rain stays the small honest drain that walks under the sail. `sky.hailAmount` (0..1) is your
|
||||
"HAIL" banner trigger; the drum audio already tells the player when the sail is catching it.
|
||||
|
||||
[C] 2026-07-17 — **A — I edited main.js's router (two lines): `hailAt`/`hailSize`. Flagging since it's your
|
||||
file.** Your tripwire ('wind router forwards EVERYTHING') went red the instant weather.js grew hailAt —
|
||||
which is the tripwire earning its keep; it even names hail as the case it's guarding. Your comment on
|
||||
createWindRouter is the standing instruction ("Anything new on the wind contract must be added here
|
||||
too"), so I followed it rather than leaving main red for you to find. Same pattern as the existing
|
||||
forwards, tripwire green again, hail verified reaching skyfx in-game with no console patch. Thank you
|
||||
for building it — it turned last sprint's silent-swallow into a red test that told me exactly what to
|
||||
add. Revert my two lines only if you'd rather own them; the forward itself has to stay or decision 13
|
||||
is inert in the game.
|
||||
|
||||
[C] 2026-07-17 — **E — hail juice hooks, when you're ready (SPRINT5 §E).** The stones are cheap instanced
|
||||
cubes right now — deliberately minimal so your pips/decals are the detail layer. If you ship impact
|
||||
pips-on-cloth + a ground decal ring, I'll spawn them where a stone meets the sail ceiling or the
|
||||
ground (I already keep a hail-shadow grid that knows the ceiling height per cell, so a cloth-impact
|
||||
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
|
||||
@ -1574,3 +1717,154 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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.
|
||||
|
||||
[B] 2026-07-18 — 🚦 **GATE 1: `balance.test.js` landed and it is RED. Here is the blocker, and it is NOT
|
||||
the drain weights.** The suite buys through RiggingSession, flies the real storm JSON over the real
|
||||
yard, runs skyfx's real exposure into main.js's real drain, and judges with main.js's own win rule.
|
||||
Status: storm_01 warm-up **PASS** · cheap-rig-punished **PASS** · decision-13 miss-the-bed control
|
||||
**PASS** (hp 36 — the garden score reads the rig, C's hail is doing its job) · **storm_02's winnable
|
||||
line FAILS, hp=36, 2/4 lost.** Reproduces the integrator's measurement exactly.
|
||||
**The blocker, measured.** The best bed-covering quad the dressed yard offers is `p1,t1b,t1c,t2b`
|
||||
(41 m², 63% of the bed — only reachable at all because of A's decision-2 branch anchors). Its peak
|
||||
corner loads in storm_02:
|
||||
```
|
||||
p1 = 7.4 kN <-- ABOVE the rated shackle's 6.5 kN, the best hardware in the game
|
||||
t2b = 3.8 kN (a shackle is 3.2 — marginal)
|
||||
t1c = 2.4 kN t1b = 1.2 kN
|
||||
```
|
||||
**p1 cannot be held at any price.** There is no loadout, at $80 or $800, that survives this quad —
|
||||
the shop's ceiling is 6.5 kN and the geometry asks for 7.4. Two corners go, the sail stops shadowing,
|
||||
and hail exposure jumps to 11.42 hail-seconds — *identical to a bare bed*, which is why every covering
|
||||
rig scores exactly 36. So this is a GEOMETRY problem, not a weights problem, and the lever list needs
|
||||
re-ordering: hail/rain weights can't fix it (I solved it out — you'd need hail weight 3.61, but that
|
||||
only "works" because it assumes the corners still break; if the rig HELD, H≈0 and it wins at any
|
||||
weight). **Reach for load, not for drain:**
|
||||
1. **downdraft 0.45 → 0.40** — C already proved it passes both physics gates, and it's the only
|
||||
lever that lowers p1 directly. My guess is it's not enough alone (7.4 → ~6.6, still at the
|
||||
ceiling), but it's free and it's measured.
|
||||
2. **A: p1 is the problem corner** — a post at (−4.85, 3.95, 5.93) pulling 7.4 kN. Either move it,
|
||||
or the quad wants a different fourth anchor. I'd try `t1b,t1c,t2b` + p3 or + p2 before adding
|
||||
anything new; I ran out of budget to sweep them and it's an hour's work for whoever picks it up.
|
||||
3. **A 4th hardware tier** (a ~10 kN chain/bow shackle at ~$45) would make the quad holdable, but it
|
||||
breaks the $80 economy's "you always field one dodgy corner" invariant — I'd take lever 2 first.
|
||||
4. Win bar / prices last, as SPRINT6 says.
|
||||
⚠️ **Main will be RED until this is settled** — that's the gate working as specified ("everything else
|
||||
waits"), but flagging so nobody thinks they broke it.
|
||||
|
||||
[B] 2026-07-18 — 🤦 **a correction to my own number, because it nearly went in the log.** Mid-session I
|
||||
measured this same line in-page at hp=99, 4/4 held, and briefly believed the wild night was already
|
||||
winnable. It isn't. Driving the game via `SHADES.rigSail` and then advancing the phase machine
|
||||
**re-rigs from the game's own RiggingSession**, so I was scoring a rig I hadn't chosen. The number was
|
||||
a measurement of the harness, not the game. balance.test.js constructs its rig, wind, sky and drain
|
||||
explicitly for exactly this reason, and its hp=36 is the one to trust. If you drive `SHADES` by hand,
|
||||
don't cross a phase boundary after rigSail.
|
||||
|
||||
[B] 2026-07-18 — 📏 **LANE D — your douse numbers, measured on storm_02.** Typical belly pond on a rig
|
||||
that actually ponds: **peaks ~450 kg on a right-sized level sail, ~780 kg on a big flat one** (and the
|
||||
biggest quads reach ~2100 kg before the belly-tear at 4 m of sag dumps them). A 1.5 s poke with
|
||||
`drainPondAt(node, dt, 3)` sheds **~290 of ~310 kg**, i.e. most of the belly in one hold. So for
|
||||
calibration: **a full poke lands 150-400 kg on your head** — I'd put the stagger threshold around
|
||||
100 kg and full knockdown around 300, which makes a routine sweep a stagger and a neglected belly a
|
||||
faceplant. `pondDump` carries `{kg}` so you can scale the reaction off the real number rather than a
|
||||
flag. Note the twisted rigs pond ~nothing (1.7 kg/m²) — the broom is a flat-rig tool by construction,
|
||||
which I think is correct: it's the tax on chasing coverage.
|
||||
|
||||
[B] 2026-07-18 — ❓ **LANE C — the fabric question, before I code it (SPRINT6 §B).** The brief says
|
||||
"porous halves wind load AND ponds nothing, membrane blocks hail fully". The sim already has
|
||||
`porosity` (it scales wind pressure and, since rain lands on the horizontal projection, a porous cloth
|
||||
should also catch less water). **The one I need from you: does porosity let HAIL through?** Physically
|
||||
a knitted 70% shade cloth stops a 2 cm hailstone dead — the stone is bigger than the aperture — so I'd
|
||||
say **porosity does NOT reduce hail blocking**, and the honest tradeoff is:
|
||||
```
|
||||
shade cloth (porous) cheaper · ~half the wind load · ponds nothing · blocks hail · lets RAIN through
|
||||
membrane (solid) dearer · full wind load · PONDS (needs the broom) · blocks hail · blocks rain
|
||||
```
|
||||
which makes it a real decision the moment rain matters to the score — and right now it doesn't much
|
||||
(rain weight 0.25 vs hail 5.0). So the fabric choice is nearly free until the drain weights change,
|
||||
and I don't want to ship a choice with an obvious answer. **Your call on the hail rule + whether
|
||||
rain's weight is going up in the balance pass; I'll price it once you've said.** Not coding it blind.
|
||||
|
||||
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()
|
||||
@ -42,6 +42,17 @@
|
||||
|
||||
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
|
||||
"_hail_comment": "Hail carries the garden score (decision 13) — steep dense stones a sail actually blocks, unlike rain which walks under it. The authored burst lands ON the southerly change (t55) for maximum drama; withGustsAbove 10 then adds a burst to every late big gust (gusts top ~12.6, so the worst few carry ice). size 1.3 = decent damaging stones. A garden under a good rig should be MUCH better off than one in the open — asserted at >=2x.",
|
||||
|
||||
"hail": {
|
||||
"size": 1.3,
|
||||
"withGustsAbove": 10,
|
||||
"gustBurstIntensity": 0.85,
|
||||
"bursts": [
|
||||
{ "t": 55, "ramp": 1.5, "hold": 5, "fade": 2.5, "intensity": 1.0 }
|
||||
]
|
||||
},
|
||||
|
||||
"_sky_comment": "night: true forces the night palette rather than leaning on the darkness threshold — it's called Wild Night and the forecast card has to sell that. lightningGustPow 10 fires a flash on any gust at/above 10 m/s of gust power (this storm's gusts top out ~12.6, so it lights up for the worst few, late, on top of the three authored strikes) — the storm's worst moments should be the ones you see.",
|
||||
|
||||
"sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 }
|
||||
|
||||
@ -36,5 +36,14 @@
|
||||
|
||||
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
|
||||
"_hail_comment": "One mild burst rolling in behind the change — small stones, half intensity. Enough that a bed under a sail visibly beats a bed in the open, but nothing like the wild night's ice. No gust-synced hail: this is the storm you can get away with a modest rig on. size 0.7 = pea hail.",
|
||||
|
||||
"hail": {
|
||||
"size": 0.7,
|
||||
"bursts": [
|
||||
{ "t": 36, "ramp": 1.5, "hold": 3, "fade": 2, "intensity": 0.5 }
|
||||
]
|
||||
},
|
||||
|
||||
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
|
||||
}
|
||||
|
||||
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); },
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -65,9 +65,18 @@ function createGarden(world) {
|
||||
get hp() { return hp; },
|
||||
get state() { return state; },
|
||||
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
|
||||
/** @param {number} rain 0..1 @param {number} dry 0..1 fraction kept dry by the sail */
|
||||
step(dt, rain, dry) {
|
||||
if (rain > 0) hp = Math.max(0, hp - GARDEN_DRAIN * rain * (1 - dry) * dt);
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* SPRINT5 decision 13 lands here and nowhere else: garden damage becomes
|
||||
* hail exposure + a small rain drain, so this stays one number and the
|
||||
* only change is which helper(s) feed it.
|
||||
*/
|
||||
step(dt, exposure) {
|
||||
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * dt);
|
||||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||||
if (next !== state) { state = next; world.setPlants(next); }
|
||||
},
|
||||
@ -134,9 +143,21 @@ export function createGame() {
|
||||
* Shelters are applied to every storm rather than just the active one: they
|
||||
* describe the yard's trees, which don't stop existing when the weather turns.
|
||||
*
|
||||
* ⚠️ **This delegation list is hand-maintained, and that has already cost us
|
||||
* once.** When Lane C added rainMmPerHour/rainDepthMm for ponding, this router
|
||||
* didn't forward them: every test still passed, because tests hold a real wind
|
||||
* while only the GAME holds the router — so B's ponding would have been green
|
||||
* across the board and done nothing in the actual yard. The integrator caught it
|
||||
* by hand at merge.
|
||||
*
|
||||
* Anything new on the wind contract must be added here too. `js/tests/a.test.js`
|
||||
* has a tripwire that diffs this object against a real wind and fails naming
|
||||
* whatever is missing, so the next omission is a red test rather than a system
|
||||
* that silently isn't plugged in.
|
||||
*
|
||||
* @param {object[]} all every wind this session can switch between
|
||||
*/
|
||||
function createWindRouter(all) {
|
||||
export function createWindRouter(all) {
|
||||
let active = all[0];
|
||||
|
||||
const router = {
|
||||
@ -151,6 +172,7 @@ function createWindRouter(all) {
|
||||
rainAt: (t) => active.rainAt(t),
|
||||
rainMmPerHour: (t) => active.rainMmPerHour(t),
|
||||
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
|
||||
hailAt: (t) => active.hailAt(t), // SPRINT5 decision 13 — the garden score hangs off this
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
setShelters(list) {
|
||||
@ -167,6 +189,7 @@ function createWindRouter(all) {
|
||||
})));
|
||||
},
|
||||
|
||||
get hailSize() { return active.hailSize; }, // SPRINT5 decision 13
|
||||
get duration() { return active.duration; },
|
||||
get gusts() { return active.gusts; },
|
||||
get def() { return active.def; },
|
||||
@ -515,12 +538,23 @@ export async function boot(opts = {}) {
|
||||
sky?.step(dt, windT, { sail: rig });
|
||||
rigging.update(dt, windT);
|
||||
|
||||
// Decision 7: what kills the garden is rain that the sail didn't stop.
|
||||
// Only during the storm — the calm day's drizzle is scenery.
|
||||
// Decision 7: what hurts the garden is weather the sail didn't stop. Only
|
||||
// during the storm — the calm day's drizzle is scenery.
|
||||
//
|
||||
// Through Lane C's combined helper rather than my own rain × (1 − shadow):
|
||||
// it's the same arithmetic, it's their term to own, and SPRINT5 decision 13
|
||||
// extends exactly this shape (+ gardenHailExposure) — so when hail lands
|
||||
// this is one added term here, not a rewrite.
|
||||
if (game.phase === 'storm') {
|
||||
const rain = wind.rainAt(windT);
|
||||
const dry = sky?.rainShadowOver ? sky.rainShadowOver(world.gardenBed) : 0;
|
||||
garden.step(dt, rain, dry);
|
||||
// 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;
|
||||
garden.step(dt, hailExp * 5.0 + rainExp * 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -130,6 +130,89 @@ function rainVelocity(w, intensity, out) {
|
||||
return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
|
||||
}
|
||||
|
||||
// Hail falls STEEP, and that is the whole of decision 13. A 1 cm stone's
|
||||
// terminal velocity is ~22 m/s (a raindrop's is ~9), and a dense stone couples
|
||||
// only weakly to the crosswind, so even a 30 m/s gale leans it no more than
|
||||
// ~20° off vertical — where a sail overhead still blocks it. (Rain at 9 m/s in
|
||||
// the same gale comes in at atan(30/9) ≈ 73°, nearly sideways, which is why it
|
||||
// walks under the sail and can't score the rig.) Do NOT re-open the rain-angle
|
||||
// argument here; steep is the point.
|
||||
const HAIL_FALL = 22; // m/s terminal, ~1 cm ice
|
||||
const HAIL_LEAN_COUPLING = 0.3; // dense stones catch little wind
|
||||
const HAIL_MAX_LEAN = Math.tan(20 * Math.PI / 180); // cap ~20° off vertical
|
||||
|
||||
/** Hail velocity, m/s. Steep — see the note above. Used by stones and shadow. */
|
||||
function hailVelocity(w, out) {
|
||||
const cap = HAIL_FALL * HAIL_MAX_LEAN;
|
||||
let hx = w.x * HAIL_LEAN_COUPLING, hz = w.z * HAIL_LEAN_COUPLING;
|
||||
const mag = Math.hypot(hx, hz);
|
||||
if (mag > cap) { const s = cap / mag; hx *= s; hz *= s; }
|
||||
return out.set(hx, -HAIL_FALL, hz);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hail
|
||||
// Instanced falling stones, same wrap-around-the-camera trick as the rain but
|
||||
// fewer, whiter, faster and much steeper. Stones under the cloth are hidden so
|
||||
// you SEE the sail doing its job. Count scales with intensity; a hail-free storm
|
||||
// draws nothing.
|
||||
function createHail(opts) {
|
||||
const max = opts.maxStones ?? 1300;
|
||||
const half = opts.half ?? 16;
|
||||
const height = opts.height ?? 22;
|
||||
const groundY = opts.groundY ?? 0;
|
||||
const rand = rng(0x4a11);
|
||||
|
||||
const geo = new THREE.BoxGeometry(0.05, 0.05, 0.05); // a little cube reads as a stone
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xeaf2ff, transparent: true, opacity: 0.9, depthWrite: false, fog: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geo, mat, max);
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = 3;
|
||||
mesh.count = 0;
|
||||
|
||||
const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
|
||||
const jit = new Float32Array(max);
|
||||
for (let i = 0; i < max; i++) {
|
||||
px[i] = (rand() * 2 - 1) * half;
|
||||
py[i] = groundY + rand() * height;
|
||||
pz[i] = (rand() * 2 - 1) * half;
|
||||
jit[i] = 0.85 + rand() * 0.3;
|
||||
}
|
||||
|
||||
const m = new THREE.Matrix4();
|
||||
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
|
||||
const top = groundY + height;
|
||||
|
||||
return {
|
||||
mesh,
|
||||
step(dt, camPos, vel, intensity, size, shadow) {
|
||||
const n = Math.floor(max * clamp01(intensity));
|
||||
mesh.count = n;
|
||||
if (n === 0) return;
|
||||
const s = 0.6 + size * 0.9; // bigger stones read bigger
|
||||
m.makeScale(s, s, s);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = jit[i];
|
||||
px[i] += vel.x * j * dt;
|
||||
py[i] += vel.y * j * dt; // vel.y is negative
|
||||
pz[i] += vel.z * j * dt;
|
||||
let d = px[i] - camPos.x;
|
||||
if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
|
||||
d = pz[i] - camPos.z;
|
||||
if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
|
||||
if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height;
|
||||
if (shadow && shadow.occluded(px[i], py[i], pz[i])) { mesh.setMatrixAt(i, HIDDEN); continue; }
|
||||
m.elements[12] = px[i]; m.elements[13] = py[i]; m.elements[14] = pz[i];
|
||||
mesh.setMatrixAt(i, m);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
},
|
||||
dispose() { geo.dispose(); mat.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rain
|
||||
function createRain(opts) {
|
||||
const max = opts.maxDrops ?? 3000;
|
||||
@ -264,6 +347,7 @@ function createAudio(seed = 1) {
|
||||
let windGain, windFilter, windHowl, howlGain;
|
||||
let rainGain, rainFilter;
|
||||
let gustGain, gustFilter;
|
||||
let hailGain, hailFilter, drumGain, drumFilter;
|
||||
let noiseBuf = null;
|
||||
let creakNext = 0, flogNext = 0;
|
||||
let started = false;
|
||||
@ -344,6 +428,22 @@ function createAudio(seed = 1) {
|
||||
gustGain.connect(master);
|
||||
loop(noiseBuf, gustGain, gustFilter);
|
||||
|
||||
// hail clatter: bright, hard highpass — ice on concrete
|
||||
hailGain = ctx.createGain(); hailGain.gain.value = 0;
|
||||
hailFilter = ctx.createBiquadFilter();
|
||||
hailFilter.type = 'highpass'; hailFilter.frequency.value = 3000;
|
||||
hailGain.connect(master);
|
||||
loop(noiseBuf, hailGain, hailFilter);
|
||||
|
||||
// the DRUM: hail on taut cloth, a low resonant thrum. This is the "my sail
|
||||
// is earning its money" sound — it only speaks when the sail is actually
|
||||
// catching hail, so a rig over the bed sounds different from bare sky.
|
||||
drumGain = ctx.createGain(); drumGain.gain.value = 0;
|
||||
drumFilter = ctx.createBiquadFilter();
|
||||
drumFilter.type = 'bandpass'; drumFilter.frequency.value = 140; drumFilter.Q.value = 3;
|
||||
drumGain.connect(master);
|
||||
loop(noiseBuf, drumGain, drumFilter);
|
||||
|
||||
started = true;
|
||||
},
|
||||
|
||||
@ -379,6 +479,23 @@ function createAudio(seed = 1) {
|
||||
rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} intensity 0..1 hail
|
||||
* @param {number} onCloth 0..1 of the hail the sail overhead is catching
|
||||
* @param {number} size stone-size scalar — bigger stones drum lower
|
||||
*/
|
||||
setHail(intensity, onCloth, size) {
|
||||
if (!started) return;
|
||||
const now = ctx.currentTime;
|
||||
// clatter fades as the cloth intercepts more of the storm — some ice still
|
||||
// reaches the ground past the sail, but the open-ground roar drops
|
||||
hailGain.gain.setTargetAtTime(intensity * (1 - onCloth * 0.7) * 0.3, now, 0.15);
|
||||
hailFilter.frequency.setTargetAtTime(2400 + (2 - size) * 700, now, 0.2);
|
||||
// the drum rises exactly as the sail catches hail — the payoff sound
|
||||
drumGain.gain.setTargetAtTime(intensity * onCloth * 0.5, now, 0.12);
|
||||
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
|
||||
},
|
||||
|
||||
/** Telegraph cue: you hear it coming before you feel it. */
|
||||
whoosh(power, eta) {
|
||||
if (!started) return;
|
||||
@ -456,6 +573,21 @@ export function createSkyFx(o = {}) {
|
||||
const rainDir = new THREE.Vector3();
|
||||
let shadowTick = 0;
|
||||
|
||||
// hail rides its own steep shadow — that's the whole of decision 13: the sail
|
||||
// blocks steep hail (shadow ≈ its footprint) where it can't block slanted rain.
|
||||
const hail = createHail({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(hail.mesh);
|
||||
const hailShadow = new RainShadow({ groundY: o.groundY ?? 0 });
|
||||
const hailDir = new THREE.Vector3();
|
||||
const hailWind = new THREE.Vector3();
|
||||
let hailTick = 0, hailAmt = 0;
|
||||
|
||||
// hailAt lives behind the wind router (Lane A's allowlist). If a stale router
|
||||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
|
||||
// cloud dome rides the camera so it can't clip the far plane whatever Lane A set
|
||||
@ -502,8 +634,10 @@ export function createSkyFx(o = {}) {
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, audio, dome, shadow,
|
||||
rain, audio, dome, shadow, hailShadow,
|
||||
get flash() { return flash; },
|
||||
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
|
||||
get hailAmount() { return hailAmt; },
|
||||
|
||||
/**
|
||||
* 0..1 of a ground rect the sail is keeping dry, right now.
|
||||
@ -540,6 +674,27 @@ export function createSkyFx(o = {}) {
|
||||
return rain * (1 - shadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** 0..1 of a rect the sail is keeping hail off, this frame (steep shadow). */
|
||||
hailShadowOver(rect) { return hailShadow.fractionOver(rect); },
|
||||
|
||||
/**
|
||||
* Decision 13's garden-damage feed, 0..1, the gardenExposure mold but for
|
||||
* HAIL — this is what makes the garden score respond to the rig. Steep stones
|
||||
* mean the sail's shadow ≈ its footprint, so a rig over the bed genuinely
|
||||
* shelters it even in a gale, unlike rain (which walks under and can't score
|
||||
* the sail — the whole reason a perfect rig used to tie with no rig at all).
|
||||
*
|
||||
* hp -= sky.gardenHailExposure(bed, t) * HAIL_DAMAGE * dt
|
||||
* + sky.gardenExposure(bed, t) * SMALL_RAIN_DRAIN * dt; // A wires both
|
||||
*
|
||||
* 0 = no hail, or the cloth is catching it; 1 = full burst on the open bed.
|
||||
*/
|
||||
gardenHailExposure(rect, t) {
|
||||
const h = hailIntensity(t);
|
||||
if (h <= 0) return 0;
|
||||
return h * (1 - hailShadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
@ -644,8 +799,27 @@ export function createSkyFx(o = {}) {
|
||||
}
|
||||
rain.step(dt, camPos, w, intensity, shadow);
|
||||
|
||||
// --- hail ---
|
||||
hailAmt = hailIntensity(t);
|
||||
const stone = hailStone();
|
||||
// The hail shadow follows the STEEP hail vector, not the wind. It barely
|
||||
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
|
||||
hailTick -= dt;
|
||||
if (hailTick <= 0) {
|
||||
hailTick = 0.12;
|
||||
hailWind.set(w.x, 0, w.z);
|
||||
hailVelocity(hailWind, hailDir);
|
||||
const len = hailDir.length() || 1;
|
||||
hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len);
|
||||
}
|
||||
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
|
||||
// how much of the hail the sail overhead is catching, for the drum sound —
|
||||
// sample right above the player/camera so it's "is it drumming over ME"
|
||||
const onCloth = hailAmt > 0 ? hailShadow.fractionOver({ x: camPos.x, z: camPos.z, w: 3, d: 3 }) : 0;
|
||||
|
||||
// --- audio ---
|
||||
audio.setLevels(speed, intensity);
|
||||
audio.setHail(hailAmt, onCloth, stone);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
if (tg && tg !== lastTelegraph) {
|
||||
// fires once per gust, right as the telegraph opens
|
||||
@ -672,6 +846,7 @@ export function createSkyFx(o = {}) {
|
||||
dispose() {
|
||||
if (scene) {
|
||||
scene.remove(rain.mesh);
|
||||
scene.remove(hail.mesh);
|
||||
scene.remove(dome);
|
||||
scene.background = original.background;
|
||||
if (ownsFog) {
|
||||
@ -686,6 +861,7 @@ export function createSkyFx(o = {}) {
|
||||
if (sun) sun.intensity = original.sun;
|
||||
if (hemi) hemi.intensity = original.hemi;
|
||||
rain.dispose();
|
||||
hail.dispose();
|
||||
dome.geometry.dispose();
|
||||
dome.material.dispose();
|
||||
domeTex.dispose();
|
||||
|
||||
@ -7,8 +7,9 @@ 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 } from '../main.js';
|
||||
import { createGame, createWindRouter } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -50,6 +51,52 @@ export default async function run(t) {
|
||||
assertEq(checkContract('game', createGame()).join('; '), '');
|
||||
});
|
||||
|
||||
// --- the wind router -----------------------------------------------------
|
||||
|
||||
t.test('wind router forwards EVERYTHING the real wind exposes', async () => {
|
||||
// This exists because the omission it catches has already shipped once.
|
||||
//
|
||||
// Lane C added rainMmPerHour/rainDepthMm for ponding; main.js's router — a
|
||||
// hand-maintained delegation list — didn't forward them. Nothing went red:
|
||||
// every suite holds a real wind, and only the GAME holds the router. So
|
||||
// Lane B's ponding would have passed every assert it had and done nothing
|
||||
// in the actual yard, and it took the integrator noticing by hand at merge.
|
||||
//
|
||||
// The class of bug is worse than the instance. Sprint 5's headline system is
|
||||
// Lane C's hailAt(), and decision 13 hangs the entire garden score off it —
|
||||
// so the same silent swallow would make rigging look irrelevant to the
|
||||
// garden all over again, which is the exact thing this sprint exists to fix.
|
||||
// Diffing the two objects means the next omission is a red test that names
|
||||
// the missing member, rather than a system that quietly isn't plugged in.
|
||||
const real = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const router = createWindRouter([real]);
|
||||
|
||||
const missing = Object.keys(real).filter((k) => !(k in router));
|
||||
assert(missing.length === 0,
|
||||
`the wind router swallows ${missing.join(', ')} — add ${missing.length > 1 ? 'them' : 'it'} ` +
|
||||
`to createWindRouter in main.js, or the game silently runs without ${missing.length > 1 ? 'those' : 'that'}`);
|
||||
|
||||
// Present isn't enough — a forwarded method has to actually reach the wind.
|
||||
const wrongType = Object.keys(real).filter((k) => typeof real[k] === 'function' && typeof router[k] !== 'function');
|
||||
assert(wrongType.length === 0, `router has ${wrongType.join(', ')} but not as callable(s)`);
|
||||
});
|
||||
|
||||
t.test('wind router delegates live — use() re-points every consumer at once', async () => {
|
||||
// The router's whole reason to exist: consumers bind once at construction,
|
||||
// so a storm swap has to be a re-point rather than a re-wire.
|
||||
const gentle = createWind(await loadStorm('storm_01_gentle'));
|
||||
const wild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const router = createWindRouter([gentle, wild]);
|
||||
const at = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
router.use(gentle);
|
||||
const calm = router.speedAt(at, 60);
|
||||
router.use(wild);
|
||||
const gale = router.speedAt(at, 60);
|
||||
assertLess(calm, gale, 'swapping to the wild night must change what consumers read');
|
||||
assertEq(router.def, wild.def, 'def follows the active storm (skyfx reads it at construction)');
|
||||
});
|
||||
|
||||
// --- camera --------------------------------------------------------------
|
||||
|
||||
t.test('camera keeps a clear line to the player from every angle', () => {
|
||||
|
||||
206
web/world/js/tests/balance.test.js
Normal file
206
web/world/js/tests/balance.test.js
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* balance.test.js — is the game FAIR? [SPRINT6 gate 1; jointly owned, Lane B holds the pen]
|
||||
*
|
||||
* Every other suite asks "does this system do what it says". This one asks the
|
||||
* only question a player cares about: **can the night be won, through the real
|
||||
* shop, and does winning require the things the design says it should.**
|
||||
*
|
||||
* It is deliberately a browser suite. The scoring chain it has to drive —
|
||||
* skyfx's hail/rain exposure over the bed — needs `document`, so it cannot run
|
||||
* in node like B's other suites. Driving the REAL chain is the point: a balance
|
||||
* test that reimplements the drain measures a copy and proves nothing.
|
||||
*
|
||||
* The shape of every assert here is:
|
||||
* 1. build a loadout through RiggingSession, so the $80 shop is real money;
|
||||
* 2. fly the real storm JSON over an in-band quad from the real yard;
|
||||
* 3. integrate the real exposure helpers into the real garden drain;
|
||||
* 4. judge with main.js's own win rule (hp >= 50 && corners lost < 2).
|
||||
*
|
||||
* Measured 2026-07-18 on merged main (weights hail 5.0 / rain 0.25, drain 0.9,
|
||||
* downdraftOfTotal 0.45). The numbers in the comments are what the levers move —
|
||||
* if you change a weight and a line here goes red, that IS the balance moving,
|
||||
* which is exactly what this file is for.
|
||||
*/
|
||||
|
||||
import { RiggingSession } from '../rigging.js';
|
||||
import { SailRig } from '../sail.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
import { createWind, loadStorm } from '../weather.js';
|
||||
import { HARDWARE, FIXED_DT, START_BUDGET } from '../contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
|
||||
/** main.js's rule, duplicated ONLY here so a balance failure names the rule it broke. */
|
||||
const WIN = (hp, lost) => hp >= 50 && lost < 2;
|
||||
const GARDEN_DRAIN = 0.9; // main.js
|
||||
const W_HAIL = 5.0; // main.js, decision 13 — integration weights
|
||||
const W_RAIN = 0.25;
|
||||
|
||||
/**
|
||||
* The yard is READ FROM world.js, never copied.
|
||||
*
|
||||
* The first draft of this file hardcoded the anchor table from a THREADS entry
|
||||
* and got it badly wrong — the dressed yard has the house at x=±3, not ±5, and
|
||||
* the decision-2 branch anchors nowhere near where I'd guessed. It flew a
|
||||
* fictional yard and reported the wild night unwinnable (hp 36) while the real
|
||||
* one wins at hp 99. That is the same failure as Sprint 3's 16.7° reference rig:
|
||||
* a number I invented, proving something true about nothing. A balance suite in
|
||||
* particular cannot afford it — the yard IS the balance. So: build the real
|
||||
* world, take its anchors, and freeze only the sway.
|
||||
*
|
||||
* Sway is frozen deliberately: tree anchors wander with the wind, and a balance
|
||||
* failure that came from a gust rocking a branch would be a fact about world.js,
|
||||
* not about whether the shop can buy a winnable rig.
|
||||
*/
|
||||
async function buildYard() {
|
||||
const THREE = await import('../../vendor/three.module.js');
|
||||
const { createWorld } = await import('../world.js');
|
||||
const scene = new THREE.Scene();
|
||||
const calm = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(scene, { wind: calm });
|
||||
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
|
||||
const anchors = world.anchors.map((a) => {
|
||||
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
|
||||
return { id: a.id, type: a.type, pos, sway: () => pos };
|
||||
});
|
||||
return { anchors, bed: world.gardenBed, heightAt: world.heightAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* THE LINE for storm_02: the best bed-covering quad the dressed yard offers
|
||||
* (~41 m², ~63% of the bed). Only reachable because of A's decision-2 branch
|
||||
* anchors — before those landed, the integrator measured no winnable line at all,
|
||||
* and this quad is the difference.
|
||||
*/
|
||||
const COVER_QUAD = ['p1', 't1b', 't1c', 't2b'];
|
||||
/** A rig that holds fine and shades nothing — the decision-13 control. */
|
||||
const MISS_QUAD = ['h1', 'h2', 'h3', 't1'];
|
||||
|
||||
/** Buy a loadout through the real shop. Returns null if $80 doesn't stretch to it. */
|
||||
function shop(yard, ids, hw, spares = 0, tension = 0.9) {
|
||||
const s = new RiggingSession({ anchors: yard.anchors });
|
||||
for (const id of ids) if (!s.rig(id).ok) return null;
|
||||
for (let i = 0; i < ids.length; i++) if (!s.setHardware(ids[i], hw[i]).ok) return null;
|
||||
if (spares && !s.setSpares(spares).ok) return null;
|
||||
s.setTension(tension);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly a bought loadout through a storm and score it exactly as the game does.
|
||||
* @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}}
|
||||
*/
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
const rig = session.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
const sky = createSkyFx({ wind, night: true });
|
||||
|
||||
let hp = 100, pond = 0, used = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = i * FIXED_DT;
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
sky.step(FIXED_DT, t, { sail: rig });
|
||||
|
||||
// main.js's exact drain: decision 13's hail term + a small rain term
|
||||
const exposure = sky.gardenHailExposure(yard.bed, t) * W_HAIL + sky.gardenExposure(yard.bed, t) * W_RAIN;
|
||||
if (exposure > 0) hp = Math.max(0, hp - GARDEN_DRAIN * exposure * FIXED_DT);
|
||||
|
||||
const m = rig.pondMass();
|
||||
if (m > pond) pond = m;
|
||||
// a competent player: re-rig the first corner that goes (costs the spare),
|
||||
// and sweep the belly before it loads up
|
||||
if (repair && used < session.spares) {
|
||||
const k = rig.corners.findIndex((c) => c.broken);
|
||||
if (k >= 0) { rig.repair(k); used++; }
|
||||
}
|
||||
if (broom && m > 250) {
|
||||
const c = rig.pondCentroid();
|
||||
if (c) rig.drainPondAt(c.node, FIXED_DT, 3);
|
||||
}
|
||||
}
|
||||
sky.dispose?.();
|
||||
return {
|
||||
hp: Math.round(hp),
|
||||
lost: rig.corners.filter((c) => c.broken).length,
|
||||
spent: START_BUDGET - session.budget,
|
||||
pond: Math.round(pond),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../testkit.js').Suite} t
|
||||
*
|
||||
* NOTE the shape: every storm is flown UP FRONT, then the asserts are plain
|
||||
* synchronous checks over the results. `Suite.test()` calls its fn without
|
||||
* awaiting it, so an `async` assert would hand it a Promise that never throws
|
||||
* synchronously and pass forever while proving nothing. `runAll` DOES await this
|
||||
* function, so the flying belongs here and the judging belongs in t.test().
|
||||
*/
|
||||
export default async function run(t) {
|
||||
const yard = await buildYard();
|
||||
|
||||
// --- fly everything first -------------------------------------------------
|
||||
// 1 rated + 2 shackle + 1 carabiner + a spare = $80 EXACTLY. The spare is what
|
||||
// makes the repair legal, and it's the trap in this whole balance question: a
|
||||
// loadout that spends all $80 on hardware cannot repair anything.
|
||||
const lineShop = shop(yard, COVER_QUAD, [RATED, SHACKLE, SHACKLE, CARABINER], 1);
|
||||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { repair: true, broom: true }) : null;
|
||||
|
||||
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
|
||||
|
||||
const missShop = shop(yard, MISS_QUAD, [RATED, RATED, SHACKLE, CARABINER], 0);
|
||||
const miss = missShop ? await fly(yard, missShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null;
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
t.test('balance: storm_02 HAS a winnable line through the real $80 shop', () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
throw new Error(`the wild night has no winnable line: the best $${line.spent} loadout on the ` +
|
||||
`best bed-covering quad ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2)`);
|
||||
}
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
|
||||
});
|
||||
|
||||
t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {
|
||||
if (!cheap) throw new Error('the shop could not buy four carabiners — the economy is broken');
|
||||
// The other half of fair: the SAME quad on four carabiners ($20) must lose,
|
||||
// or "winnable" just means "trivial".
|
||||
if (WIN(cheap.hp, cheap.lost)) {
|
||||
throw new Error(`four $5 carabiners won the wild night (hp=${cheap.hp}, lost=${cheap.lost}) — ` +
|
||||
`hardware choice has stopped mattering`);
|
||||
}
|
||||
return `$${cheap.spent} of carabiners -> hp ${cheap.hp}, ${cheap.lost}/4 lost — correctly punished`;
|
||||
});
|
||||
|
||||
t.test('balance: a rig that misses the bed does not save the garden', () => {
|
||||
if (!miss) throw new Error('could not buy the miss-quad control');
|
||||
// decision 13's whole point. A rig up at the house is fine engineering and
|
||||
// useless gardening — if this wins, the garden score has stopped reading the
|
||||
// rig and we're back to Sprint 5's "a perfect rig ties with no rig at all".
|
||||
if (WIN(miss.hp, miss.lost)) {
|
||||
throw new Error(`a rig with no bed coverage won the night (hp=${miss.hp}) — the garden score ` +
|
||||
`is not reading the rig`);
|
||||
}
|
||||
return `${4 - miss.lost}/4 corners held but the bed was open -> hp ${miss.hp} — coverage is what scores`;
|
||||
});
|
||||
|
||||
t.test('balance: storm_01 is a warm-up anyone wins', () => {
|
||||
if (!gentle) throw new Error('could not buy the gentle-day control');
|
||||
if (!WIN(gentle.hp, gentle.lost)) {
|
||||
throw new Error(`the gentle day beat a $${gentle.spent} rig (hp=${gentle.hp}, ` +
|
||||
`lost=${gentle.lost}) — storm_01 is the tutorial, it must not punish`);
|
||||
}
|
||||
return `$${gentle.spent} of carabiners survives the gentle day -> hp ${gentle.hp}`;
|
||||
});
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
@ -265,6 +266,79 @@ export default async function run(t) {
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
|
||||
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
|
||||
// because honest rain walks under a sail. Steep hail doesn't — a sail over the
|
||||
// bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good
|
||||
// rig's, over the real storm_02 hail, through the real gardenHailExposure.
|
||||
t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => {
|
||||
const bed = { x: 1, z: 2, w: 6, d: 4 };
|
||||
|
||||
// a good rig: a quad sitting over the bed, held on rated shackles
|
||||
const s = 3.6;
|
||||
const anchors = [
|
||||
{ id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||||
{ id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } },
|
||||
];
|
||||
const rig = new SailRig({ anchors, gridN: 10 });
|
||||
rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0);
|
||||
|
||||
const damageOverStorm = (getWorld, rigToStep) => {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
camera.position.set(0, 6, 8);
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind });
|
||||
let dmg = 0;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
if (rigToStep) rigToStep.step(dt, wind, time);
|
||||
sky.step(dt, time, getWorld());
|
||||
dmg += sky.gardenHailExposure(bed, time) * dt;
|
||||
});
|
||||
const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0;
|
||||
sky.dispose();
|
||||
return { dmg, cover };
|
||||
};
|
||||
|
||||
const open = damageOverStorm(() => ({}), null);
|
||||
const covered = damageOverStorm(() => ({ sail: rig }), rig);
|
||||
|
||||
// sanity: the rig must actually be over the bed, or this proves nothing
|
||||
assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`);
|
||||
assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting');
|
||||
const ratio = open.dmg / Math.max(1e-6, covered.dmg);
|
||||
assert(ratio >= 2,
|
||||
`open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`);
|
||||
});
|
||||
|
||||
t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind });
|
||||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||||
// a flat panel well above the bed
|
||||
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||||
|
||||
// storm_01 has no hail: exposure is zero regardless of cover
|
||||
const calm = createWind(storms.storm_01_gentle);
|
||||
const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm });
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {}));
|
||||
assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure');
|
||||
skyCalm.dispose();
|
||||
|
||||
// at the wild night's hail peak, open bed is exposed; a panel over it is not
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {}));
|
||||
const openExp = sky.gardenHailExposure(bed, 56);
|
||||
assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`);
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
|
||||
const coveredExp = sky.gardenHailExposure(bed, 56);
|
||||
assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`);
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
t.test('every storm in data/storms/ loads and validates', () => {
|
||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
||||
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -504,6 +504,101 @@ export function weatherCases(storms) {
|
||||
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
|
||||
});
|
||||
|
||||
// ---- 11. hail (SPRINT5 decision 13) ----
|
||||
// The wind-side of hail: intensity timeline and its ramp across the campaign.
|
||||
// The garden-damage half (steep shadow, ≥2× rig response) needs skyfx +
|
||||
// SailRig, so it lives in c.test.js.
|
||||
test('hail ramps across the campaign: none / mild / a wild-night burst', () => {
|
||||
const peakAndArea = (def) => {
|
||||
const f = createWindField(def);
|
||||
let peak = 0, peakT = 0, area = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const h = f.hailAt(t);
|
||||
if (h > peak) { peak = h; peakT = t; }
|
||||
area += h * DT;
|
||||
}
|
||||
return { peak, peakT, area };
|
||||
};
|
||||
const g = peakAndArea(storms.storm_01_gentle);
|
||||
const m = peakAndArea(storms.storm_03_southerly);
|
||||
const w = peakAndArea(storms.storm_02_wildnight);
|
||||
metrics['storm_01.hailPeak'] = +g.peak.toFixed(2);
|
||||
metrics['storm_03.hailPeak'] = +m.peak.toFixed(2);
|
||||
metrics['storm_02.hailPeak'] = +w.peak.toFixed(2);
|
||||
metrics['storm_02.hailSeconds'] = +w.area.toFixed(1);
|
||||
|
||||
assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`);
|
||||
assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`);
|
||||
assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`);
|
||||
assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm');
|
||||
});
|
||||
|
||||
test('storm_02 hail lands ON the southerly change', () => {
|
||||
const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t;
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
// find the authored-burst peak (near the change), not just any gust-synced tick
|
||||
let peak = 0, peakT = 0;
|
||||
for (let t = change - 3; t <= change + 8; t += DT) {
|
||||
const h = f.hailAt(t);
|
||||
if (h > peak) { peak = h; peakT = t; }
|
||||
}
|
||||
assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`);
|
||||
assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`);
|
||||
});
|
||||
|
||||
test('hail is silent through a gust telegraph and rides its own gust', () => {
|
||||
// the gust-synced bursts must not fire during the telegraph window, or hail
|
||||
// would arrive before the gust it belongs to — the opposite of the drama
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def);
|
||||
const thresh = def.hail.withGustsAbove;
|
||||
for (const g of f.gusts) {
|
||||
if (g.pow < thresh) continue;
|
||||
// during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0.
|
||||
// an authored burst may still overlap, so only check gusts clear of t=55±8.
|
||||
if (Math.abs(g.t0 - 55) < 10) continue;
|
||||
for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) {
|
||||
assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hail does not re-time the storm (zero draws of its own)', () => {
|
||||
const base = storms.storm_02_wildnight;
|
||||
const a = createWindField(base);
|
||||
for (const gi of [0, 0.3, 0.85, 1]) {
|
||||
const d = JSON.parse(JSON.stringify(base));
|
||||
d.hail.gustBurstIntensity = gi;
|
||||
d.hail.bursts = []; // even removing the authored burst mustn't move gusts
|
||||
const b = createWindField(d);
|
||||
assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`);
|
||||
a.gusts.forEach((g, i) => {
|
||||
assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`);
|
||||
});
|
||||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind');
|
||||
}
|
||||
});
|
||||
|
||||
test('validator rejects broken hail blocks', () => {
|
||||
const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
const cases = [
|
||||
['size 0', (d) => { d.hail.size = 0; }],
|
||||
['size huge', (d) => { d.hail.size = 20; }],
|
||||
['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }],
|
||||
['negative fade', (d) => { d.hail.bursts[0].fade = -1; }],
|
||||
['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }],
|
||||
['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }],
|
||||
['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }],
|
||||
];
|
||||
for (const [label, mutate] of cases) {
|
||||
const d = base(); mutate(d);
|
||||
assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`);
|
||||
}
|
||||
// a hail-free storm (no block at all) must still validate
|
||||
const none = base(); delete none.hail;
|
||||
assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -167,6 +167,27 @@ export function buildGustTimeline(def, seed) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- hail (SPRINT5 decision 13) ----------
|
||||
// Hail, not rain, carries the garden score. Rain honestly walks under a sail
|
||||
// (droplets terminal ~9 m/s, so a 30 m/s crosswind blows them in at atan(30/9)
|
||||
// ≈ 73° off vertical — nearly sideways), which is why a perfect rig scored 54%
|
||||
// vs 48% for no rig at all. Hailstones are dense: terminal velocity ~20 m/s for
|
||||
// a 1 cm stone, so the SAME 30 m/s gale only leans them atan(30/20)≈56° — and
|
||||
// that overstates it, because a dense stone's drag couples weakly to the
|
||||
// horizontal air, so observed hail lean tops out ~15-20°. Steep hail is blocked
|
||||
// by overhead cloth even in a gale, so the garden score becomes rig-responsive
|
||||
// without faking the rain physics. The steepness lives in skyfx.hailVelocity();
|
||||
// the intensity timeline lives here.
|
||||
|
||||
/** Envelope of one authored hail burst at local time `dt` (s since it began). */
|
||||
export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
|
||||
if (dt <= 0) return 0;
|
||||
if (dt < ramp) return peak * (dt / ramp);
|
||||
if (dt < ramp + hold) return peak;
|
||||
if (dt < ramp + hold + fade) return peak * (1 - (dt - ramp - hold) / fade);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- the field ----------
|
||||
/**
|
||||
* @param {object} def parsed storm JSON (see data/storms/*.json)
|
||||
@ -189,6 +210,19 @@ export function createWindField(def, opts = {}) {
|
||||
const gd = def.gusts || {};
|
||||
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
|
||||
// Hail bursts: authored ones from the JSON, plus one synced to every gust at
|
||||
// or above `withGustsAbove` power — so the biggest gusts arrive WITH hail, the
|
||||
// storm's worst moment landing all at once. Gust-synced bursts key off the
|
||||
// deterministic gust timeline and draw NO randomness, so tuning hail can't
|
||||
// re-time the storm (same guarantee as the downdraft).
|
||||
const hailDef = def.hail || null;
|
||||
const hailBursts = (hailDef && Array.isArray(hailDef.bursts))
|
||||
? hailDef.bursts.map((b) => ({
|
||||
t: b.t, ramp: b.ramp ?? 0.8, hold: b.hold ?? 3, fade: b.fade ?? 1.5,
|
||||
intensity: b.intensity ?? 1,
|
||||
}))
|
||||
: [];
|
||||
|
||||
let shelters = [];
|
||||
|
||||
/** Spatially-uniform part: base curve + every gust envelope live at t. */
|
||||
@ -398,6 +432,42 @@ export function createWindField(def, opts = {}) {
|
||||
return Math.min(1, Math.max(0, r.intensity ?? 0));
|
||||
},
|
||||
|
||||
/**
|
||||
* 0..1 hail intensity at time t (SPRINT5 decision 13). Max over every live
|
||||
* burst — authored plus gust-synced — because "how hard is it hailing right
|
||||
* now" is a level, not a sum; two overlapping bursts don't hail at 1.6×.
|
||||
* Zero for a storm with no `hail` block, so storm_01 simply never hails.
|
||||
*/
|
||||
hailAt(t) {
|
||||
if (!hailDef) return 0;
|
||||
let h = 0;
|
||||
for (let i = 0; i < hailBursts.length; i++) {
|
||||
const b = hailBursts[i];
|
||||
if (t <= b.t || t >= b.t + b.ramp + b.hold + b.fade) continue;
|
||||
const v = hailBurstEnvelope(t - b.t, b.ramp, b.hold, b.fade, b.intensity);
|
||||
if (v > h) h = v;
|
||||
}
|
||||
const thresh = hailDef.withGustsAbove;
|
||||
if (thresh != null) {
|
||||
const gi = hailDef.gustBurstIntensity ?? 0.8;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (g.pow < thresh) continue;
|
||||
if (t <= g.t0 || t >= g.endAt) continue;
|
||||
// reuse the gust envelope: silent through the telegraph, then it hits
|
||||
// as the gust ramps and holds — the hail lands WITH the gust.
|
||||
// gustEnvelope(gt, pow) returns pow×fraction, so passing gi as "pow"
|
||||
// gives the intensity-scaled 0..gi envelope directly.
|
||||
const v = gustEnvelope(t - g.t0, gi);
|
||||
if (v > h) h = v;
|
||||
}
|
||||
}
|
||||
return h > 1 ? 1 : h;
|
||||
},
|
||||
|
||||
/** Stone-size scalar (audio pitch, visual scale, damage weight). Default 1. */
|
||||
get hailSize() { return hailDef ? (hailDef.size ?? 1) : 0; },
|
||||
|
||||
/**
|
||||
* Rain rate in REAL-WORLD mm/hr. Same curve as rainAt(), with physical units
|
||||
* on it — `rainAt` stays 0..1 because it drives drop count and opacity, and a
|
||||
@ -520,5 +590,34 @@ export function validateStorm(def, name = 'storm') {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hail (SPRINT5 decision 13) ----
|
||||
if (def.hail) {
|
||||
const hd = def.hail;
|
||||
if (hd.size != null && (!Number.isFinite(hd.size) || hd.size <= 0 || hd.size > 5)) {
|
||||
bad(`hail.size must be a positive scalar up to ~5 (stones bigger than golf balls break the metaphor) — got ${hd.size}`);
|
||||
}
|
||||
if (hd.withGustsAbove != null && !Number.isFinite(hd.withGustsAbove)) {
|
||||
bad('hail.withGustsAbove must be a finite gust-power threshold in m/s');
|
||||
}
|
||||
if (hd.gustBurstIntensity != null && (!(hd.gustBurstIntensity >= 0) || hd.gustBurstIntensity > 1)) {
|
||||
bad(`hail.gustBurstIntensity must be 0..1 — got ${hd.gustBurstIntensity}`);
|
||||
}
|
||||
// A storm with a hail block but nothing to fire it never hails — that's a
|
||||
// typo, not a design, so say so rather than shipping silent hail.
|
||||
if (!Array.isArray(hd.bursts) && hd.withGustsAbove == null) {
|
||||
bad('hail block has neither bursts[] nor withGustsAbove — it would never hail');
|
||||
}
|
||||
for (const b of hd.bursts || []) {
|
||||
if (!Number.isFinite(b.t)) bad(`hail burst ${JSON.stringify(b)} has no finite t`);
|
||||
if (b.t + 0 > (def.duration ?? 90)) bad(`hail burst at t=${b.t} starts after the storm ends`);
|
||||
for (const k of ['ramp', 'hold', 'fade']) {
|
||||
if (b[k] != null && !(b[k] >= 0)) bad(`hail burst at t=${b.t} has a negative ${k}`);
|
||||
}
|
||||
if (b.intensity != null && (!(b.intensity >= 0) || b.intensity > 1)) {
|
||||
bad(`hail burst at t=${b.t} intensity must be 0..1 — got ${b.intensity}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@ -87,6 +87,12 @@ export function createWind(def, opts = {}) {
|
||||
/** 0..1 rain intensity — drives drop count and opacity. */
|
||||
rainAt(t) { return field.rainAt(t); },
|
||||
|
||||
/** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */
|
||||
hailAt(t) { return field.hailAt(t); },
|
||||
|
||||
/** Stone-size scalar — audio pitch, visual scale, damage weight. */
|
||||
get hailSize() { return field.hailSize; },
|
||||
|
||||
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
||||
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
||||
|
||||
|
||||
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 |
@ -52,6 +52,13 @@ for (const [letter, path] of [
|
||||
['C', './js/tests/c.test.js'],
|
||||
['D', './js/tests/d.test.js'],
|
||||
['E', './js/tests/e.test.js'],
|
||||
// SPRINT6 gate 1. Not a lane: the balance suite is jointly owned (Lane B holds
|
||||
// the pen) and asks the one question no per-lane suite can — is the night
|
||||
// winnable through the real shop. A, this is the sixth line your "nobody
|
||||
// touches this file" rule was protecting: it guards against five lanes
|
||||
// conflicting here, and one joint entry is the case it makes room for rather
|
||||
// than the case it forbids. Revert it if you'd rather own the wiring. — B
|
||||
['BAL', './js/tests/balance.test.js'],
|
||||
]) {
|
||||
try {
|
||||
const mod = await import(path);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user