Compare commits
12 Commits
966dda9590
...
0813b18f5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0813b18f5a | ||
|
|
f7d747a578 | ||
|
|
0b4dd0d0bd | ||
|
|
34712bf297 | ||
|
|
da35a954e0 | ||
|
|
ce8d22cb60 | ||
|
|
0ceff91d5a | ||
|
|
acb35752ee | ||
|
|
1f99cd9bca | ||
|
|
4bb0efcfd1 | ||
|
|
ea128e1307 | ||
|
|
7c699e776d |
@ -274,3 +274,59 @@ Read THREADS' last [I] entry (the dispute ruling) then SPRINT4.md — decisions
|
||||
> (the prop waits for the mechanic), fence_panel_snapped if not shipped, and
|
||||
> refresh the assembled-yard contact sheet for DESIGN.md — the yard finally
|
||||
> looks like the game.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 5 prompts (hail & the broom — fire all five)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 4
|
||||
merged; router passes rain data; downdraftOfTotal 0.45; decision 13 ruled).
|
||||
Read THREADS' last [I] entry then SPRINT5.md.
|
||||
|
||||
## Lane A — Sprint 5
|
||||
> You are Lane A on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane A. Wire decision 13 (garden drain = C's hail exposure helper + small
|
||||
> rain term; aftermath adds "hail blocked", verdicts re-tuned), pond warnings
|
||||
> in the HUD off B's pondMass(), take D's greyed-prompt surface (label +
|
||||
> reason) into hud.js, and the small carried bits: sway_amp/sway_phase canopy
|
||||
> handles, C's router-contract tripwire, answer B's panel question (keep their
|
||||
> panel). Keep 60 fps with hail + rain + ponding live. Shepherd as always.
|
||||
|
||||
## Lane B — Sprint 5
|
||||
> You are Lane B on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane B — the water arc has been carried twice and everything now waits on
|
||||
> it. Ponding v1 from your reverted prototype + C's landed data (use exported
|
||||
> RAIN_TIME_COMPRESSION, never hardcode 40): accumulation × flatness → node
|
||||
> water mass → weight; pondMass() + pond centroid; dump on corner break and
|
||||
> belly-tipping tension change. Asserts: hypar pools nothing, flat rig dies of
|
||||
> water alone in storm_02, storm_01 harmless, mass conserves until dumped.
|
||||
> Then drainPondAt(node) for D's broom (agree the shape in THREADS early),
|
||||
> session.reset() for A, and the tn-1.04 stability clamp (post §7 deltas
|
||||
> before landing).
|
||||
|
||||
## Lane C — Sprint 5
|
||||
> You are Lane C on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane C — hail is yours and it's decision 13's engine. Storm JSON hail
|
||||
> blocks (bursts timed with big gusts, validator, determinism), hailAt(t) +
|
||||
> a STEEP fall vector (~15-20° max lean, cite why), sky.gardenHailExposure in
|
||||
> your gardenExposure mold, hail audio (drum on cloth, clatter on ground) and
|
||||
> cheap visuals, and the decision-13 assert: no-sail garden damage ≥2× a good
|
||||
> rig's in storm_02. storm_02 gets its burst at the change; storm_03 mild;
|
||||
> storm_01 none.
|
||||
|
||||
## Lane D — Sprint 5
|
||||
> You are Lane D on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane D — the broom is yours and it's the gate: take broom (hands-full rules
|
||||
> vs spare/ladder), walk under the belly, hold-E poke (Crank/Dig per E's
|
||||
> anim_hint) → B's drainPondAt() → the water dumps ON YOU (stagger if big —
|
||||
> comedy is the point) and the sail springs back. Also surface unusable
|
||||
> prompts greyed-with-reason into A's HUD (your offer), and run a feel pass on
|
||||
> the full loop with hail + ponding live. Log everything in THREADS.
|
||||
|
||||
## Lane E — Sprint 5
|
||||
> You are Lane E on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
|
||||
> §Lane E. Small juice pass: hail impact pips + ground ring decal
|
||||
> (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.
|
||||
|
||||
115
SPRINT5.md
Normal file
115
SPRINT5.md
Normal file
@ -0,0 +1,115 @@
|
||||
# SPRINT 5 — HAIL & THE BROOM (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 4 verdict: the game has a face and the longest argument in the repo is
|
||||
closed — B conceded decision 11 with numbers and a post-mortem, storm_02 runs
|
||||
downdraftOfTotal 0.45, the ladder loop landed whole, and you can play a round
|
||||
start to finish with a mouse. The face immediately earned its keep by exposing
|
||||
the real problem: a PERFECT rig scores 54% garden vs 48% for not turning up.
|
||||
The sail cannot be scored on rain, because C's weather is honest: driving rain
|
||||
walks under a sail. Sprint 5 makes the garden score mean something (hail), and
|
||||
ships the water arc DESIGN.md promised (ponding + the broom).*
|
||||
|
||||
Read THREADS from the last [I] entry. Standing items you can rely on: the wind
|
||||
router now passes rainMmPerHour/rainDepthMm; C's ponding data is calibrated to
|
||||
B's arithmetic (storm_02 = 3.12 kN/corner of water on a flat 25 m² rig, exactly
|
||||
the kill number); E's pond/broom assets are on disk with recipes.
|
||||
|
||||
## Decision
|
||||
|
||||
13. **Hail carries storm garden damage; rain demotes to a small drain.**
|
||||
Hailstones are dense and fall fast and steep — overhead cloth blocks them
|
||||
even in wind, so the garden score becomes rig-responsive WITHOUT faking the
|
||||
rain physics. This is also canon: DESIGN.md always said hail shreds gardens
|
||||
and drainage (future content) answers rain. Aftermath keeps garden % as a
|
||||
headline but it must now respond: target ≥2× garden damage for no-sail vs
|
||||
a good rig in a hail-bearing storm, stated as an assert.
|
||||
|
||||
## Lane C — hail (the sprint's new system)
|
||||
|
||||
1. Storm JSON: `hail` block — bursts (timed like gust events, the big ones
|
||||
arriving WITH gusts for maximum drama), stone intensity/size scalar,
|
||||
validator. storm_02 gets a proper hail burst at the change; storm_03 a mild
|
||||
one; storm_01 none. Determinism rules as ever (own stream or zero draws).
|
||||
2. `hailAt(t)` intensity + a fall vector that's STEEP (slight wind lean only —
|
||||
stones are dense; ~15-20° max off vertical at gale speeds, cite the number
|
||||
in a comment so nobody re-litigates rain-angle here).
|
||||
3. Garden damage feed: `sky.gardenHailExposure(bed, t)` in the gardenExposure
|
||||
mold (hailAt × (1 − hail-angle shadow over bed)). A wires the drain.
|
||||
4. Audio + visuals: hail layer (on cloth = drum, on ground = clatter — the
|
||||
cloth drum IS the "my sail is earning its money" sound), instanced stones
|
||||
or impact pips, your call on cost. Lightning already lands on big gusts.
|
||||
5. Assert per decision 13: no-sail garden damage ≥2× good-rig damage in
|
||||
storm_02, using B's SailRig over your hail feed.
|
||||
|
||||
## Lane B — the water arc, finish it (carried twice, now everything waits on you)
|
||||
|
||||
1. **Ponding v1** — your reverted prototype + C's landed data (`rainMmPerHour`,
|
||||
`RAIN_TIME_COMPRESSION` exported so nobody hardcodes 40 twice): accumulation
|
||||
× per-node flatness → node water mass → weight in step(); `pondMass()` and
|
||||
per-pond centroid for HUD/visuals; dump on corner break and on tension
|
||||
change that tips the belly. Asserts: hypar pools ~nothing; a flat rig DIES
|
||||
of water alone in storm_02; storm_01 cannot hurt anyone; mass conserves
|
||||
until dumped.
|
||||
2. **`drainPondAt(node)` API for Lane D's broom** — poke transfers/dumps water
|
||||
locally over ~1.5 s. Coordinate the shape with D in THREADS early — the
|
||||
broom is this sprint's gate and you two meet in the middle.
|
||||
3. `session.reset()` (A's ask — they're reaching into your state machine to
|
||||
fake it). Small.
|
||||
4. Stability clamp for the tn-1.04 cliff (carried; if it moves §7 numbers,
|
||||
post before landing).
|
||||
|
||||
## Lane D — the broom (DESIGN.md's funniest correct mechanic)
|
||||
|
||||
1. Broom loop: take broom from shed wall (E's `broom_01`, `grip_anchor`,
|
||||
carry_type broom — hands-full rules vs spare/ladder apply), walk under the
|
||||
belly, hold-E poke (reuse Crank/Dig per E's anim_hint) → B's
|
||||
`drainPondAt()` → water dumps ON YOU (knock/stagger if the pond is big —
|
||||
your knockdown machinery, the comedy is the point), sail springs back.
|
||||
2. Surface unusable prompts greyed with reasons (your offer + A's HUD hook —
|
||||
"hands full", "out of reach — needs the ladder"). Kills the
|
||||
vanishing-prompt confusion you logged.
|
||||
3. Feel pass on the full loop with hail + ponding live; log notes. You're
|
||||
still the only lane that plays like a player.
|
||||
|
||||
## Lane A — score truth & polish
|
||||
|
||||
1. Wire decision 13: garden drain = hail exposure (C's helper) + small rain
|
||||
drain; aftermath keeps garden % headline + adds "hail blocked" line;
|
||||
verdicts re-tuned so the words track the new numbers.
|
||||
2. Pond HUD: pond mass warning on the corner bars' sail (B's `pondMass()`),
|
||||
"SAIL PONDING — get the broom" ticker line at threshold.
|
||||
3. Take D's greyed-prompt surface into hud.js (they provide label + reason).
|
||||
4. Small carried bits: E's `sway_amp`/`sway_phase` canopy handles, the router
|
||||
contract tripwire C suggested, B's panel question (answer: keep B's panel,
|
||||
you own everything else).
|
||||
5. Shepherd as always; keep the assembled game at 60 fps with hail + rain +
|
||||
ponding all live (you have ~16 ms of headroom; spend some, keep most).
|
||||
|
||||
## Lane E — hail & water juice (small)
|
||||
|
||||
1. Hail impact pips on cloth + ground decal ring (cheap, instanced-friendly),
|
||||
stone texture/mesh if C wants geometry over sprites.
|
||||
2. Plant shred particles when the bed takes hail (the tattered/dead swaps
|
||||
exist; a puff of green when a burst lands sells it).
|
||||
3. Contact-sheet refresh with the dressed night yard + a daylight one for
|
||||
DESIGN.md (carried).
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 1: ponding kills a flat rig in storm_02 (assert + by hand),
|
||||
and the broom SAVES it (by hand, water dumped on the player's head)
|
||||
gate 2: decision-13 assert green — rigging visibly matters to the garden
|
||||
gate 3: full round where EVERY aftermath line responds to a player choice
|
||||
(garden ← hail shadow, corners ← rigging, hardware ← breaks/repairs,
|
||||
collateral ← gnome/fence, budget ← shop) — played, screenshotted
|
||||
```
|
||||
|
||||
Definition of done = gate 3. After this, systems are DONE — Sprint 6 is
|
||||
content: sites, jobs, money across rounds, the landscaper campaign.
|
||||
|
||||
## For John
|
||||
|
||||
- A left you a cue in THREADS and it stands: play a round
|
||||
(`python3 server.py`, pick WILD NIGHT), then write three sentences in
|
||||
THREADS about what felt wrong. Sprint 6's shape depends on them.
|
||||
275
THREADS.md
275
THREADS.md
@ -1232,3 +1232,278 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
represents a whole night of story, so a storm delivers a night's water; that's the time-compression
|
||||
fiat B asked someone to own. B's finding that ponding cannot pincer the twisted rig (a hypar has no
|
||||
flat to pool in) is exactly why it's the right mechanism.
|
||||
|
||||
[E] 2026-07-17 — ❌ **RETRACTION — my "worst corner 417.7" flag last sprint was a false alarm, ignore it.**
|
||||
`corner.load` is in **newtons** (sail.js's own header says so: "a 5×5 m sail in a 34 m/s storm genuinely
|
||||
puts ~1–4 kN on a corner"). So 417.7 N is 0.42 kN — a perfectly healthy pre-tension load on a rigged
|
||||
sail, not B's cascade. I read a newton value as if it were alarming and posted it without checking the
|
||||
unit. Nobody should spend a minute chasing it. Apologies — deleting the claim rather than leaving it to
|
||||
rot in the log.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — pond visual (SPRINT4 §Lane E-1, decision 10). Two textures + the recipe.**
|
||||
`models/textures/pond_water.png` (256², RGBA) and `pond_normal.png` (256², seamless ripples). Same
|
||||
ride-the-nodes rule as the tears, same reason: a rigid disc added to the sail group sits still while
|
||||
the belly moves under it.
|
||||
· **Alpha is a radial feather**, so the pool dissolves into the cloth instead of ending on a hard
|
||||
rim, and **RGB darkens toward the middle** because that's where it's deep. Both of those are
|
||||
encoded *radially*, which is what lets you scale it per `pondMass()` without the shading going
|
||||
wrong at any size.
|
||||
· Sizing: for roughly constant depth, area ∝ mass, so **radius ∝ √pondMass** is your starting curve.
|
||||
· The normal map tiles (asserted in the build, same guard as the weave) — `RepeatWrapping`, repeat to
|
||||
taste, `normalScale` low (~0.3); it's a puddle, not the ocean.
|
||||
· Water is grey-green, not blue: rain caught in a sail is shallow, murky, and mostly mirrors an
|
||||
overcast sky. If it reads too drab against your cloth, say so and I'll lift it.
|
||||
256² not 512² on purpose: both are smooth low-frequency content, and at 512 they were 256 KB + 320 KB
|
||||
against ~20 KB for every other texture in the repo. No visible difference, quarter the bytes.
|
||||
|
||||
[E] 2026-07-17 — `broom_01_v1.glb` landed (§Lane E-2) — 0.39 × 0.06 × 1.42 m, `mass_hint` 1.2. Nodes
|
||||
`handle` / `head` / `bristles`, plus `grip_anchor` (two-thirds up, `carry_type="broom"`) and
|
||||
**`poke_tip` on the BRISTLE end** — Lane D, that's deliberate: a broomstick jabbed at a loaded sail
|
||||
puts a hole through it, and the soft end is the one a landscaper would actually use. Stands upright
|
||||
with the head on the ground, i.e. how it lives against the shed wall; rotate it to poke. Carries
|
||||
`anim_hint` = reuse Crank/Dig, no new Mixamo needed.
|
||||
|
||||
[E] 2026-07-17 — ⚠️ **RENAMED: `fence_panel_broken` → `fence_panel_snapped`.** SPRINT4 names it
|
||||
`fence_panel_snapped` in both §A-4 and §E-3; I'd shipped `fence_panel_broken` in Sprint 3. Lane A codes
|
||||
against SPRINT4, and `dress()` guards every load — so the mismatch wouldn't have crashed anything, the
|
||||
wreckage would just have **silently never appeared**, which is the worse failure. Canonical name is now
|
||||
`fence_panel_snapped_v1.glb`; old file deleted. `garden_gnome_01_broken` is unchanged and already
|
||||
matches. (Yes, the two suffixes are inconsistent — matching the spec Lane A reads beat matching my own
|
||||
naming.)
|
||||
|
||||
[E] 2026-07-17 — ✅ **Lane A — I booted the dressed yard and it's right.** house_yardside + both gums +
|
||||
shed + table all load; `fascia_anchor_01..03`, `gutter`, `wall`, `trunk`, `canopy` all present; 11
|
||||
anchors. Two things I checked specifically because I'd have been embarrassed to be wrong about them:
|
||||
· **The canopies sway.** They read `rotation 0,0` at t=0 (which briefly fooled me) but after 6 s of
|
||||
wind they're at 0.019 / 0.006 rad — you re-pointed the sway list at the GLB `canopy` groups on
|
||||
dress and the old graybox `canopy_0/1/2` are gone. The handle works.
|
||||
· **`userData` extras are live in production** — world.js:417 reads `rating_hint` and `collateral`
|
||||
off my nodes. That contract is real now, not just asserted.
|
||||
Not yet wired: `sway_amp` (0.85 on the big gum, 1.20 on the whippy one) and `sway_phase` — that's your
|
||||
§A-5 and the data is sitting in `userData` whenever you want it. Free readability: multiply your `lean`
|
||||
by `sway_amp` and take `sway_phase` instead of the hardcoded 0.7 / 2.9, and gum_02 starts showing gust
|
||||
fronts before gum_01 does.
|
||||
|
||||
[E] 2026-07-17 — 👀 art note, my asset, my call to flag rather than fix: in the dressed yard the tree
|
||||
**branch stubs read as coat hooks** — bare horizontal pegs off a bare trunk. They looked fine in
|
||||
isolation; they don't at yard scale. **I'm not touching them unprompted, because the `branch_anchor_*`
|
||||
tips are now live anchors** (A's 11, with rating hints) and moving them would shift A's yard and
|
||||
invalidate B's §7 measurements — exactly the churn I avoided in Sprint 2. I *can* taper and re-angle
|
||||
the limbs while pinning the tips so no anchor moves a millimetre. Say the word and it's a contained
|
||||
change; otherwise it waits for the content sprint.
|
||||
|
||||
[E] 2026-07-17 — 🔧 **TOOLING GAP, and it blocks §Lane E-4 (the assembled-yard picture for DESIGN.md).**
|
||||
There is no way to get a game screenshot to disk. The canvas is WebGL, so `toDataURL` returns 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 900×506 JPEG costs ~60 KB of base64 round-tripped through the harness, which is a
|
||||
ridiculous price for a picture. I've verified the yard by eye and it looks like the game (screenshot is
|
||||
in my session), but I'm not committing one this way.
|
||||
**The durable fix is ~5 lines and it isn't mine:** either a dev key in main.js that does
|
||||
`render(); canvas.toBlob(b => download(b))`, or a tiny POST handler in server.py writing to `docs/`.
|
||||
Lane A, if you'd rather I did it, say so and I'll take it as a Lane E chore next sprint — it's your
|
||||
file, and after that DESIGN.md gets a fresh picture whenever anyone presses a key.
|
||||
[C] 2026-07-17 — 🚨 **LANE A — TWO LINES, OR LANE B'S PONDING IS DEAD ON ARRIVAL IN THE GAME. Please read
|
||||
this one first.** `createWindRouter` in main.js is an explicit allowlist, and `rig.step(dt, wind, …)`
|
||||
hands B *the router*. B's asserts build wind straight from `createWindField` (sail.selftest's
|
||||
`realWind()`), so **every ponding assert will pass while ponding does nothing in the actual game** —
|
||||
green tests, dead feature, the worst kind. Verified live just now: `SHADES.wind.rainMmPerHour` →
|
||||
`undefined`. Please add to the router:
|
||||
```js
|
||||
rainMmPerHour: (t) => active.rainMmPerHour(t),
|
||||
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
|
||||
```
|
||||
(`sky.gardenExposure` is fine — skyfx is passed to you directly, not through a facade.) Worth a
|
||||
thought for later, not this sprint: the allowlist is why this bites, and a `checkContract`-style
|
||||
tripwire on the router would have caught it. Your call, your file — I'm not touching main.js.
|
||||
|
||||
[C] 2026-07-17 — **PONDING DATA IS IN, calibrated to B's own arithmetic (SPRINT4 §C-2, decision 10).**
|
||||
`rainAt()` was dimensionless 0..1 — right for drop count and opacity, useless for water mass. Rather
|
||||
than have B invent the mm/hr scale (the "constant I invented" they correctly reverted over), the scale
|
||||
is now storm data: **`rain.peakMmPerHour`**, plus `wind.rainMmPerHour(t)` and `wind.rainDepthMm(t0,t1)`
|
||||
(real-world mm, no compression). **`RAIN_TIME_COMPRESSION = 40` is exported from weather.core** — apply
|
||||
it cloth-side, so neither of us hardcodes 40 twice: how hard it rains is my lane, how much water a sail
|
||||
holds is yours. Validator now rejects a rain curve with no scale (silent default = not what the author
|
||||
meant) and intensity outside 0..1.
|
||||
Decision 10's 40× over 90 s is exactly one hour of rain, which makes the arithmetic land on your
|
||||
numbers to two decimals:
|
||||
```
|
||||
storm peak depth over the storm flat 25 m² rig
|
||||
storm_02 80 mm/hr severe 50.9 mm (5.1 cm) → 3.12 kN/corner ← your 3.1 kN kill
|
||||
storm_03 30 mm/hr moderate 8.3 mm (0.8 cm) → 0.51 kN/corner
|
||||
storm_01 8 mm/hr shower 0.9 mm → 0.06 kN/corner
|
||||
```
|
||||
…against your measured storm_02 wind of 0.2–1.1 kN/corner. **A flat rig should drown in the wild
|
||||
night; storm_01 must not be able to hurt anything** (that's the ramp, and both are asserted, using
|
||||
your arithmetic, so the storms are provably fit for their water before your cloth lands). storm_03 is
|
||||
deliberately the teaching rung: enough water to make a carabiner rig (1.2 kN) sweat once wind is added,
|
||||
not enough to drown a shackle. If your mass model wants different depths, **move `peakMmPerHour`, not
|
||||
the curve shape** — the curves carry the story (storm_03's rain arrives *with* the change at t≈30, not
|
||||
before). Say the word and I'll re-scale.
|
||||
|
||||
[C] 2026-07-17 — **LANE A — decision 7 helper landed, as offered: `sky.gardenExposure(bed, t)`.** The whole
|
||||
drain term in one call — `rainAt(t) × (1 − rainShadowOver(bed))`, 0..1:
|
||||
```js
|
||||
hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
|
||||
```
|
||||
0 = bone dry (no rain, or cloth over it); 1 = full downpour on open ground. Both terms are needed and
|
||||
neither is enough alone. ⚠️ **It moves on its own and that's the mechanic, not a bug:** the rain shadow
|
||||
follows the wind, so storm_02's southerly change walks the dry patch off the bed and the drain starts
|
||||
climbing with not a single corner having failed. If that looks surprising in your HUD, please don't
|
||||
"fix" it. Telegraph feed for the gust banner is unchanged: `wind.gustTelegraph(t)` → `{eta, dir, power}`
|
||||
or null, and the contract still guarantees eta ≥ 1.2 s when it first appears.
|
||||
|
||||
[C] 2026-07-17 — **Night pass done — and the reason it looked wrong is worth knowing.** storm_02 now reads
|
||||
as an actual wild night. `sky.night` is the author's call (it was inferred from a darkness threshold),
|
||||
but the real bug was that **darkening `scene.background` did nothing**: my cloud dome covers it at 0.85
|
||||
opacity, so a near-white cloud texture was what you were actually looking at the whole time. The dome
|
||||
now tints and crushes together — a lerp alone lands at #717273 because it runs in linear space and the
|
||||
texture is baked light. It stops at 0.78 deliberately: the yard has no lights in it, and a storm you
|
||||
can't see is a black screen (E — if a porch light or a shed lamp ever appears, I can take this darker).
|
||||
Lightning now **lights the cloud it's inside** (#3e3e3f → #d4ddf2, the good part), and fires on the
|
||||
biggest gusts via `sky.lightningGustPow: 10` — driven off the telegraph so the flash lands *with* the
|
||||
gust that earned it, on top of the three authored strikes. storm_01 stays lightning-free.
|
||||
A — the forecast card has range to sell now: `sky.night`, `rain.peakMmPerHour` (8/30/80), peak gust
|
||||
(11/21/32 m/s) and the change time all read straight off the storm JSON.
|
||||
|
||||
[C] 2026-07-17 — **B — decision 11 is yours to close and I'm ready either way; here's what I know.** My
|
||||
storm_02 `downdraftOfTotal` is still **held at 0.12** with the comment in the JSON explaining it —
|
||||
I'll delete that comment with satisfaction the moment your measurement lands. Two things that may save
|
||||
you an afternoon:
|
||||
· **A's anchors alone didn't fix it.** I re-measured your §7 rig `['h1','t2','p1','t1']` on the
|
||||
*dressed* yard: still **141 m²** (h1 is house at z≈−9.9, t2 at x≈8, t1 at x≈−9 — those four span
|
||||
the whole yard), and it dies at 0.45 (3/4, peak 6410 N). The re-point is the whole job.
|
||||
· **A caution, not a conclusion:** sweeping the near-bed anchors I couldn't find an 18–45 m² quad
|
||||
that *both* covers the bed ≥50% *and* survives 0.45 on a rated+shackle mix — the bed sits between
|
||||
the house (z≈−9.9) and the posts (z≈+6), so covering it wants a biggish quad. A's a.test says ≥3
|
||||
small quads shade the bed, so they exist and I'm likely mis-enumerating (your area calc, your
|
||||
tension intent — I don't own either). If 0.45 turns out genuinely too hot on the real
|
||||
bed-covering rig, the ruling's second branch is right there and **ponding now carries the
|
||||
anti-flat burden with 3.12 kN/corner** — which is 3× the wind and cannot touch a hypar. Either
|
||||
outcome closes it. No third sprint, as ruled.
|
||||
|
||||
[C] 2026-07-17 — Small one, my own miss: `c.test.js` hardcoded a two-storm list while the node runner globs
|
||||
`data/storms/`, so **storm_03 was never loaded in the browser half** — it only surfaced when my new
|
||||
ponding case reached for `storms.storm_03_southerly` and got `undefined`. Fixed, with a note on the
|
||||
list. If you add a storm, that list is the thing to update.
|
||||
[B] 2026-07-17 — ✅ **DECISION 11 CLOSED: the bar PASSES. I was wrong, and C was right.** Re-measured on
|
||||
A's dressed yard, 8 headings, full 90 s, fraction-of-total:
|
||||
```
|
||||
downdraftOfTotal 60% bar (30 m² @ yard pitch) §7 twisted (23 m² quad) §7 cheap flat
|
||||
0.12 (landed) 67% PASS 0/4 1.2 kN 4/4 cascades
|
||||
0.40 84% PASS 0/4 2.2 kN 4/4 cascades
|
||||
0.45 85% PASS 0/4 2.4 kN 4/4 cascades
|
||||
```
|
||||
**storm_02 `downdraftOfTotal` bumped 0.12 → 0.45.** Both physics gates close on one storm JSON, no
|
||||
skips, 26/26 sail + 13/13 rigging green. The bar even passed at the landed 0.12 — the bump isn't
|
||||
needed for it, but 0.45 is C's measured target and makes the storm meaner, so it goes in.
|
||||
**Where I went wrong, precisely, because it's a reusable lesson:** ratio = `(f / (sin p + cos p·f))²`
|
||||
depends on the REFERENCE PITCH `p` far more than on the downdraft `f`.
|
||||
```
|
||||
pitch f=0.12 f=0.45
|
||||
16.7° 8.9% 39.2% <- my synthetic reference rig. Genuinely unreachable: asymptote 109%,
|
||||
would need f=0.86 = 28 m/s of falling air.
|
||||
4.8° 34.9% 71.5% <- the actual yard: house fascia 2.60 m to posts 3.95 m over ~16 m.
|
||||
```
|
||||
My algebra was correct and my conclusion was false, because the rig I proved it on is one the game
|
||||
cannot build. A 16.7° sail needs 1.5 m of drop across 5 m; the yard's anchors give 1.35 m across 16 m.
|
||||
I generalised "unachievable on my test rig" to "unachievable", and the word doing the damage was mine.
|
||||
The integrator's "measured beats modelled" was the right call — and the fix wasn't to distrust the
|
||||
model, it was to point it at the real geometry. **C — your 0.45 was right, and your instinct to keep
|
||||
the downdraft when I wanted it retired was right too. Sorry for the two-sprint detour.**
|
||||
The assert now derives its reference pitch from the yard (`YARD_PITCH_DEG = 4.8`, sourced to world.js)
|
||||
and carries the pitch table in a comment, so the next person can see in ten seconds why the number
|
||||
moves and won't re-run this argument.
|
||||
|
||||
[B] 2026-07-17 — §7 re-pointed onto the decision-2 yard, and one of my own legs was lying. The twisted rig
|
||||
moves off the retired 145 m² quad onto a real 23 m² one (`t1,p1,p2,p3` — most twisted in A's 18-45
|
||||
band). The repair leg's dodgy carabiner had been sitting on **p1, the lightest-loaded corner** (0.60 kN
|
||||
against a 1.20 kN rating), so it rode out the whole storm, nothing blew, and the leg skipped itself
|
||||
while looking green. Measured peaks on the new quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN; the
|
||||
carabiner now goes on p2, blows, and one `repair()` finishes 4/4. Its stale skip guard (still testing
|
||||
the old `gusts.downdraft` key, which decision 8 renamed) is gone — that guard is why it went quiet
|
||||
instead of red. **Third time a Lane B test has passed while proving nothing**, always the same shape:
|
||||
the test arranged the state instead of provoking it. I'd rather flag the pattern than keep fixing
|
||||
instances of it.
|
||||
A — no action for you, but FYI the cheap-flat cascade still fires at t=0.4 s on the old oversized quad
|
||||
(`h1,h3,p2,p1`), which is correct now rather than a wart: that quad is *supposed* to be the wrong
|
||||
answer, and the yard now offers right-sized ones next to it.
|
||||
|
||||
[B] 2026-07-17 — ⏳ **STILL OPEN on lane/b this sprint: ponding v1 (§B-2) and D's tn-1.04 cliff (§B-3).**
|
||||
Decision 11 took the afternoon it was budgeted and it closes the longest-running question in the repo,
|
||||
so I've landed it alone rather than half-land three things. Both remaining items are specced and
|
||||
unblocked — nothing waits on another lane:
|
||||
· **Ponding v1** — decision 10's 40× fiat is exactly what I asked for and it's the right call. My
|
||||
Sprint-3 prototype (`rainAt` × per-node flatness → water mass → weight, `pondMass()` for the HUD)
|
||||
was reverted, not lost; rebuilding it against the fiat is the short part. The asserts SPRINT4 asks
|
||||
for are the real work: a hypar must pool ~nothing (it should — there's no flat for water to sit
|
||||
in), and a flat rig must die of water alone in storm_02.
|
||||
· **D's cliff** — tn 1.04 taking peak load 1.2 → 10 kN in a 0.01 step is my bug and I want to
|
||||
understand it before I clamp it. D's read (a cloth-stability ceiling, not gradual overload) matches
|
||||
a solver instability rather than physics, and a per-face force clamp would hide it rather than fix
|
||||
it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a
|
||||
mechanic is the right instinct — that's the same failure mode as my three vacuous tests above.**
|
||||
[D] 2026-07-17 — 🪜 **LADDER SUB-SYSTEM LANDED (decision 12) — the full loop runs in the real game.**
|
||||
New file `web/world/js/ladder.js` (Lane D). Driven by hand through SHADES.step, the prompt chain is:
|
||||
`ladder_take` → carry → `ladder_place_h2` → (h2's carabiner blows) → `spare_table` →
|
||||
`ladder_climb` → **`rerig_0`, which only exists at height** → `oooo`, spare consumed.
|
||||
Selftest **194/0/0** (was 184); 11 of the new asserts are the ladder's, including the scripted
|
||||
climb-repair-descend the sprint asked for.
|
||||
**It needed no change to main.js.** createLadder self-wires from createPlayer, which Lane A already
|
||||
hands the scene, world and interact — so a whole sub-system landed inside Lane D's own files. The
|
||||
reach gate finds it via `interact.ladder` (Interact is Lane D's class), and an explicit
|
||||
`deps.ladder` still wins for tests.
|
||||
Numbers that made the design: **fascia sits at 2.48 m, a 1.72 m person reaches 2.20 m, E's ladder
|
||||
tops out at 2.90 m.** Two hundred millimetres is the entire mechanic — the asset and the yard were
|
||||
already built for each other, I just wrote the verb between them.
|
||||
|
||||
[D] 2026-07-17 — 📐 **Scoped `needsLadder` to the FASCIA, deliberately — flagging the judgement call.**
|
||||
A pure "is it above head height?" rule would have caught the 3.95 m posts and the 5.05 m tree limbs
|
||||
and turned *every* repair in the game into a two-trip ladder job — which would have silently
|
||||
invalidated the recorded §7 run and Lane B's gate asserts, and isn't true to rigging anyway: a sail
|
||||
post is tensioned from a cleat at its base and a tree anchor is a strop you throw. A bracket bolted
|
||||
2.5 m up a bare wall is the one you cannot fake. So `needsLadder = anchor.type === 'house'`, one
|
||||
line, where it can be found and argued with. This also means **the house is now the expensive anchor
|
||||
to depend on** — which is what E's `ratingHint 0.35` / `collateral "gutter"` was already saying in
|
||||
the data. Shout if you wanted it broader.
|
||||
|
||||
[D] 2026-07-17 — 🐛 **A FOOTGUN IN MY OWN API, found twice while building on it, now documented + asserted.**
|
||||
`interact.register({canUse})` is re-checked EVERY FRAME to keep a hold alive — and starting a hold
|
||||
moves the player into `busy`. So any `canUse` that reads `player.state` goes false on frame one and
|
||||
**the action silently cancels its own hold.** No error; the prompt just looks dead. It ate the
|
||||
ladder's climb (`canUse: p => p.state === 'idle'`) and the fascia reach gate (`isWorking()` testing
|
||||
`state === 'atTop'`) — I lost time to it both times. Fixed by gating on PHYSICAL facts (carrying,
|
||||
position, climbY), which is also the honest test. Now written on `register()`'s JSDoc where you'll
|
||||
read it, and pinned by an assert that registers a deliberately state-gated action and proves it
|
||||
never fires. **B/C/E: if you ever register an interact target, gate on facts, not on state.**
|
||||
|
||||
[D] 2026-07-17 — 💬 **FEEL NOTES from playing it (A's HUD/prep not landed yet, so these are from the
|
||||
parts that exist). I'm the lane that plays it, so:**
|
||||
1. **No prompt is worse than a refusal.** Carry the ladder to the shed table and the "take a
|
||||
spare" prompt doesn't say "hands full" — it *vanishes*, because `canUse` filters the target out
|
||||
of `nearest()` before the label can explain itself. It reads as a broken game, not a full pair
|
||||
of hands. **Lane A, this is a HUD-shaped problem:** the prompt wants to show unusable actions
|
||||
greyed out with their reason, and my `label(player)` already returns "hands full" / "out of
|
||||
reach — needs the ladder" for exactly this. Say the word and I'll surface unusable targets.
|
||||
2. **The two-trip fascia repair costs ~15 s of running** (shed→ladder→wall→shed→wall) out of a 90 s
|
||||
storm. It is *supposed* to hurt, and it does — but that's a sixth of the storm on foot, and
|
||||
until the HUD shows corner loads you can't tell whether you're spending it well. Worth a look
|
||||
once the HUD lands; I'd rather tune it against a player who can see, than guess now.
|
||||
3. **A ladder standing bolt upright reads as a post, not a ladder** — I had it vertical at first
|
||||
and genuinely couldn't tell what I was looking at until I saw its shadow. It now leans 15° into
|
||||
the wall. Small thing; large difference. E, the GLB is lovely and its `ladder_top`/`ladder_base`
|
||||
nodes did all the work — I read topY straight off the asset rather than hardcoding 2.9.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 4 INTEGRATION (main).** Lanes b/c/d/e merged; applied C's two-line router fix
|
||||
in main.js (rainMmPerHour/rainDepthMm — ponding would have been dead-on-arrival; C, your tripwire
|
||||
suggestion for the router allowlist is a good SPRINT5 nicety). Selftest **207/0/0**. Played the loop
|
||||
with the face on: forecast card (three named storms, the change warning) → prep (B's table, anchor
|
||||
rings, budget) → 91 s wild night → aftermath with verdict + play again. Gate 1 confirmed.
|
||||
Decision 11 closed by B conceding with numbers and a model post-mortem worth rereading (reference
|
||||
pitch, not downdraft, was the variable; "measured beats modelled" held). storm_02 downdraftOfTotal
|
||||
is 0.45. Ladder loop landed whole inside Lane D's files. E renamed fence_panel_snapped to match spec.
|
||||
**DECISION 13 (design ruling on A's garden-HP finding): HAIL carries storm garden damage.** A perfect
|
||||
rig scores 54% vs 48% for no sail because driving rain honestly walks under the sail — C's weather is
|
||||
right, so rain is the WRONG thing to score the sail on. DESIGN.md already says hail is the
|
||||
garden-killer and drainage answers rain. Hail falls steep and fast → overhead cloth blocks it even in
|
||||
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.
|
||||
|
||||
@ -16,400 +16,7 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_01",
|
||||
"dims": [
|
||||
4.5522,
|
||||
4.956,
|
||||
7.9702
|
||||
],
|
||||
"tris": 396,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"tree_gum_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_02",
|
||||
"dims": [
|
||||
3.8871,
|
||||
2.7787,
|
||||
5.4972
|
||||
],
|
||||
"tris": 288,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"tree_gum_02",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
0.13,
|
||||
0.13,
|
||||
2.03
|
||||
],
|
||||
"tris": 24,
|
||||
"nodes": [
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.054,
|
||||
1.8194
|
||||
],
|
||||
"tris": 324,
|
||||
"nodes": [
|
||||
"fence_panel",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "gate",
|
||||
"dims": [
|
||||
1.045,
|
||||
0.0615,
|
||||
1.75
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"gate",
|
||||
"gate_frame",
|
||||
"gate_palings",
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside",
|
||||
"dims": [
|
||||
9.2,
|
||||
1.0547,
|
||||
2.9
|
||||
],
|
||||
"tris": 200,
|
||||
"nodes": [
|
||||
"door",
|
||||
"fascia",
|
||||
"fascia_anchor_01",
|
||||
"fascia_anchor_02",
|
||||
"fascia_anchor_03",
|
||||
"gutter",
|
||||
"house_yardside",
|
||||
"roof",
|
||||
"wall",
|
||||
"window"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
2.58,
|
||||
1.9708,
|
||||
2.2224
|
||||
],
|
||||
"tris": 96,
|
||||
"nodes": [
|
||||
"door_anchor",
|
||||
"doors",
|
||||
"roof",
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_table",
|
||||
"dims": [
|
||||
1.6,
|
||||
0.6,
|
||||
0.9
|
||||
],
|
||||
"tris": 72,
|
||||
"nodes": [
|
||||
"pickup_anchor",
|
||||
"shed_table",
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_bed",
|
||||
"dims": [
|
||||
3.0,
|
||||
1.2,
|
||||
0.8609
|
||||
],
|
||||
"tris": 2580,
|
||||
"nodes": [
|
||||
"bed",
|
||||
"garden_bed",
|
||||
"plants_dead",
|
||||
"plants_full",
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post",
|
||||
"dims": [
|
||||
0.507,
|
||||
0.52,
|
||||
4.0327
|
||||
],
|
||||
"tris": 528,
|
||||
"nodes": [
|
||||
"footing",
|
||||
"pad_eye",
|
||||
"post",
|
||||
"rake_pivot",
|
||||
"sail_post",
|
||||
"top_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "ladder_01",
|
||||
"dims": [
|
||||
0.455,
|
||||
0.075,
|
||||
3.0
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"ladder",
|
||||
"ladder_01",
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shackle",
|
||||
"dims": [
|
||||
0.0569,
|
||||
0.019,
|
||||
0.0744
|
||||
],
|
||||
"tris": 560,
|
||||
"nodes": [
|
||||
"bow",
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carabiner",
|
||||
"dims": [
|
||||
0.049,
|
||||
0.009,
|
||||
0.1027
|
||||
],
|
||||
"tris": 476,
|
||||
"nodes": [
|
||||
"body",
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "turnbuckle",
|
||||
"dims": [
|
||||
0.0292,
|
||||
0.0341,
|
||||
0.1955
|
||||
],
|
||||
"tris": 728,
|
||||
"nodes": [
|
||||
"body",
|
||||
"eye_a",
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tramp_01",
|
||||
"dims": [
|
||||
2.9555,
|
||||
2.9555,
|
||||
0.78
|
||||
],
|
||||
"tris": 976,
|
||||
"nodes": [
|
||||
"legs",
|
||||
"mat",
|
||||
"pad",
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "wheelie_bin_01",
|
||||
"dims": [
|
||||
0.58,
|
||||
0.6808,
|
||||
1.1188
|
||||
],
|
||||
"tris": 120,
|
||||
"nodes": [
|
||||
"bin_body",
|
||||
"lid",
|
||||
"lid_plate",
|
||||
"wheelie_bin_01",
|
||||
"wheels"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "washing_line_01",
|
||||
"dims": [
|
||||
2.8441,
|
||||
2.8441,
|
||||
2.2777
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"arms",
|
||||
"head",
|
||||
"mast",
|
||||
"washing_line_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01",
|
||||
"dims": [
|
||||
0.1427,
|
||||
0.15,
|
||||
0.365
|
||||
],
|
||||
"tris": 236,
|
||||
"nodes": [
|
||||
"garden_gnome_01",
|
||||
"gnome"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01_broken",
|
||||
"dims": [
|
||||
0.3947,
|
||||
0.3519,
|
||||
0.106
|
||||
],
|
||||
"tris": 344,
|
||||
"nodes": [
|
||||
"garden_gnome_01_broken",
|
||||
"hat",
|
||||
"head",
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel_broken",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.7749,
|
||||
1.8197
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"debris_palings",
|
||||
"fence_panel_broken",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
{
|
||||
"file": "BlueCrate_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.36,
|
||||
0.29
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "BlackTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WhiteTub_v2.glb",
|
||||
"dims": [
|
||||
0.36,
|
||||
0.54,
|
||||
0.2
|
||||
],
|
||||
"sane": true
|
||||
},
|
||||
{
|
||||
"file": "WoodenBin_v2.glb",
|
||||
"dims": [
|
||||
0.35,
|
||||
0.36,
|
||||
0.31
|
||||
],
|
||||
"sane": true
|
||||
}
|
||||
]
|
||||
"debris": []
|
||||
}
|
||||
@ -117,9 +117,16 @@ PAL = {
|
||||
"gnome_skin": "#E0A986",
|
||||
"gnome_coat": "#3E6FA8",
|
||||
"gnome_hat": "#B33C36",
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
|
||||
# picks up dust off the cloth, and mostly it mirrors an overcast sky — so it
|
||||
# reads grey-green, and it goes darker where it's deeper.
|
||||
WATER_SHALLOW = (0.46, 0.51, 0.46)
|
||||
WATER_DEEP = (0.22, 0.28, 0.26)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UTILS — lifted from racks_to_glb.py, kept deliberately close to the original
|
||||
@ -1181,7 +1188,52 @@ def build_garden_gnome_01_broken(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(name):
|
||||
def build_broom_01(name):
|
||||
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
|
||||
|
||||
Stands upright, head on the ground, because that's how it lives against the
|
||||
shed wall — Lane D rotates it to poke. `poke_tip` is on the BRISTLE end, not
|
||||
the handle: a broomstick jabbed at a loaded sail puts a hole through it, and
|
||||
the soft end is the one a landscaper would actually use. `grip_anchor` is
|
||||
where the hand goes, two thirds up.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
dowel = get_material("Mat_Timber", PAL["timber"], 0.7)
|
||||
head_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
bristle = get_material("Mat_Bristle", PAL["bristle"], 0.95)
|
||||
H, HEAD_W = 1.42, 0.30
|
||||
|
||||
join_group([add_cyl(f"{name}_handle", 0.014, H - 0.10, (0, 0, 0.10 + (H - 0.10) / 2),
|
||||
dowel, verts=8)], "handle", root)
|
||||
join_group([add_box(f"{name}_head", (HEAD_W, 0.055, 0.05), (0, 0, 0.125), head_m),
|
||||
add_cone(f"{name}_ferrule", 0.020, 0.014, 0.05, (0, 0, 0.16), head_m,
|
||||
verts=8)], "head", root)
|
||||
|
||||
# Bristles: a row of tapered tufts, splayed a little and unevenly worn. A
|
||||
# solid block reads as a paint roller.
|
||||
tufts = []
|
||||
n = 11
|
||||
for i in range(n):
|
||||
x = -HEAD_W / 2 + 0.02 + i * ((HEAD_W - 0.04) / (n - 1))
|
||||
ln = rng.uniform(0.085, 0.105)
|
||||
lean = (x / (HEAD_W / 2)) * rng.uniform(0.04, 0.09)
|
||||
tufts.append(add_tube_between(f"{name}_tuft_{i:02d}", (x, 0, 0.10),
|
||||
(x + lean, rng.uniform(-0.01, 0.01), 0.10 - ln),
|
||||
0.010, bristle, verts=4))
|
||||
join_group(tufts, "bristles", root)
|
||||
|
||||
g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12)
|
||||
g["carry_type"] = "broom"
|
||||
p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12)
|
||||
p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth"
|
||||
stamp(root, name, "tool")
|
||||
root["mass_hint"] = 1.2
|
||||
root["anim_hint"] = "reuse Crank/Dig for the poke — no new Mixamo needed"
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_snapped(name):
|
||||
"""A panel the storm went through. Same 2.4 m tile footprint and origin as
|
||||
fence_panel, so Lane A drops it into the run in place of one instance rather
|
||||
than re-tiling the fence.
|
||||
@ -1366,6 +1418,103 @@ def build_sail_textures():
|
||||
return [p1, p2]
|
||||
|
||||
|
||||
def build_pond_textures():
|
||||
"""The pond in a flat sail's belly (SPRINT4 §Lane E-1, decision 10).
|
||||
|
||||
Two textures, both for a patch Lane B builds from the cloth's own nodes —
|
||||
same ride-the-nodes rule as the tear decals, and for the same reason: a rigid
|
||||
disc added to the sail group would sit still while the belly moves under it.
|
||||
|
||||
pond_water.png — RGBA decal. Alpha is a radial feather so the pool dissolves
|
||||
into the cloth instead of ending at a hard rim; RGB darkens
|
||||
toward the middle because that's where it's deep. Scale it
|
||||
per pond mass and the shading stays right, because depth is
|
||||
encoded radially rather than baked at one size.
|
||||
pond_normal.png — SEAMLESS tiling ripple normals, so the pool catches the sun
|
||||
and reads as liquid rather than as a painted patch. Tiles
|
||||
because B will repeat it across whatever area the pond has.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# 256², not 512²: both of these are smooth, low-frequency content (a radial
|
||||
# gradient and some sine ripples), so the extra resolution buys nothing you
|
||||
# can see and costs 4x the bytes. At 512 they were 256 KB + 320 KB against
|
||||
# ~20 KB for every other texture here, in a repo whose entire model set is
|
||||
# 672 KB. The fastest pond is the one that isn't most of the download.
|
||||
SIZE = 256
|
||||
Y, X = np.mgrid[0:SIZE, 0:SIZE]
|
||||
c = (SIZE - 1) / 2.0
|
||||
nx, ny = (X - c) / (SIZE / 2.0), (Y - c) / (SIZE / 2.0)
|
||||
r = np.clip(np.sqrt(nx * nx + ny * ny), 0.0, 1.0)
|
||||
|
||||
# Wind chop. Not concentric rings — a puddle ringed like a dartboard reads as
|
||||
# a target. But three crossed sines don't work either: at similar frequencies
|
||||
# they interfere into a regular lattice and the pond reads as basketweave.
|
||||
# Seven waves, directions spaced by the golden angle and frequencies in a
|
||||
# non-harmonic ratio, so nothing lines up and the surface stays irregular the
|
||||
# way real chop is. Free choice here — this decal is radial, never tiled, so
|
||||
# unlike the normal map it owes nothing to seamlessness.
|
||||
chop = np.zeros((SIZE, SIZE), dtype=np.float32)
|
||||
rw = rng_for("pond_chop")
|
||||
total = 0.0
|
||||
for i in range(7):
|
||||
ang = i * 2.39996 # golden angle: maximally non-repeating
|
||||
freq = 5.0 * (1.37 ** i) # non-harmonic progression
|
||||
amp = 1.0 / (1.0 + i * 0.8)
|
||||
chop += amp * np.sin((nx * math.cos(ang) + ny * math.sin(ang)) * freq * math.pi
|
||||
+ rw.uniform(0, math.tau))
|
||||
total += amp
|
||||
chop = np.clip(0.5 + 0.5 * chop / total, 0.0, 1.0)
|
||||
|
||||
depth = np.clip(1.0 - r, 0.0, 1.0) ** 0.7
|
||||
water = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
for i in range(3):
|
||||
base = WATER_SHALLOW[i] + (WATER_DEEP[i] - WATER_SHALLOW[i]) * depth
|
||||
water[:, :, i] = np.clip(base * (0.86 + 0.28 * chop), 0.0, 1.0)
|
||||
# Feather the last quarter of the radius: a hard edge would read as a decal.
|
||||
a = np.clip((1.0 - r) / 0.25, 0.0, 1.0)
|
||||
water[:, :, 3] = (a * a * (3.0 - 2.0 * a)).astype(np.float32) # smoothstep
|
||||
p1, kb1 = save_png(water, "pond_water")
|
||||
print(f" pond_water.png {SIZE}x{SIZE}, radial feather, {kb1} KB")
|
||||
|
||||
# --- ripple normals, seamless -----------------------------------------
|
||||
def height(px, py):
|
||||
h = np.zeros_like(px, dtype=np.float32)
|
||||
# Integer cycles across the tile = exact wrap, same trick as the weave.
|
||||
# Six of them rather than three, on deliberately unrelated (kx, ky) pairs:
|
||||
# too few waves and they beat into a visible lattice, same failure the
|
||||
# albedo chop had. Integer pairs are the only constraint seamlessness puts
|
||||
# on this — which ones is free.
|
||||
for kx, ky, amp in ((3, 5, 1.0), (7, 2, 0.62), (11, 9, 0.36),
|
||||
(2, 13, 0.28), (13, 4, 0.20), (5, 11, 0.16)):
|
||||
h += amp * np.sin(2 * np.pi * (kx * px + ky * py) / SIZE)
|
||||
return h
|
||||
|
||||
Yn, Xn = np.mgrid[0:SIZE, 0:SIZE]
|
||||
e = 1.0
|
||||
dhdx = (height(Xn + e, Yn) - height(Xn - e, Yn)) / (2 * e)
|
||||
dhdy = (height(Xn, Yn + e) - height(Xn, Yn - e)) / (2 * e)
|
||||
strength = 6.0
|
||||
nxv, nyv, nzv = -dhdx * strength, -dhdy * strength, np.ones_like(dhdx)
|
||||
ln = np.sqrt(nxv * nxv + nyv * nyv + nzv * nzv)
|
||||
normal = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
normal[:, :, 0] = (nxv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 1] = (nyv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 2] = (nzv / ln) * 0.5 + 0.5
|
||||
normal[:, :, 3] = 1.0
|
||||
|
||||
# Same guard as the weave: B is told to RepeatWrapping this, and a bad wrap
|
||||
# is a visible seam gridded across the pond.
|
||||
if not np.allclose(height(Xn, Yn), height(Xn + SIZE, Yn), atol=1e-4):
|
||||
raise AssertionError("pond_normal does not tile on X")
|
||||
if not np.allclose(height(Xn, Yn), height(Xn, Yn + SIZE), atol=1e-4):
|
||||
raise AssertionError("pond_normal does not tile on Y")
|
||||
|
||||
p2, kb2 = save_png(normal, "pond_normal")
|
||||
print(f" pond_normal.png {SIZE}x{SIZE}, seamless ripples, {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."""
|
||||
@ -1487,7 +1636,12 @@ ASSETS = [
|
||||
# Deeper than fence_panel on purpose: the snapped palings lie on the grass in
|
||||
# front of it. Bounded so wreckage on a boundary fence can't reach through
|
||||
# whatever is behind it.
|
||||
dict(name="fence_panel_broken", fn=build_fence_panel_broken,
|
||||
# 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="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"]),
|
||||
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
@ -1815,6 +1969,7 @@ def main():
|
||||
reset_to_empty()
|
||||
build_grass_atlas()
|
||||
build_sail_textures()
|
||||
build_pond_textures()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 193 KiB |
@ -24,7 +24,9 @@
|
||||
|
||||
"events": [],
|
||||
|
||||
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
|
||||
"_rain_comment": "peakMmPerHour 8 = a light shower. Mean intensity ~0.12, so at decision 10's 40x a whole storm delivers ~0.9 mm — about a millimetre of water, ~20 kg over a 25 m2 flat sail. Ponding must NOT be able to hurt anything here; that's the point of the gentle storm.",
|
||||
|
||||
"rain": { "peakMmPerHour": 8, "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
|
||||
|
||||
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraftOfTotal": 0.12
|
||||
"downdraftOfTotal": 0.45
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
@ -38,7 +38,11 @@
|
||||
{ "t": 79, "type": "lightning", "power": 0.5 }
|
||||
],
|
||||
|
||||
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
"_rain_comment": "peakMmPerHour 80 = severe thunderstorm rate, the scale rainAt's 0..1 is a fraction OF. Ponding (decision 10) reads it. Mean intensity over the storm is ~0.64, and at 40x compression a 90 s storm is one hour of rain, so depth ~= 80 * 0.64 = ~51 mm = 5.1 cm on a flat sail — Lane B's measured kill: 5 cm over 25 m2 = 1250 kg = 3.1 kN/corner, against a wind load of only 0.2-1.1 kN. A flat rig should drown here. A hypar pools nothing and doesn't care.",
|
||||
|
||||
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
|
||||
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
|
||||
|
||||
"_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 }
|
||||
}
|
||||
|
||||
@ -32,7 +32,9 @@
|
||||
{ "t": 62, "type": "lightning", "power": 0.4 }
|
||||
],
|
||||
|
||||
"rain": { "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
"_rain_comment": "peakMmPerHour 30 = moderate. The rain arrives WITH the change at t~30 and not before — dry hot afternoon, then the southerly brings the water, which is the story the curve is telling. Mean intensity ~0.28, so at decision 10's 40x it delivers ~8 mm = 0.8 cm: ~200 kg on a 25 m2 flat sail, ~0.5 kN/corner. That is the middle rung on purpose — enough that a flat rig on carabiners (1.2 kN) should feel the water once wind is added, not enough to drown a shackle. Ponding teaches here; it kills in storm_02.",
|
||||
|
||||
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
|
||||
|
||||
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
|
||||
}
|
||||
|
||||
@ -28,7 +28,13 @@ export class Interact {
|
||||
* @param {number} [spec.radius] metres
|
||||
* @param {number} [spec.holdSecs]
|
||||
* @param {string|function} [spec.label] string, or (player)->string for live text
|
||||
* @param {function} [spec.canUse] (player) -> bool
|
||||
* @param {function} [spec.canUse] (player) -> bool.
|
||||
* **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
|
||||
* and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes
|
||||
* false on frame one and the action silently cancels its own hold. It looks exactly like a dead
|
||||
* prompt. Gate on physical facts instead (carrying, position, height); locked states already
|
||||
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
|
||||
* climb and the fascia reach gate.
|
||||
* @param {function} [spec.onDone] (player, t) -> void
|
||||
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …).
|
||||
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
|
||||
@ -149,14 +155,35 @@ export class Interact {
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = 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;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
const anchorOf = (i) => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || null;
|
||||
};
|
||||
// a flogging corner is MOVING — resolve position every frame, never once at wire time
|
||||
const posAt = (i) => () => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
// A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
|
||||
// sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
|
||||
// 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
|
||||
if (ladder && ladder.needsLadder(anchorOf(i))) {
|
||||
const a = anchorOf(i);
|
||||
return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
|
||||
}
|
||||
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
||||
};
|
||||
/** Fascia work needs you up the ladder that's planted under THAT bracket. */
|
||||
const canReach = (i, p) => {
|
||||
const a = anchorOf(i);
|
||||
if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work
|
||||
return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
|
||||
};
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((_corner, i) => {
|
||||
@ -166,10 +193,10 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 2.5,
|
||||
label: 're-rig corner',
|
||||
label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
||||
&& p.carrying === 'spare' && !!sailRig.repair,
|
||||
&& p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
|
||||
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
|
||||
}));
|
||||
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
|
||||
@ -178,9 +205,9 @@ export function wireYardActions(interact, deps = {}) {
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
|
||||
clip: 'Crank',
|
||||
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
||||
canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
|
||||
onDone: () => sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
|
||||
210
web/world/js/ladder.js
Normal file
210
web/world/js/ladder.js
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* ladder.js — the ladder sub-system. (Lane D, SPRINT4 decision 12)
|
||||
*
|
||||
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
|
||||
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
|
||||
* ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
|
||||
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
|
||||
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
|
||||
*
|
||||
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
|
||||
* ladder and spare are BOTH carry items, and you can only hold one.
|
||||
* So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
|
||||
* while a post repair costs one. The house is the expensive anchor to depend on, which is
|
||||
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
|
||||
*
|
||||
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
|
||||
* no change to get this — it already hands createPlayer the scene, world and interact.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
|
||||
export const LADDER_URL = './models/ladder_01_v1.glb';
|
||||
|
||||
/**
|
||||
* Which anchors you cannot rig from the ground.
|
||||
*
|
||||
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
|
||||
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
||||
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
||||
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
||||
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
||||
*/
|
||||
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
|
||||
|
||||
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
||||
const STAND_OFF = 0.9; // m clear of the wall face
|
||||
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
||||
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
||||
|
||||
/**
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World (must be dressed — anchors are final only after dress())
|
||||
* @param {object} interact Lane D's Interact
|
||||
* @param {object} player the PlayerSim
|
||||
* @returns {object} the ladder system
|
||||
*/
|
||||
export function createLadder(scene, world, interact, player) {
|
||||
const anchors = (world.anchors || []).filter(needsLadder);
|
||||
if (!anchors.length) {
|
||||
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
|
||||
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
|
||||
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
|
||||
}
|
||||
|
||||
const state = {
|
||||
carried: false, // in the player's hands
|
||||
placedAt: null, // anchor id, or null while stowed/carried
|
||||
base: new THREE.Vector3(),
|
||||
topY: 2.9, // overwritten from the GLB's ladder_top node
|
||||
view: null,
|
||||
};
|
||||
|
||||
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
||||
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
|
||||
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
|
||||
// thing that reads as a broken game rather than a crowded shed.
|
||||
const home = new THREE.Vector3(10.4, 0, 3.4);
|
||||
if (world.shedTable && world.shedTable.pos) {
|
||||
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
|
||||
}
|
||||
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
||||
state.base.copy(home);
|
||||
|
||||
// --- view -----------------------------------------------------------------
|
||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||
const obj = g.scene;
|
||||
const top = obj.getObjectByName('ladder_top');
|
||||
if (top) state.topY = top.position.y;
|
||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = obj;
|
||||
scene.add(obj);
|
||||
syncView();
|
||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
||||
|
||||
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
||||
function syncView() {
|
||||
if (!state.view) return;
|
||||
state.view.visible = !state.carried;
|
||||
state.view.position.copy(state.base);
|
||||
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
|
||||
if (a) {
|
||||
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
||||
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
||||
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
||||
} else {
|
||||
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
||||
}
|
||||
}
|
||||
|
||||
/** The fascia anchor this ladder is currently serving, if any. */
|
||||
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
|
||||
|
||||
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
|
||||
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
|
||||
|
||||
/**
|
||||
* True if the player is up THIS ladder and can work the given anchor.
|
||||
* Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
|
||||
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
|
||||
*/
|
||||
function isWorking(anchorId) {
|
||||
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
|
||||
}
|
||||
|
||||
// --- interactions ---------------------------------------------------------
|
||||
const wired = [];
|
||||
|
||||
// 1. pick the ladder up (from its home, or from wherever it's planted)
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_take',
|
||||
pos: () => (state.carried ? null : state.base),
|
||||
radius: 1.6,
|
||||
holdSecs: 0.8,
|
||||
clip: 'PickUp',
|
||||
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
|
||||
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
state.carried = true;
|
||||
state.placedAt = null;
|
||||
p.pickUp('ladder', t);
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
|
||||
// 2. plant it at a fascia anchor
|
||||
for (const a of anchors) {
|
||||
wired.push(interact.register({
|
||||
id: `ladder_place_${a.id}`,
|
||||
// stand off the wall, on the yard side — the ladder leans in toward the bracket
|
||||
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
|
||||
radius: PLACE_RANGE,
|
||||
holdSecs: 1.0,
|
||||
clip: 'PickUp',
|
||||
label: `set the ladder under ${a.id}`,
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
state.placedAt = a.id;
|
||||
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
||||
a.pos.z + STAND_OFF);
|
||||
p.carrying = null;
|
||||
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
|
||||
syncView();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. climb it — only when it's planted, and only from the ground
|
||||
wired.push(interact.register({
|
||||
id: 'ladder_climb',
|
||||
pos: () => (state.placedAt && !state.carried ? state.base : null),
|
||||
radius: 1.5,
|
||||
holdSecs: 0.4,
|
||||
clip: 'PickUp',
|
||||
label: 'climb',
|
||||
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
|
||||
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
|
||||
// gate (are you on the ground?), and locked states can't start a hold anyway.
|
||||
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
|
||||
onDone: (p, t) => {
|
||||
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
|
||||
const a = servedAnchor();
|
||||
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
|
||||
p.climbTo(workY(), t);
|
||||
},
|
||||
}));
|
||||
|
||||
const api = {
|
||||
get placedAt() { return state.placedAt; },
|
||||
get carried() { return state.carried; },
|
||||
get base() { return state.base; },
|
||||
get topY() { return state.topY; },
|
||||
workY,
|
||||
isWorking,
|
||||
servedAnchor,
|
||||
needsLadder,
|
||||
|
||||
/**
|
||||
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
|
||||
* knockdown already drops climbY to 0 on its own.
|
||||
* player.js calls this each frame from the same input it reads for movement.
|
||||
*/
|
||||
update(dt, t, input) {
|
||||
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
||||
syncView();
|
||||
},
|
||||
|
||||
dispose() {
|
||||
wired.forEach((un) => un());
|
||||
if (interact.ladder === api) interact.ladder = null;
|
||||
if (state.view) scene.remove(state.view);
|
||||
},
|
||||
};
|
||||
|
||||
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
|
||||
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
|
||||
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
|
||||
interact.ladder = api;
|
||||
return api;
|
||||
}
|
||||
@ -149,6 +149,8 @@ function createWindRouter(all) {
|
||||
gustTelegraph: (t) => active.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => active.eventsBetween(a, b),
|
||||
rainAt: (t) => active.rainAt(t),
|
||||
rainMmPerHour: (t) => active.rainMmPerHour(t),
|
||||
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
|
||||
dirAt: (t) => active.dirAt(t),
|
||||
|
||||
setShelters(list) {
|
||||
|
||||
@ -15,9 +15,10 @@
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
|
||||
import { createLadder } from './ladder.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE, clipFor };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -193,7 +194,10 @@ export class PlayerView {
|
||||
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
|
||||
this.play(clipFor(sim), st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
|
||||
// climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
|
||||
// the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
|
||||
// just supplies the arms and legs.
|
||||
this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);
|
||||
|
||||
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
|
||||
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
|
||||
@ -278,6 +282,12 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const keyboard = new KeyboardInput();
|
||||
const { sim, view } = p;
|
||||
|
||||
// The ladder self-wires from here rather than from main.js: createPlayer is already handed the
|
||||
// scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
|
||||
// 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);
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
@ -288,6 +298,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
update(dt, t) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (ladder) ladder.update(dt, t, input);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
@ -296,8 +307,9 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
get object() { return view.root; },
|
||||
sim,
|
||||
view,
|
||||
ladder,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); },
|
||||
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,8 +29,26 @@ export const STATES = {
|
||||
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
|
||||
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
|
||||
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
|
||||
|
||||
// --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
|
||||
// knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
|
||||
// Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
|
||||
climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' },
|
||||
// atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
|
||||
// there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
|
||||
atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
|
||||
};
|
||||
|
||||
/**
|
||||
* True while the player is on a ladder — movement is off, and the wind is meaner.
|
||||
*
|
||||
* Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
|
||||
* so a state-based test would say you'd stepped off the ladder the instant you started the repair
|
||||
* you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the
|
||||
* physical truth and survives every state the ladder passes through.
|
||||
*/
|
||||
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';
|
||||
|
||||
/**
|
||||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||||
@ -96,6 +114,13 @@ export const TUNE = {
|
||||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||||
shelterShoveMult: 0.25, // and it barely pushes you
|
||||
|
||||
// Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
|
||||
// where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
|
||||
// defence is choosing your moment: climb in a lull, not through a gust.
|
||||
climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote
|
||||
reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
|
||||
ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@ -140,6 +165,10 @@ export class PlayerSim {
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.climbY = 0; // m above the ground; >0 means you're up a ladder
|
||||
this.climbTarget = 0; // where ladder.js asked you to be
|
||||
this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.collide = opts.collide || null;
|
||||
this.bodyHeight = opts.height || 1.72;
|
||||
@ -151,6 +180,23 @@ export class PlayerSim {
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
|
||||
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
|
||||
|
||||
/**
|
||||
* Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
|
||||
* a climb is deterministic and fast-forwards in selftest like everything else.
|
||||
* @param {number} y target height above ground; 0 climbs back down
|
||||
*/
|
||||
climbTo(y, t = 0) {
|
||||
this.climbTarget = Math.max(0, y);
|
||||
if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.setState('climb', t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(s, t = 0) {
|
||||
if (this.state === s) return false;
|
||||
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
|
||||
@ -194,11 +240,16 @@ export class PlayerSim {
|
||||
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
|
||||
const m = Math.hypot(x, z) || 1;
|
||||
this.knockDir = { x: x / m, z: z / m };
|
||||
// Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
|
||||
// same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
|
||||
this.fellFrom = this.climbY;
|
||||
this.climbY = 0;
|
||||
this.climbTarget = 0;
|
||||
this.setState('knocked', t);
|
||||
this.exposure = 0;
|
||||
this.vel.x = this.vel.z = 0;
|
||||
this.drop(t);
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
|
||||
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom });
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -225,7 +276,9 @@ export class PlayerSim {
|
||||
this.gust = Math.max(0, ws - this.windBase);
|
||||
|
||||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back, and refused on
|
||||
// a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
|
||||
const up = onLadder(this);
|
||||
const wantShelter = !!input.shelter;
|
||||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||||
@ -233,12 +286,34 @@ export class PlayerSim {
|
||||
const braced = this.state === 'shelter';
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||||
// Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm
|
||||
// speaks one language whether you're on your feet or up a rung. ---
|
||||
const knockAt = T.knockWind
|
||||
* (braced ? T.shelterKnockMult : 1)
|
||||
* (up ? T.ladderKnockMult : 1);
|
||||
if (ws > knockAt) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
// --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
|
||||
if (this.state === 'climb') {
|
||||
const dy = this.climbTarget - this.climbY;
|
||||
const rung = T.climbRate * dt;
|
||||
if (Math.abs(dy) <= rung) {
|
||||
this.climbY = this.climbTarget;
|
||||
this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
|
||||
} else {
|
||||
this.climbY += Math.sign(dy) * rung;
|
||||
}
|
||||
}
|
||||
// Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
|
||||
// releases a finished hold to 'idle' without knowing where you are; this puts you back in the
|
||||
// work stance instead of leaving you idling in mid-air.
|
||||
if (this.climbY > 0.02
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
this.setState('atTop', t);
|
||||
}
|
||||
|
||||
// --- a gust below the knockdown bar can still break your stride ---
|
||||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
@ -248,11 +323,12 @@ export class PlayerSim {
|
||||
}
|
||||
|
||||
const st = STATES[this.state];
|
||||
const aloft = onLadder(this); // re-read: the climb block above may have just landed you
|
||||
|
||||
// --- movement ---
|
||||
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
|
||||
let wantX = 0, wantZ = 0;
|
||||
if (!st.locked) {
|
||||
if (!st.locked && !aloft) {
|
||||
const ix = input.x || 0, iz = input.z || 0;
|
||||
const mag = Math.hypot(ix, iz);
|
||||
if (mag > 1e-3) {
|
||||
@ -305,8 +381,10 @@ export class PlayerSim {
|
||||
const pstep = dt / T.pitchSecs;
|
||||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||||
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
|
||||
if (!st.locked) {
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
|
||||
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
|
||||
// this guard the speed check would immediately re-state you to idle and drop you off. ---
|
||||
if (!st.locked && !aloft) {
|
||||
const sp = this.speed;
|
||||
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
|
||||
} else if (st.secs && this.stateT >= st.secs && st.next) {
|
||||
|
||||
@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) {
|
||||
|
||||
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
||||
const YARD = [
|
||||
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
||||
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
||||
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
||||
['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
|
||||
['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
|
||||
['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
|
||||
].map(([id, type, x, y, z]) => {
|
||||
const pos = { x, y, z };
|
||||
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
||||
@ -59,6 +59,14 @@ const YARD = [
|
||||
return { id, type, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
/**
|
||||
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto
|
||||
* a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the
|
||||
* most twisted quad the band offers. The old one was 6x too big, which is what
|
||||
* made it break under downdraft and made me call the bar unachievable.
|
||||
*/
|
||||
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
||||
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
||||
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
||||
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
||||
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const w = realWind();
|
||||
let peak = 0;
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
||||
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
||||
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
||||
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
||||
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
||||
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
||||
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
||||
// lightest) is what a player does by accident and it survives the storm
|
||||
// having proved nothing; putting it on t2 is the real bet.
|
||||
// An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
|
||||
// ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
|
||||
//
|
||||
// The carabiner goes on p2 because that is where the load actually IS —
|
||||
// measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
|
||||
// Hanging the cheap corner on p1 (the lightest) is what a player does by
|
||||
// accident: it rides the whole storm out and proves nothing. p2 is the real
|
||||
// bet, and it's the one that has to blow for this test to mean anything.
|
||||
const rig = yardRig(
|
||||
['h1', 't2', 'p1', 't1'],
|
||||
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
||||
TWISTED_QUAD, // ['t1','p1','p2','p3']
|
||||
[HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
|
||||
0.85,
|
||||
);
|
||||
const w = realWind();
|
||||
@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
||||
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
||||
// with downdraft 0.3, never without). Lights up by itself on merge.
|
||||
assert(
|
||||
!STORM_02.gusts?.downdraft,
|
||||
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
||||
);
|
||||
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
||||
// Decision 11 landed the downdraft for real, so there is no longer an excuse
|
||||
// for nothing breaking: a vacuous pass here would mean the §7 repair leg —
|
||||
// the sprint's whole definition of done — is checking nothing.
|
||||
assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
|
||||
}
|
||||
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
||||
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
||||
@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
|
||||
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
||||
|
||||
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
||||
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
||||
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
||||
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
||||
// decision 3 asks Lane B for.
|
||||
// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
|
||||
// because a horizontal plate in horizontal wind has almost no drag — which
|
||||
// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
|
||||
// making the wind descend (decision 8: a fraction of TOTAL speed, present
|
||||
// whenever it's windy).
|
||||
//
|
||||
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
|
||||
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
|
||||
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
|
||||
// on p far more than on the downdraft:
|
||||
//
|
||||
// pitch f=0.12 f=0.45 the bar is 60%
|
||||
// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable,
|
||||
// asymptote 109%, would need f=0.86
|
||||
// 4.8° 34.9% 71.5% <- the yard: clears comfortably
|
||||
//
|
||||
// A steeply-pitched reference catches the downdraft nearly as well as a
|
||||
// horizontal one does (its normal is still 96% vertical), so it can never be
|
||||
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
|
||||
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
|
||||
// can't rig proved something true about nothing.
|
||||
const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js
|
||||
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
||||
if (!downdraft) {
|
||||
// Feature-detected rather than hard-failed: this assert is only meaningful
|
||||
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
||||
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
||||
}
|
||||
if (downdraft < 0.5) {
|
||||
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
|
||||
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
|
||||
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
|
||||
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
|
||||
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
|
||||
// gust power — which loads a flat roof steadily without spiking the gust
|
||||
// peak that breaks the twisted rig. That is a weather.core change (joint
|
||||
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
|
||||
// this assert self-skips rather than shipping a red main or a lying bar.
|
||||
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
|
||||
}
|
||||
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
||||
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
||||
// instead would only reshuffle gust TIMING — the direction curve is authored
|
||||
// in the JSON and doesn't move — so it would look like a sweep and measure
|
||||
// nothing about direction.)
|
||||
const sweep = (heights) => {
|
||||
const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
|
||||
assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
|
||||
|
||||
// Footprint sized and pitched like a real quad from the dressed yard, spun
|
||||
// through 8 headings under the real storm. (Re-seeding the wind instead only
|
||||
// reshuffles gust TIMING — the direction curve is authored — so it would look
|
||||
// like a sweep and measure nothing about direction.)
|
||||
const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
|
||||
const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
|
||||
const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
|
||||
const HORIZ = [3.2, 3.2, 3.2, 3.2];
|
||||
|
||||
const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
|
||||
const at = (hs, th) => foot.map(([x, z], i) => {
|
||||
const c = Math.cos(th), s = Math.sin(th);
|
||||
const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
const sweep = (hs) => {
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
||||
// full duration: storm_02's own note says the peak lands just AFTER the
|
||||
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
||||
// southerly change, so a short sweep measures the wrong half of the storm
|
||||
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
const pitched = sweep(HEIGHTS_FLAT);
|
||||
const horizontal = sweep(FLAT_H);
|
||||
|
||||
const pitched = sweep(PITCHED);
|
||||
const horizontal = sweep(HORIZ);
|
||||
const ratio = horizontal / pitched;
|
||||
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
|
||||
@ -21,6 +21,8 @@ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const CALM_SKY = new THREE.Color(0x9fc4e8);
|
||||
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||
const WHITE = new THREE.Color(0xffffff);
|
||||
const FLASH_COL = new THREE.Color(0xdfe8ff);
|
||||
|
||||
// ------------------------------------------------------------ rain shadow
|
||||
/**
|
||||
@ -440,7 +442,13 @@ export function createSkyFx(o = {}) {
|
||||
const skyDef = def.sky || {};
|
||||
const darkness = skyDef.darkness ?? 0.7;
|
||||
const scroll = skyDef.cloudScroll ?? 0.06;
|
||||
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
||||
// The storm data gets a say: `sky.night` is the author's call, the darkness
|
||||
// threshold is only a fallback for storms that never state one.
|
||||
const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
|
||||
// Fire a flash on any gust this strong (m/s of gust power). Storms that don't
|
||||
// ask stay lit only by their authored strikes.
|
||||
const lightningGustPow = skyDef.lightningGustPow ?? Infinity;
|
||||
const firedGusts = new Set();
|
||||
|
||||
const rain = createRain({ groundY: o.groundY ?? 0 });
|
||||
if (scene) scene.add(rain.mesh);
|
||||
@ -510,6 +518,28 @@ export function createSkyFx(o = {}) {
|
||||
*/
|
||||
rainShadowOver(rect) { return shadow.fractionOver(rect); },
|
||||
|
||||
/**
|
||||
* Decision 7's garden-HP drain term, 0..1, in one call — the combined helper
|
||||
* I offered Lane A. This is the whole of "is the bed getting hit right now":
|
||||
*
|
||||
* hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
|
||||
*
|
||||
* 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open
|
||||
* ground. Both terms matter and neither is enough alone: a sail over the bed
|
||||
* in a downpour is 0, and a clear sky with no sail is also 0. It uses the
|
||||
* grid we already rebuild for the rain, so it costs a few array reads.
|
||||
*
|
||||
* Note it moves on its own during storm_02: the rain shadow follows the wind,
|
||||
* so the southerly change walks the dry patch off the bed and the drain
|
||||
* starts climbing without a single corner having failed. That's the mechanic,
|
||||
* not a bug — worth not "fixing" if it looks surprising in the HUD.
|
||||
*/
|
||||
gardenExposure(rect, t) {
|
||||
const rain = wind.rainAt(t);
|
||||
if (rain <= 0) return 0;
|
||||
return rain * (1 - shadow.fractionOver(rect));
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
@ -539,6 +569,24 @@ export function createSkyFx(o = {}) {
|
||||
o.onEvent(ev.text);
|
||||
}
|
||||
}
|
||||
|
||||
// --- lightning on the biggest gusts ---
|
||||
// The storm's worst moments should light up, not only the three authored
|
||||
// strikes. Driven off the telegraph (which is also what the HUD banner and
|
||||
// the audio whoosh read), so the flash lands WITH the gust that earned it.
|
||||
// Deterministic: the gust timeline is, and each gust fires at most once.
|
||||
const tgl = wind.gustTelegraph(t);
|
||||
if (tgl && tgl.power >= lightningGustPow) {
|
||||
// key on the ramp instant — stable across frames, unique per gust
|
||||
const key = Math.round((t + tgl.eta) * 100);
|
||||
if (!firedGusts.has(key)) {
|
||||
firedGusts.add(key);
|
||||
const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06);
|
||||
flashQueue.push({ at: t + tgl.eta, power: p });
|
||||
flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 });
|
||||
flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p });
|
||||
}
|
||||
}
|
||||
for (let i = flashQueue.length - 1; i >= 0; i--) {
|
||||
if (flashQueue[i].at <= t) {
|
||||
const f = flashQueue[i];
|
||||
@ -552,7 +600,7 @@ export function createSkyFx(o = {}) {
|
||||
|
||||
// --- sky ---
|
||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
||||
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
|
||||
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
|
||||
if (scene) {
|
||||
if (scene.fog) {
|
||||
scene.fog.color.copy(skyCol);
|
||||
@ -565,6 +613,22 @@ export function createSkyFx(o = {}) {
|
||||
|
||||
dome.position.copy(camPos);
|
||||
dome.material.opacity = storminess * 0.85;
|
||||
// Tint the CLOUDS, not just the sky behind them. The dome covers the
|
||||
// background at ~0.85 opacity, so darkening `scene.background` alone did
|
||||
// nothing — a "wild night" still read as an overcast afternoon because the
|
||||
// grey cloud texture was what you were actually looking at.
|
||||
//
|
||||
// Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because
|
||||
// it runs in linear space (84% toward night still reads mid-grey in sRGB)
|
||||
// and the cloud texture is baked near-white. The crush is what makes night
|
||||
// look like night. It stops at 0.78 on purpose — the yard has no lights in
|
||||
// it yet, and a storm you can't see isn't a storm, it's a black screen.
|
||||
const nightAmt = storminess * darkness;
|
||||
dome.material.color.copy(WHITE)
|
||||
.lerp(target, nightAmt * 0.92)
|
||||
.multiplyScalar(1 - 0.78 * nightAmt);
|
||||
// and lightning lights the cloud it's inside, which is the whole look
|
||||
if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash));
|
||||
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
|
||||
@ -21,7 +21,10 @@ import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { weatherCases } from './weather.selftest.js';
|
||||
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
||||
// Keep in step with data/storms/. The node runner globs the directory, so this
|
||||
// list going stale shows up here first — as it did when storm_03 landed and the
|
||||
// ponding case reached for a storm the browser half had never loaded.
|
||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly'];
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
@ -213,6 +216,55 @@ export default async function run(t) {
|
||||
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
|
||||
});
|
||||
|
||||
// --- decision 7: the drain helper Lane A wires garden HP to ---
|
||||
t.test('gardenExposure is rain AND no cover, and needs both', () => {
|
||||
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 };
|
||||
|
||||
// no sail: exposure is simply the rain
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {}));
|
||||
const open = sky.gardenExposure(bed, 70);
|
||||
assert(Math.abs(open - wind.rainAt(70)) < 1e-9,
|
||||
`open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`);
|
||||
assert(open > 0.5, 'storm_02 at t=70 should be pouring');
|
||||
|
||||
// a panel over the bed dries it out
|
||||
const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||||
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel }));
|
||||
const covered = sky.gardenExposure(bed, 70);
|
||||
assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`);
|
||||
|
||||
// and no rain means no drain, sail or not
|
||||
assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started');
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => {
|
||||
const mk = (name) => {
|
||||
const scene = new THREE.Scene();
|
||||
const wind = createWind(storms[name]);
|
||||
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
|
||||
let flashes = 0, peak = 0;
|
||||
let was = 0;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
sky.step(dt, time, {});
|
||||
if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike
|
||||
was = sky.flash;
|
||||
peak = Math.max(peak, sky.flash);
|
||||
});
|
||||
sky.dispose();
|
||||
return { flashes, peak };
|
||||
};
|
||||
const wild = mk('storm_02_wildnight');
|
||||
const gentle = mk('storm_01_gentle');
|
||||
// 3 authored strikes + the worst gusts; must be more than the authored ones
|
||||
assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`);
|
||||
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@ -343,6 +343,180 @@ export default async function run(t) {
|
||||
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the ladder (decision 12)
|
||||
// A fake ladder standing in for ladder.js's THREE half: same shape, no GLB, no scene. The real
|
||||
// one is verified by hand in the game; these pin the legs of the state machine.
|
||||
const fakeLadder = (opts = {}) => {
|
||||
const st = { placedAt: opts.placedAt || null, carried: false };
|
||||
return {
|
||||
needsLadder: (a) => !!a && a.type === 'house',
|
||||
workY: () => 2.35,
|
||||
// height only, mirroring the real ladder.js — see the note there on why testing for 'atTop'
|
||||
// here makes the repair cancel its own hold
|
||||
isWorking: (id) => st.placedAt === id && !!opts.player && opts.player.climbY > 2.2,
|
||||
servedAnchor: () => null,
|
||||
get placedAt() { return st.placedAt; },
|
||||
_place: (id) => { st.placedAt = id; },
|
||||
update() {}, dispose() {},
|
||||
};
|
||||
};
|
||||
|
||||
t.test('ladder: climb is code-driven and lands in a work stance', () => {
|
||||
const s = new PlayerSim();
|
||||
assertEq(s.climbY, 0, 'starts on the ground');
|
||||
s.climbTo(2.35);
|
||||
assertEq(s.state, 'climb', 'climbing');
|
||||
assert(s.busy, 'you cannot be interrupted mid-rung');
|
||||
assertEq(clipFor(s), 'ClimbLadder', 'and ClimbLadder plays');
|
||||
drive(s, 0.5);
|
||||
assert(s.climbY > 0.3 && s.climbY < 2.35, `partway up, got ${s.climbY.toFixed(2)}`);
|
||||
drive(s, 3);
|
||||
assertEq(s.state, 'atTop', 'arrives in the work stance');
|
||||
assertClose(s.climbY, 2.35, 1e-6, 'at the top rung');
|
||||
assert(!s.busy, 'atTop must NOT be busy — the whole point is that hold-E works up there');
|
||||
});
|
||||
|
||||
t.test('ladder: the fascia is out of reach from the ground and in reach from the top', () => {
|
||||
const s = new PlayerSim();
|
||||
const FASCIA_Y = 2.48; // measured in the real yard
|
||||
assertLess(s.reachY, FASCIA_Y, 'standing on the ground, a 1.72 m person cannot reach the bracket');
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assert(s.reachY >= FASCIA_Y, `up the ladder they can, reach=${s.reachY.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot walk while you are on it', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
assertEq(s.state, 'atTop');
|
||||
const x0 = s.pos.x, z0 = s.pos.z;
|
||||
drive(s, 1.5, { x: 1, z: 1, run: true, camYaw: 0 });
|
||||
assertClose(s.pos.x, x0, 1e-9, 'WASD does not walk you off a ladder');
|
||||
assertClose(s.pos.z, z0, 1e-9);
|
||||
assertEq(s.state, 'atTop', 'and you stay in the work stance');
|
||||
});
|
||||
|
||||
t.test('ladder: descending returns you to the ground and frees you', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
s.climbTo(0);
|
||||
assertEq(s.state, 'climb', 'going down is the same clip');
|
||||
drive(s, 4);
|
||||
assertEq(s.state, 'idle', 'back on your feet');
|
||||
assertEq(s.climbY, 0);
|
||||
drive(s, 1, { x: 0, z: 1, camYaw: 0 });
|
||||
assertEq(s.state, 'walk', 'and walking again');
|
||||
});
|
||||
|
||||
t.test('ladder: you cannot brace up there — both hands are on the rungs', () => {
|
||||
const s = new PlayerSim();
|
||||
s.climbTo(2.35); drive(s, 4);
|
||||
// a wind under even the ladder's lowered bar, so this isolates the brace refusal from the fall
|
||||
drive(s, 1, { shelter: true }, windX(TUNE.knockWind * TUNE.ladderKnockMult - 4));
|
||||
assertEq(s.state, 'atTop', 'holding C on a ladder does nothing');
|
||||
});
|
||||
|
||||
t.test('ladder: the wind is meaner at height, and being blown off is a FALL', () => {
|
||||
// a wind that is survivable standing must be able to take you off the ladder
|
||||
const between = TUNE.knockWind * TUNE.ladderKnockMult + 2; // over the ladder bar, under the standing one
|
||||
assertLess(between, TUNE.knockWind, 'the test wind must be survivable on the ground');
|
||||
|
||||
const ground = new PlayerSim();
|
||||
drive(ground, TUNE.knockSustain + 0.5, {}, windX(between));
|
||||
assertEq(ground.state, 'idle', 'on your feet this wind is nothing');
|
||||
|
||||
const up = new PlayerSim();
|
||||
up.climbTo(2.35); drive(up, 4);
|
||||
up.carrying = 'spare';
|
||||
drive(up, TUNE.knockSustain + 0.3, {}, windX(between));
|
||||
assertEq(up.state, 'knocked', 'the same wind takes you off the ladder');
|
||||
assertEq(up.climbY, 0, 'you are on the ground now, not floating at height');
|
||||
assertClose(up.fellFrom, 2.35, 1e-6, 'and the fall height is recorded');
|
||||
assertEq(up.carrying, null, 'you dropped the spare on the way down');
|
||||
drive(up, 3);
|
||||
assertEq(up.state, 'idle', 'the ladder gets the normal get-up chain for free');
|
||||
});
|
||||
|
||||
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
|
||||
const L = fakeLadder();
|
||||
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||
'a 4 m post does NOT — you tension it from a cleat at the base');
|
||||
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
|
||||
'nor a tree limb — that is a strop you throw');
|
||||
});
|
||||
|
||||
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } },
|
||||
{ id: 'p1', type: 'post', pos: { x: 0, y: 3.95, z: 0 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }, { anchorId: 'p1', broken: true }];
|
||||
let repaired = [];
|
||||
const it = new Interact();
|
||||
wireYardActions(it, {
|
||||
ladder: L,
|
||||
world: { anchors },
|
||||
sailRig: { corners, repair: (i) => repaired.push(i), trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) },
|
||||
});
|
||||
p.carrying = 'spare';
|
||||
|
||||
// the POST corner: repairable from the ground, exactly as it was before the ladder existed
|
||||
p.pos.x = 0; p.pos.z = 0;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(1), 'post corner still re-rigs from the ground — the ladder changed nothing here');
|
||||
|
||||
// the FASCIA corner: same spare, standing right under it, refused
|
||||
repaired = []; p.carrying = 'spare'; p.pos.x = 0; p.pos.z = 0.9;
|
||||
it.latched = false;
|
||||
const near = it.nearest(p);
|
||||
assert(!near || near.id !== 'rerig_0', 'standing under the bracket is not enough');
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(!repaired.includes(0), 'fascia re-rig refused from the ground');
|
||||
|
||||
// plant the ladder and climb it → now it lands
|
||||
L._place('h2');
|
||||
p.climbTo(2.35); drive(p, 4);
|
||||
assertEq(p.state, 'atTop');
|
||||
p.carrying = 'spare';
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired.includes(0), 'up the ladder, the fascia re-rig lands');
|
||||
assertEq(p.carrying, null, 'and it ate the spare');
|
||||
});
|
||||
|
||||
t.test('ladder: the scripted loop — carry, plant, fetch spare, climb, repair, descend', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } }];
|
||||
const corners = [{ anchorId: 'h2', broken: true }];
|
||||
let repaired = false;
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { ladder: L, world: { anchors },
|
||||
sailRig: { corners, repair: () => { repaired = true; }, trim: () => {},
|
||||
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) } });
|
||||
|
||||
// hands-full: the ladder and the spare compete for the same pair of hands
|
||||
assertEq(p.pickUp('ladder'), true, 'pick the ladder up');
|
||||
assertEq(p.pickUp('spare'), false, 'you cannot also carry a spare — that is the two-trip cost');
|
||||
assertEq(p.drop(), 'ladder', 'put it down');
|
||||
|
||||
// trip 2: the spare, then up
|
||||
assertEq(p.pickUp('spare'), true);
|
||||
L._place('h2');
|
||||
p.climbTo(L.workY()); drive(p, 4);
|
||||
assertEq(p.state, 'atTop', 'up the ladder with the spare');
|
||||
it.latched = false;
|
||||
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(repaired, 'fascia repaired at height');
|
||||
assertEq(p.carrying, null, 'spare consumed');
|
||||
|
||||
// and back down
|
||||
p.climbTo(0); drive(p, 4);
|
||||
assertEq(p.state, 'idle', 'down and free');
|
||||
assertEq(p.climbY, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
@ -476,6 +650,28 @@ export default async function run(t) {
|
||||
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
|
||||
});
|
||||
|
||||
t.test('interact: a hold survives the busy transition it causes', () => {
|
||||
// The bug this pins bit twice while building the ladder, and is invisible: canUse is re-checked
|
||||
// every frame to keep the hold alive, and starting the hold sets state='busy' — so any canUse
|
||||
// reading player.state goes false on frame one and cancels itself. Prompt looks dead, no error.
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
let fired = 0;
|
||||
it.register({ id: 'ok', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.climbY < 0.02, onDone: () => { fired++; } }); // physical gate — fine
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(fired, 1, 'a physically-gated action completes');
|
||||
|
||||
const sim2 = new PlayerSim();
|
||||
const it2 = new Interact();
|
||||
let fired2 = 0;
|
||||
it2.register({ id: 'trap', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
|
||||
canUse: (p) => p.state === 'idle', onDone: () => { fired2++; } }); // state gate — the trap
|
||||
fixedLoop(1, DT, (dt, tt) => it2.step(dt, tt, sim2, true));
|
||||
assertEq(fired2, 0,
|
||||
'a state-gated canUse cancels its own hold — documented on register(); gate on physical facts');
|
||||
});
|
||||
|
||||
t.test('interact: canUse() gates on the carrying flag', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
|
||||
@ -75,8 +75,10 @@ const ASSETS = [
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
|
||||
nodes: ['stump', 'head', 'hat', 'shards'] },
|
||||
{ name: 'fence_panel_broken', h: [1.70, 1.90],
|
||||
{ name: 'fence_panel_snapped', h: [1.70, 1.90],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
{ name: 'broom_01', h: [1.35, 1.50],
|
||||
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -268,7 +270,7 @@ export default async function run(t) {
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_broken']]) {
|
||||
['fence_panel', 'fence_panel_snapped']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
// call it with the fetched storm defs.
|
||||
|
||||
import {
|
||||
createWindField, validateStorm, gustEnvelope, GUST,
|
||||
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||
} from '../weather.core.js';
|
||||
|
||||
const DT = 1 / 60;
|
||||
@ -405,6 +405,105 @@ export function weatherCases(storms) {
|
||||
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
|
||||
});
|
||||
|
||||
// ---- 10. the ponding story (SPRINT4 decision 10) ----
|
||||
// Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story
|
||||
// their mass model needs, using their own arithmetic, before their cloth lands.
|
||||
// B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a
|
||||
// storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic.
|
||||
const FLAT_AREA = 25; // m², B's reference flat sail
|
||||
const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner
|
||||
/** Water on a flat sail over a whole storm, at decision 10's compression. */
|
||||
const stormDepthMm = (def) => {
|
||||
const f = createWindField(def);
|
||||
return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION;
|
||||
};
|
||||
/** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */
|
||||
const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000;
|
||||
|
||||
test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => {
|
||||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||||
metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1);
|
||||
metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2);
|
||||
metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2);
|
||||
metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3);
|
||||
|
||||
assert(wild >= KILL_DEPTH_MM * 0.9,
|
||||
`storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`);
|
||||
// and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner)
|
||||
assert(kNPerCorner(wild) > 1.5,
|
||||
`storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`);
|
||||
// the gentle storm must be unable to hurt anything, or the ramp is a lie
|
||||
assert(kNPerCorner(gentle) < 0.3,
|
||||
`storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`);
|
||||
assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower');
|
||||
});
|
||||
|
||||
test('storm_03 ponding sits between the other two', () => {
|
||||
const mid = stormDepthMm(storms.storm_03_southerly);
|
||||
const wild = stormDepthMm(storms.storm_02_wildnight);
|
||||
const gentle = stormDepthMm(storms.storm_01_gentle);
|
||||
metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1);
|
||||
metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2);
|
||||
assert(mid > gentle * 3 && mid < wild * 0.5,
|
||||
`storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`);
|
||||
// it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2)
|
||||
assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2,
|
||||
`storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`);
|
||||
});
|
||||
|
||||
test('rain depth integrates monotonically and matches its curve', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
// depth only ever accumulates
|
||||
let prev = 0;
|
||||
for (let t = 1; t <= f.duration; t += 1) {
|
||||
const d = f.rainDepthMm(0, t);
|
||||
assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`);
|
||||
prev = d;
|
||||
}
|
||||
// splitting the interval must give the same water (no double-count, no gap)
|
||||
const whole = f.rainDepthMm(0, 90);
|
||||
const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90);
|
||||
assert(Math.abs(whole - split) < 0.05,
|
||||
`depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`);
|
||||
// dead calm before the rain starts
|
||||
assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water');
|
||||
assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water');
|
||||
});
|
||||
|
||||
test('rainMmPerHour tracks rainAt against the storm scale', () => {
|
||||
for (const [name, def] of defs) {
|
||||
if (!def.rain || !def.rain.curve) continue;
|
||||
const f = createWindField(def);
|
||||
const peak = def.rain.peakMmPerHour;
|
||||
assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`);
|
||||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||||
const want = f.rainAt(t) * peak;
|
||||
assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9,
|
||||
`${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`);
|
||||
}
|
||||
// rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity
|
||||
for (let t = 0; t <= f.duration; t += 2.5) {
|
||||
const r = f.rainAt(t);
|
||||
assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('validator rejects a rain curve with no scale, and a silly scale', () => {
|
||||
const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
delete noScale.rain.peakMmPerHour;
|
||||
assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default');
|
||||
for (const mm of [-1, 5000, NaN]) {
|
||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
d.rain.peakMmPerHour = mm;
|
||||
assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`);
|
||||
}
|
||||
const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
hot.rain.curve = [[0, 0], [45, 3], [90, 0]];
|
||||
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -132,6 +132,25 @@ function sampleAngleCurve(curve, t) {
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export const DEFAULT_DOWNDRAFT = 0.22;
|
||||
|
||||
/** Fallback rain scale, mm/hr at rainAt()==1. Storms should state their own. */
|
||||
export const DEFAULT_PEAK_MM_PER_HOUR = 40;
|
||||
|
||||
/**
|
||||
* SPRINT4 decision 10 — the time-compression fiat, in ONE place.
|
||||
*
|
||||
* Game rain accumulates ~40× real time. A 90 s storm is canonically a whole
|
||||
* night (storm_02 telegraphs its change "around the hour mark"), so it should
|
||||
* deliver a night's water: 90 s × 40 = 3600 s = one hour of rain. At storm_02's
|
||||
* 80 mm/hr peak that lands ~5 cm on a flat sail — exactly Lane B's measured kill
|
||||
* threshold (5 cm over 25 m² = 1250 kg = 3.1 kN/corner) against a wind load of
|
||||
* only 0.2–1.1 kN. Ponding is 3–15× everything else, and it cannot pincer §7
|
||||
* because a hypar has no flat to pool in.
|
||||
*
|
||||
* Exported so Lane B applies it cloth-side rather than either of us hardcoding
|
||||
* 40 twice: how hard it rains is Lane C, how much water a sail holds is Lane B.
|
||||
*/
|
||||
export const RAIN_TIME_COMPRESSION = 40;
|
||||
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
@ -378,6 +397,45 @@ export function createWindField(def, opts = {}) {
|
||||
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
|
||||
return Math.min(1, Math.max(0, r.intensity ?? 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
|
||||
* renderer doesn't want millimetres.
|
||||
*
|
||||
* This exists for ponding (decision 10). Without it Lane B has to invent the
|
||||
* mm/hr scale to turn intensity into water mass, which is exactly the
|
||||
* "default-off code tuned by a constant I invented" they rightly reverted.
|
||||
* The scale is storm data, so it lives here: `rain.peakMmPerHour`.
|
||||
* For reference: 8 = light shower, 30 = moderate, 50 = heavy, 80+ = severe.
|
||||
*/
|
||||
rainMmPerHour(t) {
|
||||
const r = def.rain;
|
||||
if (!r) return 0;
|
||||
return field.rainAt(t) * (r.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR);
|
||||
},
|
||||
|
||||
/**
|
||||
* Real-world water depth in mm delivered over (t0, t1]. Deterministic
|
||||
* trapezoid over the curve — no compression applied.
|
||||
*
|
||||
* Lane B multiplies by RAIN_TIME_COMPRESSION cloth-side: how much water a
|
||||
* SAIL holds is theirs, how hard it rains is mine. Handy shape for a HUD
|
||||
* "water delivered" readout too.
|
||||
*/
|
||||
rainDepthMm(t0, t1, stepS = 0.25) {
|
||||
if (!(t1 > t0)) return 0;
|
||||
let mm = 0;
|
||||
const n = Math.max(1, Math.ceil((t1 - t0) / stepS));
|
||||
const h = (t1 - t0) / n;
|
||||
let prev = field.rainMmPerHour(t0);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const cur = field.rainMmPerHour(t0 + i * h);
|
||||
mm += (prev + cur) * 0.5 * h / 3600; // mm/hr × seconds → mm
|
||||
prev = cur;
|
||||
}
|
||||
return mm;
|
||||
},
|
||||
};
|
||||
|
||||
return field;
|
||||
@ -446,6 +504,21 @@ export function validateStorm(def, name = 'storm') {
|
||||
}
|
||||
|
||||
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
|
||||
if (def.rain) {
|
||||
if (def.rain.curve && isCurve(def.rain.curve)
|
||||
&& def.rain.curve.some((p) => p[1] < 0 || p[1] > 1)) {
|
||||
bad('rain.curve intensity must be 0..1 — the physical scale is rain.peakMmPerHour');
|
||||
}
|
||||
const mm = def.rain.peakMmPerHour;
|
||||
if (mm != null && (!Number.isFinite(mm) || mm < 0 || mm > 300)) {
|
||||
bad(`rain.peakMmPerHour must be 0..300 mm/hr (8 light, 30 moderate, 50 heavy, 80+ severe) — got ${mm}`);
|
||||
}
|
||||
// Ponding reads this; a storm that rains with no scale silently ponds at the
|
||||
// default instead of what its author meant.
|
||||
if (def.rain.curve && mm == null) {
|
||||
bad('rain.curve without rain.peakMmPerHour — ponding needs the mm/hr scale (SPRINT4 decision 10)');
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
// determinism rule can't be broken by accident.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { createWindField, validateStorm, GUST } from './weather.core.js';
|
||||
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
||||
|
||||
export { GUST, validateStorm };
|
||||
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
@ -84,9 +84,16 @@ export function createWind(def, opts = {}) {
|
||||
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
||||
|
||||
/** 0..1 rain intensity. */
|
||||
/** 0..1 rain intensity — drives drop count and opacity. */
|
||||
rainAt(t) { return field.rainAt(t); },
|
||||
|
||||
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
||||
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
||||
|
||||
/** Real-world mm of water delivered over (t0,t1]. Multiply by
|
||||
* RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
|
||||
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
|
||||
|
||||
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
||||
dirAt(t) { return field.dirAt(t); },
|
||||
|
||||
|
||||
BIN
web/world/models/broom_01_v1.glb
Normal file
BIN
web/world/models/broom_01_v1.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/pond_normal.png
Normal file
BIN
web/world/models/textures/pond_normal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
web/world/models/textures/pond_water.png
Normal file
BIN
web/world/models/textures/pond_water.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Loading…
Reference in New Issue
Block a user