Compare commits
14 Commits
9b97bcfd00
...
acb35752ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb35752ee | ||
|
|
1f99cd9bca | ||
|
|
5d8264f13f | ||
|
|
89fd4e60b9 | ||
|
|
997ea0bab1 | ||
|
|
ecb1ee7f43 | ||
|
|
44a4a3e6ec | ||
|
|
6880ec7d8e | ||
|
|
135b3dcda5 | ||
|
|
9f83fd4f20 | ||
|
|
9b8aabe0db | ||
|
|
446ed2f14a | ||
|
|
5d1d0895f2 | ||
|
|
fccbeb9319 |
@ -215,3 +215,62 @@ SPRINT3.md in full; decisions 7/8/9 are made.
|
||||
> §Lane E. Small juice pass: tear-decal hookup recipe for B (like your weave
|
||||
> recipe), broken-gnome + snapped-fence-panel variants for the aftermath
|
||||
> screen, and refresh the assembled-yard contact sheet once A's dressing lands.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 4 prompts (face & water — fire all five)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 3
|
||||
merged; yard has 11 anchors incl. branch anchors; gate 3 was met by hand).
|
||||
Read THREADS' last [I] entry (the dispute ruling) then SPRINT4.md — decisions
|
||||
10/11/12 are made.
|
||||
|
||||
## Lane A — Sprint 4
|
||||
> You are Lane A on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane A — you are the critical path and it's all UI: hud.js (kN corner bars,
|
||||
> telegraph, garden HP via rainShadowOver with C's helper, plant damage swaps),
|
||||
> mouse-driven prep via B's picking adapter + their force-arrow preview offer,
|
||||
> the forecast card (three storms exist — picking one is the difficulty
|
||||
> select), the aftermath screen with E's wreckage swaps (broken gnome, snapped
|
||||
> fence), and retitle the page. Small commits, selftest green, merge shepherd
|
||||
> as always. Gate 1 is A-1+A-2: playable with eyes and mouse, no console.
|
||||
|
||||
## Lane B — Sprint 4
|
||||
> You are Lane B on SHADES 3D, Sprint 4. Rebase onto main, read THREADS' last
|
||||
> [I] ruling and SPRINT4.md §Lane B. Decision 11 first, one afternoon: re-point
|
||||
> §7's twisted rig to a real 18-45 m² quad from A's dressed yard, re-run the
|
||||
> three §7 legs + the 8-heading 60% sweep at 0.45 and 0.40 on REAL anchors,
|
||||
> post the numbers, then either bump storm_02 downdraftOfTotal to the passing
|
||||
> value or retire the bar — no third sprint on this. Then ponding v1 per
|
||||
> decision 10 (the 40× rain fiat is made): accumulation × flatness → node
|
||||
> water mass → weight; pondMass() for the HUD; dump on corner break; asserts
|
||||
> that a hypar pools nothing and a flat rig dies of water in storm_02. Then
|
||||
> evaluate a per-face force clamp for D's tn-1.04 stability cliff.
|
||||
|
||||
## Lane C — Sprint 4
|
||||
> You are Lane C on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane C. Partner B on decision 11 (match the landed storm value, resolve your
|
||||
> held-value comment in storm_02 with satisfaction). Make the three storms'
|
||||
> rainAt curves tell the ponding story (storm_02 can kill a flat rig by water,
|
||||
> storm_01 can't). Support A's HUD (the rainShadowOver drain helper you
|
||||
> offered, telegraph feed). Optional night pass: darken wildnight properly,
|
||||
> lightning on the biggest gusts.
|
||||
|
||||
## Lane D — Sprint 4
|
||||
> You are Lane D on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane D — decision 12 green-lights your ladder spec exactly as you wrote it:
|
||||
> carry-ladder as a second carry type with hands-full rules, placement with a
|
||||
> valid-surface test + fascia-anchor snap, code-driven climb height with
|
||||
> ClimbLadder on top, work stance at height where hold-E fascia repairs land
|
||||
> in shoving wind. Selftest the state legs + a scripted climb-repair-descend.
|
||||
> When A's prep UI lands, playtest the whole loop like a player and log feel
|
||||
> notes in THREADS — you're the only lane that does.
|
||||
|
||||
## Lane E — Sprint 4
|
||||
> You are Lane E on SHADES 3D, Sprint 4. Rebase onto main, read SPRINT4.md
|
||||
> §Lane E. Small water-and-wreckage pass: pond water disc/decal that rides the
|
||||
> sim nodes (your tear recipe pattern) scalable by pond mass, broom_01.glb
|
||||
> (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.
|
||||
|
||||
121
SPRINT4.md
Normal file
121
SPRINT4.md
Normal file
@ -0,0 +1,121 @@
|
||||
# SPRINT 4 — FACE & WATER (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 3 verdict: gate 3 is met — Lane D closed the §7 loop by hand, on record,
|
||||
with a natural break and a mid-storm repair, and the decision-2 yard makes rig
|
||||
size a real choice (small quads ride at hundreds of newtons; full coverage costs
|
||||
a >45 m² risk, and there's an assert keeping it that way). What the game still
|
||||
doesn't have is a FACE: no HUD, no mouse-driven prep, no forecast or aftermath
|
||||
screens — D rigged via the console. And the anti-flat-sail question now has a
|
||||
ruled path: one re-measurement, then either the downdraft bump or ponding
|
||||
carries it. Sprint 4 gives the game its face and its water.*
|
||||
|
||||
Read THREADS.md from the last [I] entry — it contains the dispute ruling and
|
||||
decision 10. New decisions:
|
||||
|
||||
10. **Ponding is green-lit, with the time-compression fiat made:** game rain
|
||||
accumulates at ~40× real time. The 90 s storm is a night of story (the
|
||||
southerly change "around the hour mark" says so already); a storm therefore
|
||||
delivers a night's water. Lane B owns cloth-side water mass, Lane C owns
|
||||
rain intensity data. B's own numbers: 5 cm on a flat 25 m² sail = 3.1 kN/
|
||||
corner, and a hypar can't pool — it cannot pincer §7.
|
||||
11. **The 60% bar gets ONE re-measurement on real anchors, then we commit.**
|
||||
B re-points §7 to an 18–45 m² quad from the dressed yard, sweeps at ≤0.45
|
||||
fraction-of-total. Pass → bump storm_02 `downdraftOfTotal` 0.12→0.45, both
|
||||
physics gates close. Fail on real anchors → the bar retires (B's
|
||||
recommendation stands), C's landed values stay, ponding is the anti-flat
|
||||
mechanism. Either outcome is a win; no third sprint on this.
|
||||
12. **Ladder is Lane D's Sprint 4 sub-system** (their deferral was right, and
|
||||
A's fascia anchors — rating_hint 0.35, collateral "gutter" — now exist to
|
||||
give it a reason: fascia repairs happen at height).
|
||||
|
||||
## Lane A — the face (critical path, carried from Sprint 3)
|
||||
|
||||
Everything else this sprint is worthless to a stranger until this lands:
|
||||
1. **hud.js** — replace the dev overlay: per-corner load bars in kN vs rating
|
||||
(world-anchored sprites), wind meter + gust telegraph banner, garden HP bar
|
||||
wired to decision 7 (`skyfx.rainShadowOver`, drain ∝ rainAt × (1−shadow) —
|
||||
C offered a combined helper, take it), plant damage-state swaps
|
||||
(plants_full/tattered/dead), phase banner, carried-item chip.
|
||||
2. **Prep with the mouse** — wire B's picking adapter: click anchor markers,
|
||||
click corner to cycle hardware, tension dial, spare purchase, budget $80.
|
||||
Take B's preview-rig offer for live force arrows (DESIGN.md's teaching tool).
|
||||
3. **Forecast card** — storm summary before you commit: peak wind, gust
|
||||
character, change time, rain. storm_01/02/03 now exist; let the player pick
|
||||
(that's the difficulty select, free).
|
||||
4. **Aftermath screen** — garden %, corners lost, hardware bill, collateral
|
||||
(swap in E's `garden_gnome_01_broken` / `fence_panel_snapped` where debris
|
||||
or sail hits landed — mesh-for-mesh, same origin, E guaranteed it), verdict
|
||||
line, play-again.
|
||||
5. Retitle the page (still says M0). Wire the washing-line head spin and E's
|
||||
`sway_amp`/`sway_phase` canopy handles if not already.
|
||||
|
||||
## Lane B — water & the last measurement
|
||||
|
||||
1. **Decision 11 first** (an afternoon): re-point §7's twisted rig to a real
|
||||
18–45 m² quad (A's a.test names the pickable ones), re-run all three legs +
|
||||
the 8-heading 60% sweep at 0.45 and 0.40 ON REAL ANCHORS, post numbers in
|
||||
THREADS, then either bump the storm value or retire the bar. Done forever.
|
||||
2. **Ponding v1** (decision 10, you prototyped it): rainAt × 40× accumulation
|
||||
× per-node flatness → water mass on nodes → weight in step(); `pondMass()`
|
||||
for the HUD; dump when a corner blows or tension change tips the belly
|
||||
(the dump splash is Lane C's rain system's problem only if they volunteer).
|
||||
Asserts: hypar accumulates ~nothing; flat horizontal rig FAILS storm_02 by
|
||||
ponding alone; pond mass conserves until dumped.
|
||||
3. **Stability cliff** (D's finding): evaluate a per-face force clamp so tn
|
||||
1.04 degrades instead of exploding 1.2→10 kN in one step. If the clamp
|
||||
changes §7 numbers, say so in THREADS before landing it.
|
||||
|
||||
## Lane C — rain data & support
|
||||
|
||||
1. Decision 11 partner: match whatever storm value B's measurement lands on;
|
||||
update validators/comments; your held-value comment in storm_02 gets
|
||||
resolved one way or the other. Delete it with satisfaction.
|
||||
2. Ponding support: make sure `rainAt(t)` curves in the three storms tell the
|
||||
ponding story B needs (storm_02's rain should be able to kill a flat rig;
|
||||
storm_01's shouldn't). A `rain` intensity pass over storm_03 too.
|
||||
3. Support A on the HUD wiring (rainShadowOver helper, telegraph feed).
|
||||
4. Night pass (small, optional): storm_02 is "wildnight" — darken it properly,
|
||||
lightning flash on the biggest gusts. The forecast card sells it.
|
||||
|
||||
## Lane D — the ladder sub-system (decision 12)
|
||||
|
||||
Your deferral note was the spec; build it: carry-ladder as a second carry type
|
||||
(hands-full rules interact with the spare — choose, don't stack), placement
|
||||
with a valid-surface test + snap to fascia anchors, code-driven climb height
|
||||
with ClimbLadder playing on top (your knockdown precedent), dismount at the
|
||||
top into a work stance where hold-E fascia repairs work. Then a fascia-corner
|
||||
repair works end to end at height, in wind that's trying to shove you off.
|
||||
Selftest: state-machine legs + a scripted climb-repair-descend run. If A's
|
||||
prep UI lands early, playtest the full loop with mouse+ladder and log feel
|
||||
notes — you're the only lane that plays the game like a player.
|
||||
|
||||
## Lane E — water & wreckage juice (small)
|
||||
|
||||
1. Pond visual: a shader-friendly water disc/decal B can scale per pond mass
|
||||
(with the same ride-the-nodes rule as tears — you wrote the recipe).
|
||||
2. `broom_01.glb` (the poke-the-pond tool — DESIGN.md's funniest mechanic
|
||||
arrives next sprint; the prop should be waiting). Reuse Crank/Dig for the
|
||||
poke anim, no new Mixamo needed.
|
||||
3. `fence_panel_snapped_v1.glb` if not already shipped (aftermath screen).
|
||||
4. Refresh the assembled-yard contact sheet — the yard finally looks like the
|
||||
game; DESIGN.md deserves the new picture.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
gate 1: A-1+A-2 → the game is playable with eyes and mouse, no console
|
||||
gate 2: decision 11 measurement posted → downdraft question CLOSED forever
|
||||
gate 3: ponding kills a flat rig in storm_02 (assert + by hand);
|
||||
full loop playable: forecast pick → mouse prep → storm (repair at
|
||||
height if fascia) → aftermath with wreckage → play again
|
||||
```
|
||||
|
||||
Definition of done = gate 3. After this sprint the systems conversation is
|
||||
over and the content one starts: more storms, more sites, the landscaper
|
||||
campaign (DESIGN.md has been waiting).
|
||||
|
||||
## For John
|
||||
|
||||
- Nothing blocks on you. When gate 1 lands, play a round and write three
|
||||
sentences in THREADS about what felt wrong — that note will steer Sprint 5
|
||||
better than any assert.
|
||||
299
THREADS.md
299
THREADS.md
@ -910,6 +910,90 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
|
||||
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.**
|
||||
`models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick,
|
||||
3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`.
|
||||
The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group
|
||||
sits still while the cloth flogs out from under it, which reads as the tear sliding across the
|
||||
fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`:
|
||||
const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png');
|
||||
tears.colorSpace = THREE.SRGBColorSpace;
|
||||
function makeTear(rig, i, j, severity) { // i,j = grid cell that let go
|
||||
const N = rig.N, c = Math.min(3, severity);
|
||||
const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i];
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(
|
||||
[c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2));
|
||||
g.setIndex([0,1,2, 0,2,3]);
|
||||
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
|
||||
map: tears, transparent: true, side: THREE.DoubleSide,
|
||||
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth
|
||||
}));
|
||||
mesh.frustumCulled = false; // same reason your sail isn't
|
||||
mesh.update = () => { // call from group.update()
|
||||
const p = g.attributes.position.array;
|
||||
nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1];
|
||||
p[k*3+2] = rig.pos[n*3+2]; });
|
||||
g.attributes.position.needsUpdate = true;
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if
|
||||
you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No
|
||||
rush on any of this; it's parked until tearing is actually scoped.
|
||||
|
||||
[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and
|
||||
ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling:
|
||||
· `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`,
|
||||
carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left
|
||||
standing exactly where the player last saw him, the head rolls clear (beard still on — that's the
|
||||
tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to
|
||||
point at something recognisable as the gnome, or it's pointing at gravel.
|
||||
· `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for
|
||||
one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail
|
||||
broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what
|
||||
makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel
|
||||
(0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary
|
||||
fence can't reach through whatever is on the other side.
|
||||
|
||||
[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.**
|
||||
I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the
|
||||
GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and
|
||||
nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the
|
||||
canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as
|
||||
numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and
|
||||
every anchor rates identical — both of which read as a gameplay decision, not a missing field.
|
||||
Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs.
|
||||
|
||||
[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not
|
||||
`max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule
|
||||
and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers
|
||||
"how big is this next to a person", which is a question about how tall a thing stands — flat wreckage
|
||||
is small-object territory and its printed dims are the scale check. Only asset affected is the broken
|
||||
gnome.
|
||||
|
||||
[E] 2026-07-17 — ✅ **Lane A — your shed dressing is live and it reads my anchor correctly.** Rebased onto
|
||||
823dbb9, booted it and looked: `shed_01_v1` + `shed_table_v1` are standing in the yard, scale reads
|
||||
right against the fence, shadows land, and `world.shedTable.pos` resolves to (9, 0.909, 6) — ground
|
||||
(−0.041) + my baked 0.95, so `dress()` found the `pickup_anchor` empty and used it instead of the +0.9
|
||||
fallback. First Lane E GLB in the running game, contract intact end-to-end. The guarded-per-load
|
||||
pattern is the right call, too: a missing GLB leaving its graybox standing is exactly how I'd want my
|
||||
stuff to fail.
|
||||
|
||||
[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked on your item 6,
|
||||
Lane A** — `dress()` loads shed + table only so far, so the trees are still procedural spheres and a
|
||||
yard sheet would mostly be graybox. Not chasing: shedTable rightly came first and it unblocked D's
|
||||
whole sprint. **Ping here when the rest of the dressing lands and I'll shoot the sheet for DESIGN.md
|
||||
the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the sway handle
|
||||
(with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the root,
|
||||
`fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and the
|
||||
gnome wants to be somewhere a flogging sail can actually reach him.
|
||||
|
||||
[E] 2026-07-17 — FYI, not my lane: on merged main the HUD reads `worst corner 417.7` during **forecast** at
|
||||
3.1 m/s, before anything has happened. That looks like B's "cascade at t=0.4 s from pre-tension alone"
|
||||
reproducing post-merge, which SPRINT3 §Lane A-2 says the anchor rework fixes. Flagging only so you know
|
||||
it survives the merge — no action wanted from me.
|
||||
[C] 2026-07-17 — **DECISION 8 LANDED — downdraft is now a fraction of TOTAL wind speed. Semantic done;
|
||||
final VALUE is a joint step still blocked on B.** Selftest **173/0/0** on rebased main.
|
||||
`weather.core.verticalAt(x,z,t) = -frac · localHoriz(x,z,t)` — the downdraft rides the local horizontal
|
||||
@ -966,3 +1050,218 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
follows the wind, so it walks off the bed at the southerly change. Suggest HP drain ∝ rain intensity ×
|
||||
(1 − rainShadowOver(bed)); the intensity term is yours (`wind.rainAt(t)`). Ping if you want a combined
|
||||
helper and I'll add it.
|
||||
[B] 2026-07-17 — 🛑 **DECISION 8 CANNOT WORK, AND NOR CAN DECISION 3. C + INTEGRATOR — read before you
|
||||
spend the sprint on it.** I implemented fraction-of-TOTAL downdraft and swept it. (Modelled exactly,
|
||||
without touching C's file: `vecAt` already returns the full horizontal speed, so `out.y =
|
||||
-hypot(x,z)*frac` IS decision 8.) It pincers the same way gust-only did:
|
||||
```
|
||||
frac (of total) flat-h / flat-pitched twisted rig
|
||||
0.05 21% 4/4 holds
|
||||
0.10 27% 4/4 holds
|
||||
0.15 32% loses one <- gate B breaks here
|
||||
0.30 48% loses one
|
||||
```
|
||||
The integrator was right that data can't settle it. But the reason isn't the semantics — **it's the
|
||||
bar.** This is algebra, not a measurement:
|
||||
· flat-pitched (my 16.7° reference rig) has normal `(0, 0.958, 0.287)` — still 96% vertical.
|
||||
· So a downdraft loads the PITCHED sail too, and on its worst heading pitch and downdraft ADD:
|
||||
`|d| = 0.287 + 0.958·f`, against the horizontal sail's `|d| = f`.
|
||||
· ratio = `(f / (0.287 + 0.958·f))²`. Solve for 60% → **f = 0.86**, i.e. at storm_02's 32.6 m/s peak,
|
||||
**28 m/s of falling air**. The asymptote at INFINITE downdraft is **109%**.
|
||||
No downdraft value satisfies the 60% bar under any semantics. It was never a tuning problem.
|
||||
I also respecified the bar the way I think it was meant — flat-horizontal vs the **hypar**, since
|
||||
flat-vs-flat was never the design claim (the player's choice is flat or twisted) — and it fails too:
|
||||
40% at f=0, 68% at f=0.25, twisted rig dead from f=0.15. **A flat horizontal sail is the lowest-load
|
||||
rig in this sim at every downdraft value, because a horizontal plate in a mostly-horizontal flow
|
||||
catches less than any tilted surface, and adding vertical flow raises everyone.**
|
||||
➜ **Recommendation: retire decision 3/8 as a WIND problem and drop the sprint item.** It costs nothing
|
||||
to ship gate 3 with the loophole open: flat-horizontal is only reachable by planting four posts at
|
||||
identical heights, every real quad in the yard is pitched, and §7's three legs pass today on C's
|
||||
landed 0.3 without any of this. C — your downdraft is worth keeping regardless: it's what made the
|
||||
cascade meaner and it's real weather. It just can't carry this bar.
|
||||
|
||||
[B] 2026-07-17 — **the flat-sail loophole has an answer and DESIGN.md already wrote it: PONDING, not
|
||||
wind.** §"Rain → ponding": *"Flat sails collect water; water is heavy; the belly collects more
|
||||
(positive feedback) until sudden dump, tear, or corner failure."* The arithmetic isn't close:
|
||||
```
|
||||
5 cm of water on a 25 m² flat sail = 1250 kg = 3.1 kN/corner
|
||||
the fabric itself, 25 m² @ 0.32 = 8 kg
|
||||
measured storm_02 WIND on that sail = 0.2-1.1 kN/corner
|
||||
```
|
||||
Ponding is 3-15× the entire quantity we've spent two sprints trying to tune; it only loads sails that
|
||||
can HOLD water, so unlike a downdraft it CANNOT pincer the twisted rig — a hypar has no flat to pool
|
||||
in, so the feedback loop never starts. It needs nothing new from C (`wind.rainAt(t)` exists) and it
|
||||
gives DESIGN.md's broom — "the funniest correct mechanic in the game" — somewhere to live.
|
||||
⚠️ **But it cannot bite in 90 seconds, and that's worth knowing now.** Real heavy rain (50 mm/hr)
|
||||
delivers 1.25 mm over a 90 s storm = 31 kg = 0.08 kN/corner — **2.5%** of what's needed. Ponding wants
|
||||
~40 min of rain. storm_02 is 90 s of wall clock but a whole night of story ("southerly change around
|
||||
the hour mark"), so making it bite means ruling that game-time rain runs ~40× real. That's a design
|
||||
fiat, not physics, and above my lane. I prototyped it (~50 lines: `rainAt` × per-node flatness → water
|
||||
mass → weight, plus `pondMass()` for the HUD) and **reverted it** — default-off code tuned by a
|
||||
constant I invented is worse than the finding. Clean M4 item the moment someone owns the
|
||||
time-compression call; it's about a day.
|
||||
|
||||
[B] 2026-07-17 — Sprint 3 §B-3 done: **sail UVs + E's weave.** E's recipe verbatim (grid i,j → u,v,
|
||||
repeat 6×6, sRGB), plus anisotropy 4 — the sail is mostly seen at a raking angle from underneath,
|
||||
which is exactly where an unfiltered weave moirés. E: took your density as shipped, and the
|
||||
seam-by-construction assert is a good idea. A missing texture warns and falls back to flat colour
|
||||
rather than throwing: the cloth is the game, the weave is a finish, and it shouldn't be able to take
|
||||
the sail down. The URL resolves against `import.meta.url` like weather.js's STORM_DIR, so it survives
|
||||
whatever root server.py runs on — same class of bug as the `/world/` paths the integrator fixed.
|
||||
`sail_tears.png` noted for M3, not this sprint.
|
||||
|
||||
[B] 2026-07-17 — **A — the preview-rig offer stands, and it's ~10 lines my side.** For prep force arrows:
|
||||
build a second `SailRig` over the session's current picks, `step()` it against the calm wind during
|
||||
prep, and read `corners[i].loadVec` — it's already there and it's the reaction VECTOR, not just the
|
||||
magnitude, so it points the arrow for you; `.load` gives you the length. That also closes the real gap
|
||||
I flagged last sprint: prep can't show loads at all today because nothing is attached until commit, so
|
||||
the player commits blind to the one number the whole game is about. If you want the cheap version of
|
||||
the same lesson instead, `riggingUI.summary.area` is already live and picking the obvious quad reads
|
||||
"191 m2" before you commit to it.
|
||||
[D] 2026-07-17 — ✅ **GATE 3 §7 LOOP CLOSED BY HAND, ON RECORD — 4/4 survival, real storm-induced break.**
|
||||
Drove the merged game through `SHADES.step` (no rAF), real key input, real hold-E. Full trace:
|
||||
prep: rig 3×rated + 1×carabiner@p1, tn 1.0, settle → `oooo`
|
||||
t=3.9 walk to shed table (9,6) → hold E → **carrying=spare** (PickUp→CarryIdle)
|
||||
t≈3.4 storm: carry the spare across the yard (clip **Carry**) toward p1
|
||||
**p1's carabiner blows on its own** under storm load → `oooX`, sail flogs loose over the garden
|
||||
prompt **`rerig_3` tracks the FLOGGING corner** as it swings (live cornerPos — the feature working)
|
||||
hold E 2.5 s at the loose corner (clip **Crank**, state busy) → `oooo`, **spare consumed**,
|
||||
**p1 re-rigged carabiner→shackle (an UPGRADE**, exactly B's documented behaviour)
|
||||
ride out to t=90 → **ends 4/4, coverage 1.0, no further breaks** → aftermath, garden intact.
|
||||
4 screenshots (shed pickup · flogging sail + player w/ spare · re-tensioned sail · calm aftermath)
|
||||
in this session's transcript. **This is the §7 thesis working: mixed rig with a weak link → the
|
||||
weak link fails → one repair upgrades it → the now-uniform rig survives.** Definition of done met,
|
||||
and the break was the storm's, not induced.
|
||||
|
||||
[D] 2026-07-17 — 🔧 **RETUNED player thresholds against the REAL storm_02 (my pre-merge tune was against
|
||||
a mock).** Committed ahead of the run. In the actual storm, gusts-over-baseline peak at **12.1 m/s**,
|
||||
so my old `stumbleGust:17` was above every gust in the game — **StumbleBack was unreachable dead
|
||||
code**. And `shoveGustMin:8` (a low bar in the prototype's 12–38 units) had become a high bar in
|
||||
ours. Fixed: stumbleGust 17→9, shoveGustMin 8→4. A wild night now reads shoved (26 s of 90) →
|
||||
stumbling (4) → floored (2); a calm day stays 0/0. **d.test.js now loads the real storm JSON
|
||||
(`weather.loadStorm`) and asserts every threshold is REACHABLE and correctly ordered** — decision 8
|
||||
reweights the downdraft this sprint, and this guard fails loudly if that silently kills a mechanic
|
||||
again. 40 Lane D asserts, 0 fail.
|
||||
|
||||
[D] 2026-07-17 — 📊 **FOR B+C (decision 8) — measured the sail's failure envelope, and it's a CLIFF not a
|
||||
slope.** Driving `rig.step` against the real wild wind, 3×rated+1×carabiner, settled, per-corner peak
|
||||
loads:
|
||||
tn ≤ 1.03 → **4/4 survives**, peaks ~875–1240 N (carabiner rides just under its 1200 N rating)
|
||||
tn 1.04 → **instant double cascade**: h1 and p2 spike to **~10,300 N at t=1.1** and blow → 1/4
|
||||
A 0.01 tension step takes peak load from ~1.2 kN to ~10 kN — that's the cloth going **unstable**, not
|
||||
gradual overload. Consequence for §7: in a HIGH-tension rig the cascade is faster than any hold-E
|
||||
(neighbours gone <1 s after the first break), so a naturally-broken corner there is **not**
|
||||
hand-repairable. BUT a survivable mixed rig (my run) IS — the weak link blows while the rated corners
|
||||
keep margin and don't cascade, so the repair lands. **Net: good rigs survive, flat/drum-tight rigs
|
||||
cascade — the thesis holds.** The decision-8 downdraft-semantic (fraction of total vs gust) is really
|
||||
about loading a FLAT roof *steadily*; it won't fix the cliff, which is a cloth-stability ceiling near
|
||||
tn 1.04. Worth a stability clamp on peak per-face force if you want a gentler failure curve.
|
||||
|
||||
[D] 2026-07-17 — 🪜 **LADDER STRETCH GOAL — DEFERRED, flagging early as asked.** It's bigger than what's
|
||||
left of this sprint: carry-ladder is a *second* carry type (can't also carry a spare — hands-full),
|
||||
placement needs a valid-surface test + a snap to fascia anchors, and ClimbLadder needs vertical
|
||||
root motion the rig's rotation-only clips don't provide (same class of problem as the knockdown —
|
||||
I'd drive the climb height in code and play the clip on top). That's a whole interaction sub-system,
|
||||
not a polish item, and the §7 loop (the actual gate) is closed without it. Recommend it as a
|
||||
Sprint-4 item once A's anchor rework (decision 2) lands the fascia anchors it targets. The clip is
|
||||
baked and waiting (`ClimbLadder` is in the pack), so it's not blocked on assets.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 3 INTEGRATION (main).** Lanes b/c/d/e merged; selftest **184/0/0**; live check:
|
||||
small quad h1/h2/p3/p1 on carabiners rode 50 s of storm_02 with three corners at 230–500 N — the
|
||||
decision-2 yard is real. GATE 3 IS MET (D's on-record §7 hand-run). Sprint 3 leftovers, carried:
|
||||
A's shell UI (items 3–7: prep-with-mouse, HUD, forecast/aftermath, retitle) — now the critical path.
|
||||
**Ruling on the B↔C decision-3/8 dispute: measured beats modelled.** B's "unachievable" algebra is
|
||||
contradicted by B's own sweep (0.30 → 48% measured vs ~27% modelled) and by C's runs (0.40 → 63%
|
||||
PASS with a right-sized rig). The blocker was the oversized §7 quad, which decision 2 has since
|
||||
fixed. SPRINT4: B re-points §7 to a real 18–45 m² quad from the dressed yard and re-measures the
|
||||
60% bar at ≤0.45 ON REAL ANCHORS; if it passes, bump storm_02 downdraftOfTotal 0.12→0.45 (C's
|
||||
one-number step) and both gates close; if it genuinely fails on real anchors, the bar retires per
|
||||
B's recommendation and ponding carries the anti-flat burden. **Ponding is GREEN-LIT either way
|
||||
(SPRINT4, decision 10): game rain accumulates at ~40× real time** — the 90 s storm already
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
||||
@ -337,6 +337,41 @@
|
||||
],
|
||||
"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": [
|
||||
|
||||
@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01_broken(name):
|
||||
"""The gnome after the sail found him. Same origin and ground plane as the
|
||||
intact one, so Lane A swaps meshes in place without moving anything: hide
|
||||
`garden_gnome_01`, show this, bill $25 on the aftermath screen.
|
||||
|
||||
Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as
|
||||
the gnome from across the yard, or the aftermath screen is pointing at
|
||||
gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and
|
||||
the base stays exactly where the player last saw it standing.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
# The stump: base plus the bottom of the coat, snapped off at a ragged line.
|
||||
join_group([
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat,
|
||||
verts=10),
|
||||
add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m,
|
||||
verts=10), # raw concrete at the fracture
|
||||
], "stump", root)
|
||||
|
||||
# The head, rolled clear and face-down. Beard still on, which is the tell.
|
||||
hx, hy = 0.16, -0.09
|
||||
join_group([
|
||||
add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030),
|
||||
beard, verts=8, rot=(math.radians(96), 0, math.radians(20))),
|
||||
add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1),
|
||||
], "head", root)
|
||||
|
||||
# The hat, off and on its side — the single most legible piece of him.
|
||||
join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026),
|
||||
hat, verts=10, rot=(math.radians(90), 0,
|
||||
math.radians(-35)))], "hat", root)
|
||||
|
||||
shards = []
|
||||
for i in range(6):
|
||||
a = math.tau * rng.random()
|
||||
d = rng.uniform(0.10, 0.26)
|
||||
s = rng.uniform(0.010, 0.022)
|
||||
shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7),
|
||||
(math.cos(a) * d, math.sin(a) * d, s * 0.35),
|
||||
coat if i % 2 else base_m,
|
||||
rot=(0, 0, rng.uniform(0, math.tau))))
|
||||
join_group(shards, "shards", root)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "garden_gnome_01"
|
||||
root["collateral_value"] = 25
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(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.
|
||||
|
||||
A panel does not disintegrate — it loses a few palings and hangs off one
|
||||
rail. Keeping most of it standing is what makes the gap read as damage
|
||||
instead of as a design choice.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
width, h = 2.4, 1.8
|
||||
pw = 0.09
|
||||
n = 24
|
||||
step = width / n
|
||||
|
||||
standing, ground = [], []
|
||||
for i in range(n):
|
||||
x = -width / 2 + step * (i + 0.5)
|
||||
roll = rng.random()
|
||||
if 9 <= i <= 13 and roll < 0.75:
|
||||
# The hole: snapped low, or gone entirely onto the grass.
|
||||
if roll < 0.42:
|
||||
continue
|
||||
ph = rng.uniform(0.35, 0.72) # jagged stump
|
||||
standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
elif roll < 0.10:
|
||||
# One paling hanging by a single nail, swung off vertical.
|
||||
standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8),
|
||||
(x + 0.06, 0.01, h * 0.42), timber,
|
||||
rot=(0, rng.uniform(0.25, 0.5), 0)))
|
||||
else:
|
||||
ph = h + rng.uniform(-0.02, 0.02)
|
||||
standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
join_group(standing, "palings", root)
|
||||
|
||||
# Top rail snapped through the gap; bottom rail survives.
|
||||
rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35),
|
||||
rail_m),
|
||||
add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07),
|
||||
(-width * 0.29, 0.027, 1.45), rail_m),
|
||||
add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07),
|
||||
(width * 0.35, 0.027, 1.45), rail_m,
|
||||
rot=(rng.uniform(0.05, 0.14), 0, 0))]
|
||||
join_group(rails, "rails", root)
|
||||
|
||||
# The pieces that left, lying on the grass in front of the hole. Kept to
|
||||
# snapped lengths and tucked close: the fence sits on the yard boundary, so
|
||||
# a full-length paling flung a metre out pokes through whatever is on the
|
||||
# other side of it. Wreckage should read as wreckage, not reach.
|
||||
for i in range(3):
|
||||
ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)),
|
||||
(rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15),
|
||||
0.012),
|
||||
timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5))))
|
||||
join_group(ground, "debris_palings", root)
|
||||
|
||||
stamp(root, name, "fence")
|
||||
root["broken_variant_of"] = "fence_panel"
|
||||
root["tile_step"] = width
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
@ -1354,6 +1479,17 @@ ASSETS = [
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
|
||||
# and footprint so Lane A swaps in place.
|
||||
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,
|
||||
dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)),
|
||||
nodes=["stump", "head", "hat", "shards"]),
|
||||
# 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,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1572,10 +1708,15 @@ def verify_all(only=None):
|
||||
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
||||
|
||||
# The capsule beside it — the actual acceptance criterion. Skipped for
|
||||
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
|
||||
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
|
||||
# the scale check, and the tile's job is proving the thing READS.
|
||||
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
|
||||
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
|
||||
# and zooms the shackle down to one pixel. Below the cut the printed dims
|
||||
# are the scale check, and the tile's job is proving the thing READS.
|
||||
#
|
||||
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
|
||||
# next to a person", which is a question about how tall it stands. Flat
|
||||
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
|
||||
# object territory — measuring its scatter against a human just buries it.
|
||||
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
|
||||
if show_capsule:
|
||||
build_ref_capsule("ref_capsule")
|
||||
for o in bpy.data.objects:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -62,7 +62,11 @@ export const TUNE = {
|
||||
// prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
|
||||
// only applied while wind.gust > 8, never from the base wind.
|
||||
shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust
|
||||
shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all
|
||||
// Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a
|
||||
// low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it
|
||||
// became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the
|
||||
// prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm.
|
||||
shoveGustMin: 4, // m/s of gust (over baseline) before the wind can push you at all
|
||||
shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp
|
||||
|
||||
// Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
|
||||
@ -80,7 +84,12 @@ export const TUNE = {
|
||||
|
||||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||||
stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet)
|
||||
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
|
||||
// real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the
|
||||
// shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2
|
||||
// knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline).
|
||||
// d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again.
|
||||
stumbleGust: 9, // m/s over baseline → you lose your footing (but not your feet)
|
||||
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
|
||||
|
||||
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
|
||||
|
||||
@ -755,9 +755,37 @@ export async function createSailView(rig, { color = 0xd8c48a } = {}) {
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
|
||||
|
||||
// UVs: the grid IS the UV space, so (i, j) maps straight to (u, v). Without
|
||||
// this three defaults every vertex to (0,0), the map samples one texel, and
|
||||
// the membrane reads as flat colour — which looks like the texture failing
|
||||
// rather than like a bug. (Lane E's recipe, THREADS.)
|
||||
const N = rig.N;
|
||||
const uv = new Float32Array(N * N * 2);
|
||||
for (let j = 0, k = 0; j < N; j++) {
|
||||
for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); }
|
||||
}
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||||
});
|
||||
|
||||
// Resolved against this module rather than the server root: the same reason
|
||||
// weather.js builds STORM_DIR this way, and it's what the integrator's
|
||||
// /world/ -> relative pass was fixing. A missing texture must not take the
|
||||
// sail down — the cloth is the game, the weave is a finish.
|
||||
try {
|
||||
const tex = await new THREE.TextureLoader().loadAsync(
|
||||
new URL('../models/textures/sail_weave.png', import.meta.url).href,
|
||||
);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail (E's density)
|
||||
tex.colorSpace = THREE.SRGBColorSpace; // r175 spelling — `encoding` is gone
|
||||
tex.anisotropy = 4; // it's viewed at a raking angle from underneath
|
||||
mat.map = tex; // keep mat.color: the weave multiplies it
|
||||
} catch (err) {
|
||||
console.warn('[sail] weave texture missing, falling back to flat colour:', err.message);
|
||||
}
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true; // the shadow IS the product
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -15,6 +15,7 @@ import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
|
||||
import { Interact, wireYardActions } from '../interact.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
@ -25,8 +26,31 @@ const windX = (speed) => ({ x: speed, y: 0, z: 0 });
|
||||
const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind));
|
||||
|
||||
/** Count what a real storm actually does to a person standing mid-yard. */
|
||||
function stormProfile(wind, tune, secs = 90) {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 4 }, tune });
|
||||
let stumbles = 0, knocks = 0, maxGust = 0, maxWind = 0, shovedFor = 0;
|
||||
fixedLoop(secs, DT, (dt, t) => {
|
||||
p.step(dt, t, {}, wind);
|
||||
maxGust = Math.max(maxGust, p.gust);
|
||||
maxWind = Math.max(maxWind, p.windSpeed);
|
||||
if (Math.hypot(p.shove.x, p.shove.z) > 0.15) shovedFor += dt;
|
||||
for (const e of p.events) if (e.type === 'state') {
|
||||
if (e.state === 'stumble') stumbles++;
|
||||
if (e.state === 'knocked') knocks++;
|
||||
}
|
||||
p.events.length = 0;
|
||||
});
|
||||
return { stumbles, knocks, maxGust, maxWind, shovedFor };
|
||||
}
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
export default async function run(t) {
|
||||
// The REAL storms, not a mock. Lane D's thresholds are meaningless except against the data they
|
||||
// fire on, and this sprint's decision 8 has B+C changing the downdraft semantics underneath us —
|
||||
// exactly the kind of edit that silently made StumbleBack unreachable the first time.
|
||||
const wild = createWind(await loadStorm('storm_02_wildnight'));
|
||||
const calm = createWind(await loadStorm('storm_01_gentle'));
|
||||
// ---------------------------------------------------------------- state machine table
|
||||
// The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS).
|
||||
// Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly.
|
||||
@ -205,12 +229,27 @@ export default function run(t) {
|
||||
assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not');
|
||||
});
|
||||
|
||||
t.test('shelter: releasing the key always frees you, even mid-gust', () => {
|
||||
t.test('shelter: releasing the key always frees you — you are never stuck braced', () => {
|
||||
// steady wind (already learned as baseline, so no apparent gust): straight back to idle
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(20));
|
||||
drive(s, 1, { shelter: true }, windX(20));
|
||||
assertEq(s.state, 'shelter', 'braced');
|
||||
drive(s, 0.5, {}, windX(20)); // let go, wind still blowing
|
||||
assert(!s.busy && s.state === 'idle', 'released');
|
||||
drive(s, 0.5, {}, windX(20));
|
||||
assertEq(s.state, 'idle', 'let go in steady wind → idle');
|
||||
assert(!s.busy, 'and free to move');
|
||||
});
|
||||
|
||||
t.test('shelter: unbracing INTO the gust you were hiding from costs you your footing', () => {
|
||||
// Emergent, and worth pinning: bracing is a commitment. Let go early and the gust takes you.
|
||||
// This is what makes "wait for the lull" a decision rather than a formality.
|
||||
const g = new PlayerSim();
|
||||
drive(g, 30, {}, windX(4)); // learn a calm baseline
|
||||
drive(g, 1, { shelter: true }, windX(4 + TUNE.stumbleGust + 6), 30);
|
||||
assertEq(g.state, 'shelter', 'braced through the gust');
|
||||
drive(g, 0.2, {}, windX(4 + TUNE.stumbleGust + 6), 31);
|
||||
assert(g.state !== 'shelter', 'releasing always leaves shelter');
|
||||
assertEq(g.state, 'stumble', 'and straight into the gust → you stumble');
|
||||
});
|
||||
|
||||
t.test('shelter: cannot brace from your back', () => {
|
||||
@ -259,6 +298,51 @@ export default function run(t) {
|
||||
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- tuned against the REAL storms
|
||||
// These are the guard rails the pre-merge tuning didn't have. Every threshold below is only
|
||||
// meaningful relative to the storm JSON it fires on, so assert against the JSON.
|
||||
t.test('storm_02: every player threshold is actually REACHABLE in the real storm', () => {
|
||||
const p = stormProfile(wild, undefined);
|
||||
assert(p.maxGust > TUNE.stumbleGust,
|
||||
`StumbleBack is dead code: storm_02 gusts peak at ${p.maxGust.toFixed(1)} m/s over baseline, `
|
||||
+ `but stumbleGust is ${TUNE.stumbleGust}. Lower it or ask Lane C for angrier gusts.`);
|
||||
assert(p.maxGust > TUNE.shoveGustMin, `gust shove unreachable: peak ${p.maxGust.toFixed(1)}`);
|
||||
assert(p.maxWind > TUNE.knockWind,
|
||||
`knockdown unreachable: storm_02 peaks at ${p.maxWind.toFixed(1)} m/s, knockWind is ${TUNE.knockWind}`);
|
||||
});
|
||||
|
||||
t.test('storm_02: reads as shoved → stumbling → floored, in that order of frequency', () => {
|
||||
const p = stormProfile(wild, undefined);
|
||||
assert(p.stumbles >= 2, `a wild night should break your stride more than twice, got ${p.stumbles}`);
|
||||
assert(p.knocks >= 1, `and floor you at least once, got ${p.knocks}`);
|
||||
assert(p.stumbles > p.knocks,
|
||||
`stumble is the beat and knockdown the punchline — got ${p.stumbles} stumbles vs ${p.knocks} knocks`);
|
||||
assert(p.shovedFor > 10, `you should feel pushed for a real slice of the storm, got ${p.shovedFor.toFixed(1)}s`);
|
||||
assertLess(p.knocks, 8, `${p.knocks} knockdowns in 90 s is unplayable, not dramatic`);
|
||||
});
|
||||
|
||||
t.test('storm_01: a calm day leaves you completely alone', () => {
|
||||
const p = stormProfile(calm, undefined);
|
||||
assertEq(p.stumbles, 0, 'no stumbles on a gentle day');
|
||||
assertEq(p.knocks, 0, 'no knockdowns on a gentle day');
|
||||
assertLess(p.shovedFor, 1, 'and essentially no shove');
|
||||
});
|
||||
|
||||
t.test('storm_02: bracing is what makes the worst of it survivable', () => {
|
||||
const exposed = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
|
||||
const braced = new PlayerSim({ start: { x: 0, y: 0, z: 4 } });
|
||||
let exposedKnocks = 0, bracedKnocks = 0;
|
||||
fixedLoop(90, DT, (dt, tt) => {
|
||||
exposed.step(dt, tt, {}, wild);
|
||||
braced.step(dt, tt, { shelter: true }, wild);
|
||||
for (const e of exposed.events) if (e.state === 'knocked') exposedKnocks++;
|
||||
for (const e of braced.events) if (e.state === 'knocked') bracedKnocks++;
|
||||
exposed.events.length = 0; braced.events.length = 0;
|
||||
});
|
||||
assert(exposedKnocks > 0, 'storm_02 floors you if you just stand in it');
|
||||
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
|
||||
@ -73,6 +73,10 @@ const ASSETS = [
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ 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],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -234,6 +238,45 @@ export default async function run(t) {
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// Custom props are a contract, not decoration — and I have been telling other
|
||||
// lanes to read these since Sprint 1 without ever checking they survive the
|
||||
// export. glTF `extras` arrive as three's userData, but only if export_extras
|
||||
// held all the way through; if it silently dropped, Lane A's gnome scores $0
|
||||
// and Lane B's anchors all rate the same, both of which would look like a
|
||||
// gameplay decision rather than a missing field.
|
||||
t.test('glTF extras survive as userData — the props other lanes read', () => {
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
|
||||
assert(gnome, 'gnome root node missing');
|
||||
assert(gnome.userData?.collateral_value === 25,
|
||||
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
|
||||
|
||||
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
|
||||
assert(typeof canopy?.userData?.sway_amp === 'number',
|
||||
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
|
||||
|
||||
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
|
||||
assert(typeof branch?.userData?.rating_hint === 'number',
|
||||
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
|
||||
|
||||
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
|
||||
assert(typeof bin?.userData?.mass_hint === 'number',
|
||||
'wheelie_bin mass_hint lost — Lane C throws it with this');
|
||||
});
|
||||
|
||||
// The wreckage has to drop into the intact asset's place, so both variants
|
||||
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
|
||||
// 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']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
@ -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/fence_panel_broken_v1.glb
Normal file
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user