Compare commits
16 Commits
446ed2f14a
...
4bb0efcfd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb0efcfd1 | ||
|
|
5d8264f13f | ||
|
|
89fd4e60b9 | ||
|
|
997ea0bab1 | ||
|
|
ecb1ee7f43 | ||
|
|
44a4a3e6ec | ||
|
|
6880ec7d8e | ||
|
|
9b97bcfd00 | ||
|
|
9a2abad1be | ||
|
|
576422e1f0 | ||
|
|
135b3dcda5 | ||
|
|
9f83fd4f20 | ||
|
|
a34cbd6d19 | ||
|
|
0cabb19dc7 | ||
|
|
9b8aabe0db | ||
|
|
5d1d0895f2 |
@ -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.
|
||||
300
THREADS.md
300
THREADS.md
@ -825,6 +825,49 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
[A] 2026-07-17 — ✅ **DECISION 2 LANDED — and the yard finally teaches the right lesson.** Posts in to
|
||||
(−4.5,5.5)/(4.0,6.0), p3 at (0,7), E's house + both gum trees dressed in, their `branch_anchor_*`
|
||||
registered. **7 anchors → 11.** Quads covering the bed went from "nothing under 110 m²" to **34 in
|
||||
the 18–45 m² band, 8 of which shade ≥25% of the bed** (decision 2 asked for ≥3). Selftest 172/0/0.
|
||||
**Lane B — your "cascade at t=0.4 s from pre-tension alone" is GONE.** Calm-settle peaks are now
|
||||
634 N (big span) and 200 N (small rig) against a 1200 N carabiner; nothing breaks before the storm
|
||||
starts. Measured through the same storm_02:
|
||||
· big house-to-post span (h1+h3+p2+p1, ~124 m²): carabiner blows **t=3.7 s**, p2 cascades
|
||||
**t=33.2 s**, ends **2/4**. Note the 3.7 s — a carabiner on a 124 m² sail now dies almost
|
||||
immediately. Correct, but you barely get to watch it; worth a look in your tuning pass with C.
|
||||
· small twisted rig (t2+p1+t1b+t2b, **37.7 m²**, tension 0.85): **survives all 90 s, 4/4 intact**,
|
||||
shades **58%** of the bed.
|
||||
Big+flat = great shade, dead. Small+twisted = survives, patchy. That is DESIGN.md's thesis standing
|
||||
up in the yard instead of in a doc.
|
||||
|
||||
[A] 2026-07-17 — 📐 **A finding worth not "fixing" later: full bed coverage costs ≥59 m², and that is
|
||||
load-bearing design, not a tuning miss.** I enumerated all 330 quads. Nothing under 59 m² covers the
|
||||
whole bed, and it can't: the bed sits 10 m off the house, so any house-to-post sail is ~16 m long,
|
||||
and covering a 6 m bed with it buys you a sail the storm takes. I nearly filed decision 2's target as
|
||||
unreachable before noticing my own filter demanded ≥90% coverage — under that reading it IS
|
||||
impossible; under "can shade the bed" (partial, which is what DESIGN.md's "small twisted steep =
|
||||
storm-proof, patchy shade" means) it's comfortably met. **I've asserted BOTH directions** in
|
||||
a.test.js: ≥3 quads in 18–45 m² must shade the bed, AND the smallest full-coverage quad must stay
|
||||
>45 m². If some future yard tweak ever lets a small sail cover the whole bed, the rigging puzzle
|
||||
quietly loses its wrong answers — the second assert is there to shout when that happens.
|
||||
|
||||
[A] 2026-07-17 — 🎁 **LANE E — your baked data is doing real work, thank you.** `rating_hint` is now on
|
||||
every anchor: fascia **0.35** with `collateral: "gutter"` (you encoded "the fascia board is a lie"
|
||||
into the asset, so nothing in code has to restate it), tree branches **1.0 / 0.88 / 0.76** fork→thin
|
||||
limb — exactly the inspection intel DESIGN.md wants. Your fascia anchors sit at x=−3..3, not the
|
||||
−5..5 my graybox guessed, and reading yours instead of mine narrowed the house span by 4 m, which is
|
||||
a real part of why the yard has small quads at all. Decision 6's "data wins over constants" earned
|
||||
its place. `pickup_anchor` likewise sat 5 cm off my guess. **Lane B/D:** `anchor.ratingHint` (0..1)
|
||||
and `anchor.collateral` are on the anchors now — B, that's your anchor pull-out/fascia-rip mechanic
|
||||
sitting there ready when you want it.
|
||||
|
||||
[A] 2026-07-17 — ⚠️ **Anchors are FINAL only after `await world.dress()`.** `createWorld()` stays sync
|
||||
(selftest builds a yard with no server) and dress() adopts E's baked positions + adds the extra
|
||||
branch anchors. main.js awaits dress() before anything rigs, and a.test.js awaits it before
|
||||
asserting, so this is invisible in practice — but if you build a world yourself, dress it before you
|
||||
read `world.anchors` or you're looking at graybox. dress() MUTATES `anchor.pos` in place rather than
|
||||
reassigning, so vectors captured by `interact.register` and Lane B's corners stay live.
|
||||
|
||||
[A] 2026-07-17 — 🚩 **GATE 1 (Sprint 3) — `world.shedTable` IS LIVE. LANE D: GO.** On main. Lane E's
|
||||
`shed_01_v1.glb` + `shed_table_v1.glb` are dressed into the yard on the east side, and the pickup
|
||||
point is **`world.shedTable.pos` = (9.00, 0.909, 6.00)** — read from E's baked `pickup_anchor`, which
|
||||
@ -951,3 +994,260 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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
|
||||
speed, so it presses a flat roof steadily the whole storm (not just at gust peaks) and a tree's wind
|
||||
shadow shelters from falling air too. `speedAt()` stays horizontal. Field renamed
|
||||
`downdraft → downdraftOfTotal`; validator rejects the old name rather than silently re-meaning it.
|
||||
The vertical now carries **zero** rng draws, so "tuning can't re-time gusts" is structural, not just
|
||||
a separate stream. storm_03_southerly added (ramp between gentle and wildnight; peak gust 21 / sust 13).
|
||||
weather_demo.html retired — the game is the bench.
|
||||
|
||||
[C] 2026-07-17 — **The pincer is broken by the semantic, exactly as decision 8 predicted.** I measured
|
||||
both gates myself with B's SailRig (8-heading flat-vs-16.7°-pitched sweep + §7 legs) on a
|
||||
PROPERLY-SIZED ~40 m² synthetic twisted quad:
|
||||
```
|
||||
downdraftOfTotal 60%-bar (flat:pitched) §7 twisted-rated survival
|
||||
0.22 45% fail 4/4 (2928 N)
|
||||
0.40 63% PASS 4/4 (4617 N)
|
||||
0.45 69% of-max / 60% worst-head 4/4 (5142 N, 21% margin) ← TARGET
|
||||
0.60 78% PASS 3/4 DIES (6567 > 6500)
|
||||
```
|
||||
So **0.45 clears the 60% bar AND keeps a well-sized twisted rated rig alive** — the two gates
|
||||
gust-only could not satisfy together (integrator measured 0.58 → 48% and still broke twisted). Decision
|
||||
8 works. (My harness reproduces B's scale: fraction-of-total 0.15 → 37%, matching B's gust-only 0.3 →
|
||||
34% at the same ~-4.5 m/s peak. Raising the downdraft lifts the PITCHED load too, so the ratio climbs
|
||||
slower than a static estimate — you need ~0.4, not B's ~7.3 m/s single-point guess. That's a real note
|
||||
for your assert, B.)
|
||||
|
||||
[C] 2026-07-17 — ⚠️ **B — A's anchor rework alone does NOT unblock 0.45; your §7 rig is still oversized.
|
||||
Re-point it and we finish gate 2.** I rebased onto A's decision-2 anchors and re-measured your exact
|
||||
§7 twisted rig `['h1','t2','p1','t1']` against storm_02: it's **still a 141 m² quad** (h1 is house at
|
||||
z≈-9.9, t2 at x≈8, p1 at x≈-4.9, t1 at x≈-9 — those four corners span the whole yard), and it dies at
|
||||
0.45 (3/4, peak 6410 N). A ADDED small quads (`p3` near (0,7.6), branch anchors `t1b/t2b`, posts moved
|
||||
in to p1≈(-4.9,5.9)/p2≈(4.3,6.5)) — but `h1,t2,p1,t1` isn't one of them. **Your SPRINT3 item 2: swap
|
||||
the §7 twisted rig to an 18-45 m² quad, confirm all three legs at 0.45, then bump storm_02
|
||||
`downdraftOfTotal` 0.12 → 0.45 (one number).** ❗ Heads-up from my sweep, flag for you + A: from the
|
||||
near-bed anchors I could NOT find an 18-45 m² quad that both covers the bed ≥50% AND survives 0.45 with
|
||||
a rated+shackle mix — the bed sits between the house (z≈-9.9) and the posts (z≈+6), so covering it
|
||||
tends to want a biggish quad. A's a.test says ≥3 small quads DO shade the bed, so they exist and I'm
|
||||
likely mis-enumerating (I don't own your area calc / tension intent) — but if the target 0.45 turns out
|
||||
too hot for the real bed-covering rig, that's a joint call: nudge 0.45 down a touch, or accept the §7
|
||||
survivable rig is a bigger quad than 45 m². Your rig, your call; I'll match the wind to whatever lands.
|
||||
|
||||
[C] 2026-07-17 — **Held storm_02 at `downdraftOfTotal: 0.12` so main stays GREEN until B re-points.** On
|
||||
the current yard B's oversized §7 rig starts losing a corner near 0.15 in the exact solver, so 0.45
|
||||
would red the §7 assert (I saw it: 1 fail before I held). 0.12 fraction-of-total ≈ the old gust-only
|
||||
0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02's feel barely moves, with ~23% load margin on
|
||||
that rig. storm_01 → 0.25, storm_03 → 0.35 (neither is in a §7 assert, so those are their real values).
|
||||
Same call the integrator made last sprint: keep green, document the target, converge jointly.
|
||||
|
||||
[C] 2026-07-17 — **A — decision 7 (garden HP on rain shadow): `skyfx.rainShadowOver(bed)` is ready, no
|
||||
new work on my side.** Returns 0..1 of the bed the cloth is keeping dry, this frame; 0 when no sail or
|
||||
no rain. It is NOT `rig.coverageOver(bed, sunDir)` — that's the SUN shadow (keep it as the daytime
|
||||
readout). During a night storm the rain shadow is the one that says whether the bed is getting hit; it
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
||||
@ -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 |
@ -14,7 +14,7 @@
|
||||
"powBase": 2,
|
||||
"powRand": 3,
|
||||
"powRamp": 2,
|
||||
"downdraft": 0.18
|
||||
"downdraftOfTotal": 0.25
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||
|
||||
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
|
||||
"_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. TARGET is 0.45 — measured to clear B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) AND let a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). HELD at 0.12 for now: on the current oversized yard the ONLY twisted quad ('h1,t2,p1,t1', ~190 m²) starts losing a corner around 0.15 in the exact solver, so 0.45 would turn B's §7 assert red. 0.12 fraction-of-total ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02 barely changes, and leaves ~23% load margin on that twisted rig. Bump to 0.45 is a ONE-NUMBER joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. See THREADS [C] 2026-07-17.",
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraft": 0.3
|
||||
"downdraftOfTotal": 0.12
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
|
||||
38
web/world/data/storms/storm_03_southerly.json
Normal file
38
web/world/data/storms/storm_03_southerly.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "Southerly Buster",
|
||||
"blurb": "Hot still afternoon, then a southerly change rolls through around the half-hour. Gusty but not vicious — a fair test of a first real rig.",
|
||||
"rating": 2,
|
||||
"seed": 30717,
|
||||
"duration": 90,
|
||||
|
||||
"_ramp_comment": "The campaign's middle rung: sits between storm_01_gentle (peak ~11 m/s) and storm_02_wildnight (peak ~32). Sustained builds to ~13 (47 km/h), worst gust ~21 (76 km/h, BOM 'strong'). The change is real but slower and smaller than the wild night's, so a decent flat-ish rig can get away with it and a good twisted one is never in doubt — the storm that teaches the swing before the one that punishes it.",
|
||||
|
||||
"baseCurve": [[0, 4.0], [12, 6.0], [30, 9.0], [45, 13.0], [65, 12.5], [90, 10.0]],
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 5,
|
||||
"minGap": 6,
|
||||
"maxGap": 12,
|
||||
"powBase": 3,
|
||||
"powRand": 4,
|
||||
"powRamp": 4,
|
||||
"downdraftOfTotal": 0.35
|
||||
},
|
||||
|
||||
"_dir_comment": "Starts blowing toward the SE (a warm NW'er), swings to blow toward the NNE (a moderate southerly) across 30-36 s. ~90 deg, gentler slew than storm_02's buster.",
|
||||
|
||||
"dirCurve": [[0, 0.8], [28, 0.9], [30, 0.55], [36, -0.7], [55, -0.85], [90, -0.75]],
|
||||
"dirWander": { "amp": 0.3, "rate": 0.11 },
|
||||
|
||||
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
|
||||
|
||||
"events": [
|
||||
{ "t": 30, "type": "windchange", "telegraph": 6, "over": 6, "text": "here comes the change" },
|
||||
{ "t": 48, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" },
|
||||
{ "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]] },
|
||||
|
||||
"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;
|
||||
|
||||
@ -8,13 +8,27 @@ import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../con
|
||||
import { createWorld, heightAt } from '../world.js';
|
||||
import { createCameraRig } from '../camera.js';
|
||||
import { createGame } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
export default async function run(t) {
|
||||
const scene = new THREE.Scene();
|
||||
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
|
||||
|
||||
// Dress the yard before asserting anything about it: anchors are only FINAL
|
||||
// after dress(), which moves them onto the positions Lane E baked and adds the
|
||||
// extra tree branches. Testing the graybox would be testing a yard that never
|
||||
// reaches a player. Guarded, so a missing server degrades to graybox asserts
|
||||
// rather than reddening the whole lane.
|
||||
let dressed = false;
|
||||
try {
|
||||
await world.dress();
|
||||
dressed = true;
|
||||
} catch (err) {
|
||||
console.warn('[a.test] dress() unavailable, asserting against graybox:', err.message);
|
||||
}
|
||||
|
||||
// --- contract conformance ------------------------------------------------
|
||||
// These are the merge tripwires: if a lane's module drifts from contracts.js,
|
||||
// this is where we find out, not three lanes later.
|
||||
@ -111,15 +125,138 @@ export default function run(t) {
|
||||
|
||||
// --- anchors -------------------------------------------------------------
|
||||
|
||||
t.test('yard offers 7 anchors: 3 house, 2 tree, 2 post', () => {
|
||||
t.test('yard offers 11 anchors: 3 house, 5 tree, 3 post', () => {
|
||||
const by = (type) => world.anchors.filter((a) => a.type === type).length;
|
||||
assertEq(by('house'), 3, 'house anchors');
|
||||
assertEq(by('tree'), 2, 'tree anchors');
|
||||
assertEq(by('post'), 2, 'post anchors');
|
||||
assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())');
|
||||
assertEq(by('post'), 3, 'post anchors — p3 added, SPRINT3 decision 2');
|
||||
const ids = world.anchors.map((a) => a.id);
|
||||
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
|
||||
});
|
||||
|
||||
t.test('anchors carry Lane E\'s rating_hint, and the fascia is the weak one', () => {
|
||||
if (!dressed) return t.skip('needs dress()');
|
||||
const hint = (id) => world.anchors.find((a) => a.id === id)?.ratingHint;
|
||||
// DESIGN.md: "The fascia board is a lie: holds until the first real gust."
|
||||
// Lane E encoded that as rating_hint 0.35 in house_yardside_v1.glb, so the
|
||||
// asset says it and nothing here has to restate it. If this ever flips to
|
||||
// 1.0, the yard has quietly stopped teaching its best lesson.
|
||||
assertLess(hint('h1'), 0.5, 'fascia anchor should be the weak option');
|
||||
assertEq(world.anchors.find((a) => a.id === 'h1').collateral, 'gutter',
|
||||
'a fascia failure takes the gutter with it — that is the collateral cost');
|
||||
assert(hint('t1') > hint('t1c'),
|
||||
'a branch anchor at the fork must out-rate one out where the limb is thin');
|
||||
});
|
||||
|
||||
// --- decision 2: the yard has to offer a real choice ----------------------
|
||||
|
||||
t.test('yard offers ≥3 riggable quads in the 18-45 m² band that shade the bed', () => {
|
||||
if (!dressed) return t.skip('needs dress() — anchors are only final after it');
|
||||
|
||||
// SPRINT3 decision 2. Before the rework every quad covering the bed was
|
||||
// 110 m²+, which pre-tensions itself into a cascade at t=0.4 s before the
|
||||
// wind does anything — the yard taught the wrong lesson.
|
||||
const bed = world.gardenBed;
|
||||
const areaOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
};
|
||||
const inside = (x, z, r) => {
|
||||
let c = false;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const a = r[i].pos, b = r[j].pos;
|
||||
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const coverOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let hit = 0, tot = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const x = bed.x - bed.w / 2 + ((i + 0.5) / 6) * bed.w;
|
||||
const z = bed.z - bed.d / 2 + ((j + 0.5) / 4) * bed.d;
|
||||
tot++;
|
||||
if (inside(x, z, r)) hit++;
|
||||
}
|
||||
}
|
||||
return hit / tot;
|
||||
};
|
||||
|
||||
const A = world.anchors;
|
||||
const band = [];
|
||||
for (let i = 0; i < A.length; i++) {
|
||||
for (let j = i + 1; j < A.length; j++) {
|
||||
for (let k = j + 1; k < A.length; k++) {
|
||||
for (let l = k + 1; l < A.length; l++) {
|
||||
const q = [A[i], A[j], A[k], A[l]];
|
||||
const m2 = areaOf(q);
|
||||
if (m2 >= 18 && m2 <= 45 && coverOf(q) >= 0.25) {
|
||||
band.push(`${q.map((a) => a.id).join('+')} ${m2.toFixed(0)}m²`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(band.length >= 3,
|
||||
`only ${band.length} quads in 18-45 m² shade the bed — the yard offers no ` +
|
||||
`storm-survivable option. Found: ${band.join(', ') || 'none'}`);
|
||||
});
|
||||
|
||||
t.test('full shade over the bed stays expensive — the tradeoff is the game', () => {
|
||||
if (!dressed) return t.skip('needs dress()');
|
||||
// The other half of decision 2, and the half that is easy to "fix" by
|
||||
// accident. DESIGN.md's core tension is that big+flat+low buys great shade
|
||||
// and dies in a storm, while small+twisted survives and shades patchily. If
|
||||
// some future yard tweak ever lets a small quad cover the whole bed, that
|
||||
// tension is gone and the rigging puzzle has no wrong answers left.
|
||||
const bed = world.gardenBed;
|
||||
const A = world.anchors;
|
||||
let smallestFull = Infinity;
|
||||
const areaOf = (q) => {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
};
|
||||
const inside = (x, z, r) => {
|
||||
let c = false;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
|
||||
const a = r[i].pos, b = r[j].pos;
|
||||
if ((a.z > z) !== (b.z > z) && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
for (let i = 0; i < A.length; i++) {
|
||||
for (let j = i + 1; j < A.length; j++) {
|
||||
for (let k = j + 1; k < A.length; k++) {
|
||||
for (let l = k + 1; l < A.length; l++) {
|
||||
const q = [A[i], A[j], A[k], A[l]];
|
||||
const r = orderRing(q);
|
||||
let hit = 0;
|
||||
for (let a = 0; a < 6; a++) {
|
||||
for (let b = 0; b < 4; b++) {
|
||||
const x = bed.x - bed.w / 2 + ((a + 0.5) / 6) * bed.w;
|
||||
const z = bed.z - bed.d / 2 + ((b + 0.5) / 4) * bed.d;
|
||||
if (inside(x, z, r)) hit++;
|
||||
}
|
||||
}
|
||||
if (hit / 24 >= 0.9) smallestFull = Math.min(smallestFull, areaOf(q));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(smallestFull > 45,
|
||||
`a ${smallestFull.toFixed(0)} m² quad covers the whole bed — full shade is ` +
|
||||
`supposed to cost you a sail the storm can take`);
|
||||
});
|
||||
|
||||
t.test('sway() returns an absolute position, not an offset', () => {
|
||||
// If sway ever regresses to returning an offset, the returned point lands
|
||||
// near the origin instead of near the anchor, and Lane B's cloth corners
|
||||
|
||||
@ -54,11 +54,13 @@ export default async function run(t) {
|
||||
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
|
||||
});
|
||||
|
||||
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
|
||||
// decision 3 made that false on purpose: gusts now descend, which is what makes
|
||||
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
|
||||
// and never garbage — so player shove and rain angle can still trust the sign.
|
||||
t.test('vertical wind is downward-only, and only during gusts', () => {
|
||||
// This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
|
||||
// made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
|
||||
// decision 8 made the descent a fraction of TOTAL wind, so it's present
|
||||
// whenever it's windy, not only in gusts. Keeping the invariants that consumers
|
||||
// (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never
|
||||
// garbage, and speedAt() is horizontal-only.
|
||||
t.test('vertical wind is downward-only and rides the wind', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const pos = new THREE.Vector3(0, 1.7, 0);
|
||||
const v = new THREE.Vector3();
|
||||
@ -66,12 +68,13 @@ export default async function run(t) {
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
wind.sample(pos, time, v);
|
||||
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
||||
if (v.y < -1) sawDown = true;
|
||||
assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`);
|
||||
if (v.y < -2) sawDown = true;
|
||||
});
|
||||
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
|
||||
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
|
||||
const calm = wind.speedAt(pos, 0.5);
|
||||
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
|
||||
// the wind meter must stay horizontal — falling air shouldn't spike the HUD
|
||||
const s = wind.sample(pos, 0.5);
|
||||
assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
|
||||
'speedAt() is not the horizontal magnitude of sample()');
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -280,44 +280,75 @@ export function weatherCases(storms) {
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
||||
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
||||
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
||||
// the game. Gust fronts are descending air, and descending air hits a flat
|
||||
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
||||
test('gusts carry a downdraft, and still air does not', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
let peakDown = 0, betweenMax = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const v = f.gustVertical(t);
|
||||
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||
if (live) peakDown = Math.min(peakDown, v);
|
||||
else betweenMax = Math.max(betweenMax, Math.abs(v));
|
||||
}
|
||||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
|
||||
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||
});
|
||||
|
||||
test('downdraft tracks its own gust and its JSON fraction', () => {
|
||||
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
|
||||
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
|
||||
// ignore the storm" wins — the opposite of the game. The downdraft is now a
|
||||
// fraction of the LOCAL total wind speed (was: gust power), so a flat roof is
|
||||
// pressed whenever it's windy, not only at gust peaks. Lane B owns the
|
||||
// cloth-side no-free-lunch assert; these are the wind side.
|
||||
test('downdraft is a fixed fraction of the local horizontal speed', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def);
|
||||
const frac = def.gusts.downdraft;
|
||||
for (const g of f.gusts) {
|
||||
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
||||
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
||||
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
||||
const atHold = f.gustVertical(g.t0 + 3);
|
||||
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
||||
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
||||
const frac = def.gusts.downdraftOfTotal;
|
||||
assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
let peakDown = 0;
|
||||
for (const p of PROBES) {
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
f.vecAt(p.x, p.z, t, out);
|
||||
const horiz = Math.hypot(out.x, out.z);
|
||||
assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||
// out.y must be exactly -frac * horizontal, everywhere, always
|
||||
assert(Math.abs(out.y + frac * horiz) < 1e-9,
|
||||
`downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`);
|
||||
peakDown = Math.min(peakDown, out.y);
|
||||
}
|
||||
}
|
||||
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||
// Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at
|
||||
// -3 so this proves "a real downdraft exists" across the whole transition
|
||||
// range without false-failing when the joint step bumps the value.
|
||||
assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||
});
|
||||
|
||||
test('downdraft rides the wind: present when windy, gone when calm', () => {
|
||||
// The point of fraction-of-total: it's not a gust-only feature any more. Some
|
||||
// sustained-wind moment between gusts must still carry a real downdraft, and a
|
||||
// hypothetically dead-calm field must carry none.
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
let sustainedDown = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||
if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t));
|
||||
}
|
||||
assert(sustainedDown < -2,
|
||||
`between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`);
|
||||
|
||||
// dead calm → no downdraft (guards against a constant offset sneaking in)
|
||||
const calm = createWindField({
|
||||
duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]],
|
||||
gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 },
|
||||
});
|
||||
for (let t = 0; t <= 10; t += 0.1) {
|
||||
assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('downdraft 0 gives a perfectly horizontal wind', () => {
|
||||
test('downdraft follows the tree shadow (shelters from falling air too)', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]);
|
||||
const t = 30;
|
||||
const d = f.dirAt(t);
|
||||
const dx = Math.cos(d), dz = Math.sin(d);
|
||||
const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree
|
||||
const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered
|
||||
assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => {
|
||||
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
def.gusts.downdraft = 0;
|
||||
def.gusts.downdraftOfTotal = 0;
|
||||
const f = createWindField(def);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
for (let t = 0; t <= f.duration; t += 0.05) {
|
||||
@ -327,15 +358,16 @@ export function weatherCases(storms) {
|
||||
});
|
||||
|
||||
test('downdraft does not re-time the storm', () => {
|
||||
// The vertical draws from its own RNG stream precisely so that adding or
|
||||
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
||||
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
||||
// tweak silently moving those would be a nasty way to lose an afternoon.
|
||||
// The vertical carries NO rng draws of its own now (it's a pure function of
|
||||
// local speed), so tuning it cannot possibly shift gust times or powers. Lane
|
||||
// A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2
|
||||
// cascade at t=56; a downdraft tweak silently moving those would be a nasty
|
||||
// way to lose an afternoon. Determinism is now structural, but still asserted.
|
||||
const base = storms.storm_02_wildnight;
|
||||
const a = createWindField(base);
|
||||
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
|
||||
for (const dd of [0, 0.1, 0.22, 0.5, 1]) {
|
||||
const d = JSON.parse(JSON.stringify(base));
|
||||
d.gusts.downdraft = dd;
|
||||
d.gusts.downdraftOfTotal = dd;
|
||||
const b = createWindField(d);
|
||||
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||||
a.gusts.forEach((g, i) => {
|
||||
@ -343,32 +375,34 @@ export function weatherCases(storms) {
|
||||
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||||
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||||
});
|
||||
// and the HORIZONTAL wind must be byte-identical regardless of downdraft
|
||||
assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`);
|
||||
}
|
||||
});
|
||||
|
||||
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
|
||||
test('speedAt stays horizontal — a wind meter does not read falling air', () => {
|
||||
const f = createWindField(storms.storm_02_wildnight);
|
||||
const out = { x: 0, y: 0, z: 0 };
|
||||
let bestRatio = 0, atT = 0;
|
||||
for (let t = 0; t <= f.duration; t += DT) {
|
||||
f.vecAt(0, 0, t, out);
|
||||
const horiz = Math.hypot(out.x, out.z);
|
||||
if (horiz < 1) continue;
|
||||
const r = Math.abs(out.y) / horiz;
|
||||
if (r > bestRatio) { bestRatio = r; atT = t; }
|
||||
for (const p of PROBES) {
|
||||
for (const t of [12, 40, 60, 75.3]) {
|
||||
f.vecAt(p.x, p.z, t, out);
|
||||
assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9,
|
||||
`speedAt != horizontal magnitude of sample at t=${t}`);
|
||||
}
|
||||
}
|
||||
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
|
||||
assert(bestRatio > 0.12,
|
||||
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
|
||||
});
|
||||
|
||||
test('validator rejects a bad downdraft', () => {
|
||||
test('validator rejects a bad downdraft and the renamed field', () => {
|
||||
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||||
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
d.gusts.downdraft = dd;
|
||||
const { ok } = validateStorm(d, 'broken');
|
||||
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
|
||||
d.gusts.downdraftOfTotal = dd;
|
||||
assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`);
|
||||
}
|
||||
// the old gust-only field must be rejected, not silently re-meant
|
||||
const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||
delete legacy.gusts.downdraftOfTotal;
|
||||
legacy.gusts.downdraft = 0.3;
|
||||
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
|
||||
@ -130,29 +130,19 @@ function sampleAngleCurve(curve, t) {
|
||||
|
||||
// ---------- gust timeline ----------
|
||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||
export const DEFAULT_DOWNDRAFT = 0.25;
|
||||
export const DEFAULT_DOWNDRAFT = 0.22;
|
||||
|
||||
export function buildGustTimeline(def, seed) {
|
||||
const g = def.gusts || {};
|
||||
const rng = mulberry32(seed >>> 0);
|
||||
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
|
||||
// would shift every subsequent (t0, pow) and silently re-time storms that are
|
||||
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
|
||||
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
|
||||
// downdraft shouldn't move that.
|
||||
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
|
||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
const out = [];
|
||||
let t = g.firstAt ?? 3;
|
||||
// hard cap: a malformed gap can't spin us forever
|
||||
while (t < def.duration && out.length < 512) {
|
||||
const p = def.duration > 0 ? t / def.duration : 0;
|
||||
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
||||
// Not every gust slams down the same: some roll through nearly flat, some
|
||||
// are a proper little downburst. 0.6–1.4× the storm's fraction.
|
||||
const down = downFrac * (0.6 + rngV() * 0.8);
|
||||
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||
}
|
||||
return out;
|
||||
@ -174,6 +164,11 @@ export function createWindField(def, opts = {}) {
|
||||
const wander = def.dirWander || {};
|
||||
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
|
||||
const nSeed = (seed ^ 0x9e3779b9) | 0;
|
||||
// SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
|
||||
// gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
|
||||
// legacy alias so an un-migrated storm doesn't silently lose its vertical.
|
||||
const gd = def.gusts || {};
|
||||
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
|
||||
let shelters = [];
|
||||
|
||||
@ -202,24 +197,37 @@ export function createWindField(def, opts = {}) {
|
||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||
}
|
||||
|
||||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow.
|
||||
* The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */
|
||||
function localHoriz(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
|
||||
* Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
|
||||
* speed at this point and time.
|
||||
*
|
||||
* A gust front is descending air, not just faster air. Without this the field
|
||||
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
|
||||
* pressure goes with dot(wind, normal), a flat panel's normal points at the
|
||||
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
|
||||
* winning rig was "lie it flat and ignore the storm", which is the opposite of
|
||||
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
|
||||
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
|
||||
* a flat panel's normal points at the sky, so in a purely horizontal wind the
|
||||
* dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
|
||||
* game. A descending component hits a flat panel square on.
|
||||
*
|
||||
* SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only
|
||||
* semantics the downdraft peaked exactly at the gust peak, where the horizontal
|
||||
* ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
|
||||
* (B measured 34%) without a downdraft so violent it also killed the twisted
|
||||
* rig the §7 gate needs to survive. The two gates pincered. Riding total speed
|
||||
* instead spreads the load across the whole storm: a flat roof is pressed
|
||||
* steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
|
||||
* clears 60% at a gentle fraction, without a spike at the gust peak. It follows
|
||||
* the LOCAL speed, so a tree's wind shadow shelters from falling air too.
|
||||
*/
|
||||
function gustVertical(t) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < gusts.length; i++) {
|
||||
const g = gusts[i];
|
||||
if (t <= g.t0) break; // sorted — nothing later is live
|
||||
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
|
||||
}
|
||||
return v;
|
||||
function verticalAt(x, z, t) {
|
||||
if (downFrac <= 0) return 0;
|
||||
return -downFrac * localHoriz(x, z, t);
|
||||
}
|
||||
|
||||
// ---- noise drift ----
|
||||
@ -318,27 +326,22 @@ export function createWindField(def, opts = {}) {
|
||||
* The cheap path — no allocation.
|
||||
*/
|
||||
speedAt(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
return localHoriz(x, z, t);
|
||||
},
|
||||
|
||||
dirAt,
|
||||
uniformSpeed,
|
||||
gustOnly,
|
||||
gustVertical,
|
||||
verticalAt,
|
||||
get downFrac() { return downFrac; },
|
||||
|
||||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||||
vecAt(x, z, t, out) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||||
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
||||
let s = uni * m;
|
||||
if (s < 0) s = 0;
|
||||
const s = localHoriz(x, z, t);
|
||||
out.x = dirX * s;
|
||||
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
|
||||
out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt()
|
||||
out.z = dirZ * s;
|
||||
return out;
|
||||
},
|
||||
@ -415,9 +418,14 @@ export function validateStorm(def, name = 'storm') {
|
||||
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
|
||||
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
|
||||
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
|
||||
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
// `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
|
||||
// un-migrated storm loads visibly wrong rather than silently at a third power.
|
||||
if (g.downdraft != null && g.downdraftOfTotal == null) {
|
||||
bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
|
||||
}
|
||||
const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
|
||||
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
|
||||
bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,11 @@ const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
|
||||
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
|
||||
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
|
||||
|
||||
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
|
||||
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
|
||||
// the same line the graybox taught everyone to expect.
|
||||
const HOUSE = { x: 0, z: -10.5 };
|
||||
|
||||
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
|
||||
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
|
||||
const SUN_ELEV = (55 * Math.PI) / 180;
|
||||
@ -89,6 +94,8 @@ export function createWorld(scene, opts = {}) {
|
||||
const anchors = [];
|
||||
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
|
||||
const canopies = [];
|
||||
/** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
|
||||
const graybox = { house: null, trees: new Map() };
|
||||
|
||||
// --- sky & light -------------------------------------------------------
|
||||
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
|
||||
@ -174,6 +181,7 @@ export function createWorld(scene, opts = {}) {
|
||||
|
||||
root.add(house);
|
||||
solids.push(wall, roof);
|
||||
graybox.house = house;
|
||||
|
||||
for (const [i, x] of [-5, 0, 5].entries()) {
|
||||
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
|
||||
@ -222,6 +230,7 @@ export function createWorld(scene, opts = {}) {
|
||||
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
|
||||
|
||||
root.add(tree);
|
||||
graybox.trees.set(spec.id, tree);
|
||||
|
||||
// The anchor is at a branch fork, not the canopy centre.
|
||||
anchors.push(makeSwayAnchor(
|
||||
@ -236,9 +245,24 @@ export function createWorld(scene, opts = {}) {
|
||||
// Raked away from the yard centre, because that is the correct practice and
|
||||
// the shape should teach it before any text does (DESIGN.md: "rake the post
|
||||
// away from the load").
|
||||
// SPRINT3 decision 2: posts pulled in off the fence and a third added.
|
||||
//
|
||||
// The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in
|
||||
// the 70–192 m² range Lane B flagged — a sail that big pre-tensions itself
|
||||
// into a cascade at t=0.4 s before the wind has done anything, so the yard was
|
||||
// teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31
|
||||
// quads in the 18–45 m² band (8 of which shade a quarter of the bed or more).
|
||||
//
|
||||
// Worth knowing before anyone "fixes" it: the smallest quad that covers the
|
||||
// bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits
|
||||
// 10 m off the house, so any house-to-post sail is ~16 m long, and covering a
|
||||
// 6 m bed with it costs you a sail the storm will take. Full shade is meant to
|
||||
// be the expensive answer; the small quads buy survival and pay in patchy
|
||||
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
|
||||
const postSpecs = [
|
||||
{ id: 'p1', x: -6, z: 7, h: 4.0 },
|
||||
{ id: 'p2', x: 5, z: 7.5, h: 4.0 },
|
||||
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
|
||||
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
|
||||
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
|
||||
];
|
||||
const RAKE = (8 * Math.PI) / 180;
|
||||
for (const spec of postSpecs) {
|
||||
@ -366,6 +390,35 @@ export function createWorld(scene, opts = {}) {
|
||||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
/** Take a graybox stand-in out of the scene AND out of `solids`. */
|
||||
const retire = (obj) => {
|
||||
if (!obj) return;
|
||||
obj.traverse((o) => {
|
||||
const i = solids.indexOf(o);
|
||||
if (i >= 0) solids.splice(i, 1);
|
||||
o.geometry?.dispose();
|
||||
});
|
||||
const i = solids.indexOf(obj);
|
||||
if (i >= 0) solids.splice(i, 1);
|
||||
obj.parent?.remove(obj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Move an existing anchor onto the position Lane E baked, and take their
|
||||
* rating_hint with it. Mutates `pos` in place rather than reassigning it:
|
||||
* `interact.register` and Lane B's corners capture these vectors by
|
||||
* reference, and a reassign would leave them holding a stale one.
|
||||
*/
|
||||
const adoptAnchor = (glb, nodeName, anchorId) => {
|
||||
const node = glb.getObjectByName(nodeName);
|
||||
const anchor = anchors.find((a) => a.id === anchorId);
|
||||
if (!node || !anchor) return false;
|
||||
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
||||
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchor.collateral = node.userData?.collateral ?? null;
|
||||
return true;
|
||||
};
|
||||
|
||||
const load = async (name) => {
|
||||
try {
|
||||
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
|
||||
@ -379,7 +432,68 @@ export function createWorld(scene, opts = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const [shed, table] = await Promise.all([load('shed_01_v1'), load('shed_table_v1')]);
|
||||
const [shed, table, houseGlb, tree1, tree2] = await Promise.all([
|
||||
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
|
||||
load('tree_gum_01_v1'), load('tree_gum_02_v1'),
|
||||
]);
|
||||
|
||||
// --- house (decision 6: no re-cut, the GLB's data wins) ---------------
|
||||
// E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
|
||||
// graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
|
||||
// 4 m, which is a real part of why the yard now offers small quads at all.
|
||||
// Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's
|
||||
// "the fascia board is a lie" straight into the asset, and `collateral:
|
||||
// "gutter"` says what it takes with it when it goes.
|
||||
if (houseGlb) {
|
||||
retire(graybox.house);
|
||||
houseGlb.name = 'house_yardside';
|
||||
houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z);
|
||||
root.add(houseGlb);
|
||||
solids.push(houseGlb);
|
||||
houseGlb.updateWorldMatrix(true, true);
|
||||
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
|
||||
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- trees -----------------------------------------------------------
|
||||
// Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at
|
||||
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
|
||||
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
|
||||
// nothing that already references them breaks; the rest are added.
|
||||
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
|
||||
if (!glb) continue;
|
||||
const old = graybox.trees.get(spec.id);
|
||||
retire(old);
|
||||
// The graybox canopy was what world.update() swayed — hand that job over.
|
||||
const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group);
|
||||
if (idx >= 0) canopies.splice(idx, 1);
|
||||
|
||||
glb.name = `tree_${spec.id}`;
|
||||
glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z);
|
||||
root.add(glb);
|
||||
const trunk = glb.getObjectByName('trunk');
|
||||
if (trunk) solids.push(trunk);
|
||||
const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy');
|
||||
if (canopy?.parent) {
|
||||
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
|
||||
}
|
||||
|
||||
glb.updateWorldMatrix(true, true);
|
||||
const suffix = ['', 'b', 'c'];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const node = glb.getObjectByName(`branch_anchor_0${i}`);
|
||||
if (!node) continue;
|
||||
const id = spec.id + suffix[i - 1];
|
||||
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
|
||||
else {
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
|
||||
const a = makeSwayAnchor(id, p, spec.phase, wind);
|
||||
a.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchors.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shed) {
|
||||
shed.name = 'shed_01';
|
||||
|
||||
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 |
@ -1,263 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — Lane C — weather bench</title>
|
||||
<style>
|
||||
:root { --ink:#d8d8e0; --gold:#ffd23d; --neon:#3dff8b; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; overflow:hidden; background:#000;
|
||||
font:13px/1.45 "Courier New", ui-monospace, monospace; color:var(--ink); }
|
||||
canvas { display:block; }
|
||||
#hud { position:fixed; top:10px; left:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; min-width:250px; }
|
||||
#hud b { color:var(--gold); }
|
||||
#hud .warn { color:#ff6; font-weight:bold; }
|
||||
#hud .bad { color:#f66; font-weight:bold; }
|
||||
#ctl { position:fixed; bottom:10px; left:10px; background:rgba(6,6,12,.8); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; }
|
||||
#ctl button { background:#1d1d2b; color:var(--ink); border:1px solid #666; font:inherit;
|
||||
padding:4px 9px; cursor:pointer; }
|
||||
#ctl button:hover { border-color:var(--neon); color:var(--neon); }
|
||||
#ctl input[type=range] { width:220px; vertical-align:middle; }
|
||||
#note { position:fixed; top:10px; right:10px; background:rgba(6,6,12,.75); padding:8px 12px;
|
||||
border:1px solid #26263a; z-index:3; max-width:280px; color:#8a8a99; }
|
||||
.bar { display:inline-block; width:90px; height:7px; border:1px solid #555; vertical-align:middle; }
|
||||
.bar i { display:block; height:100%; background:var(--neon); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="hud"></div>
|
||||
<div id="note">
|
||||
<b>Lane C bench.</b> Graybox stand-in for Lane A's yard — this exists to drive
|
||||
weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK
|
||||
(Lane B owns the real one); it's a bare node grid so debris impulse is visible.
|
||||
<br><br>drag = orbit · click = start audio
|
||||
</div>
|
||||
<div id="ctl"></div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from './vendor/three.module.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
import { createDebris } from './js/debris.js';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(2, devicePixelRatio));
|
||||
renderer.shadowMap.enabled = true;
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 500);
|
||||
|
||||
// --- graybox yard: 30×20 m, origin centre (stands in for Lane A's world.js) ---
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(30, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4a7c3f, roughness: 1 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
scene.add(ground);
|
||||
|
||||
// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9
|
||||
const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }];
|
||||
for (const tr of TREES) {
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.2, 0.28, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5a3d24 }),
|
||||
);
|
||||
trunk.position.set(tr.x, 2, tr.z);
|
||||
trunk.castShadow = true;
|
||||
scene.add(trunk);
|
||||
const canopy = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(3, 12, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x285f23 }),
|
||||
);
|
||||
canopy.position.set(tr.x, 5, tr.z);
|
||||
canopy.castShadow = true;
|
||||
scene.add(canopy);
|
||||
}
|
||||
// house edge along north (-Z), for scale
|
||||
const house = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(30, 3.2, 1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8a8f96 }),
|
||||
);
|
||||
house.position.set(0, 1.6, -10.4);
|
||||
scene.add(house);
|
||||
// the thing you're protecting — Lane A's gardenBed rect
|
||||
const bed = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(6, 0.25, 4),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6b4a2f }),
|
||||
);
|
||||
bed.position.set(1, 0.12, 2);
|
||||
scene.add(bed);
|
||||
// 1.7 m reference person
|
||||
const ref = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.25, 1.2, 4, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffd27a }),
|
||||
);
|
||||
ref.position.set(2, 0.85, 2);
|
||||
ref.castShadow = true;
|
||||
scene.add(ref);
|
||||
const player = { pos: ref.position, carrying: null, busy: false };
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xfff4e0, 2.2);
|
||||
sun.position.set(-12, 18, 6);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(1024, 1024);
|
||||
scene.add(sun);
|
||||
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 0.9);
|
||||
scene.add(hemi);
|
||||
|
||||
// --- MOCK sail (Lane B owns the real cloth) — a bare node grid so we can see
|
||||
// debris shove it and drive the creak/flog audio off corner loads.
|
||||
const N = 9;
|
||||
const nodes = [];
|
||||
for (let v = 0; v < N; v++) {
|
||||
for (let u = 0; u < N; u++) {
|
||||
nodes.push({ x: -4 + (u / (N - 1)) * 8, y: 3.2, z: -3 + (v / (N - 1)) * 6 });
|
||||
}
|
||||
}
|
||||
const sailGeo = new THREE.BufferGeometry();
|
||||
sailGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(nodes.length * 3), 3));
|
||||
const sailPts = new THREE.Points(sailGeo, new THREE.PointsMaterial({ color: 0xe8c46a, size: 0.14 }));
|
||||
scene.add(sailPts);
|
||||
const mockSail = {
|
||||
nodes,
|
||||
corners: [
|
||||
{ anchorId: 'h1', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
{ anchorId: 'h3', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p1', hw: { name: 'shackle', rating: 19 }, load: 0, broken: false },
|
||||
{ anchorId: 'p2', hw: { name: 'carabiner', rating: 9 }, load: 0, broken: false },
|
||||
],
|
||||
};
|
||||
|
||||
// --- weather ---
|
||||
const params = new URLSearchParams(location.search);
|
||||
const stormName = params.get('storm') || 'storm_02_wildnight';
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
wind.setShelters(TREES.map((t) => ({ x: t.x, z: t.z, radius: 3, strength: 0.45, length: 14 })));
|
||||
|
||||
const ticker = [];
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi, onEvent: (s) => ticker.unshift(s) });
|
||||
const debris = createDebris({
|
||||
wind, scene, player,
|
||||
onEvent: (s) => ticker.unshift(s),
|
||||
onHitPlayer: (p, impact) => ticker.unshift(`KNOCKED DOWN by ${p.model} (${impact.toFixed(0)})`),
|
||||
});
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: true });
|
||||
|
||||
// --- controls ---
|
||||
let t = 0, playing = true, rate = 1;
|
||||
const ctl = document.getElementById('ctl');
|
||||
ctl.innerHTML = `
|
||||
<button id="play">pause</button>
|
||||
<button id="r1">1×</button><button id="r4">4×</button><button id="r0">0.25×</button>
|
||||
<button id="reset">reset</button>
|
||||
<button id="break">break a corner</button>
|
||||
<button id="crate">throw a crate</button>
|
||||
<input id="scrub" type="range" min="0" max="${def.duration}" step="0.1" value="0">
|
||||
`;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
$('play').onclick = () => { playing = !playing; $('play').textContent = playing ? 'pause' : 'play'; };
|
||||
$('r1').onclick = () => { rate = 1; };
|
||||
$('r4').onclick = () => { rate = 4; };
|
||||
$('r0').onclick = () => { rate = 0.25; };
|
||||
$('reset').onclick = () => { t = 0; debris.clear(); ticker.length = 0; mockSail.corners.forEach((c) => { c.broken = false; }); };
|
||||
$('break').onclick = () => { const c = mockSail.corners.find((x) => !x.broken); if (c) { c.broken = true; ticker.unshift(`${c.hw.name} BLOWS at ${c.anchorId.toUpperCase()}!`); } };
|
||||
$('crate').onclick = () => debris.spawn({ model: 'BlueCrate_v2', lateral: (Math.random() * 6 - 3), text: 'crate!' }, t);
|
||||
$('scrub').oninput = (e) => { t = parseFloat(e.target.value); debris.clear(); };
|
||||
|
||||
let yaw = 0.7, pitch = 0.28, dist = 26, dragging = false, lx = 0, ly = 0;
|
||||
addEventListener('pointerdown', (e) => { dragging = true; lx = e.clientX; ly = e.clientY; });
|
||||
addEventListener('pointerup', () => { dragging = false; });
|
||||
addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
yaw -= (e.clientX - lx) * 0.005; pitch = Math.min(1.3, Math.max(0.05, pitch + (e.clientY - ly) * 0.004));
|
||||
lx = e.clientX; ly = e.clientY;
|
||||
});
|
||||
addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.deltaY * 0.02)); });
|
||||
|
||||
function resize() {
|
||||
const w = innerWidth, h = innerHeight;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
addEventListener('resize', resize); resize();
|
||||
|
||||
// --- loop: fixed-dt sim, rAF only drives the clock (PLAN3D §0) ---
|
||||
const DT = 1 / 60;
|
||||
let acc = 0, last = performance.now();
|
||||
const hud = document.getElementById('hud');
|
||||
const probe = new THREE.Vector3();
|
||||
const w = new THREE.Vector3();
|
||||
const posAttr = sailGeo.getAttribute('position');
|
||||
|
||||
function frame(now) {
|
||||
const real = Math.min(0.1, (now - last) / 1000);
|
||||
last = now;
|
||||
if (playing) acc += real * rate;
|
||||
|
||||
while (acc >= DT) {
|
||||
acc -= DT;
|
||||
t += DT;
|
||||
if (t > def.duration) t = 0;
|
||||
|
||||
// mock cloth: nodes just bob with local wind so debris has something to hit
|
||||
for (const n of nodes) {
|
||||
probe.set(n.x, n.y, n.z);
|
||||
wind.sample(probe, t, w);
|
||||
const sp = Math.hypot(w.x, w.z);
|
||||
n.y += ((3.2 + Math.sin(t * 3 + n.x) * sp * 0.02) - n.y) * 0.08;
|
||||
}
|
||||
// mock loads so the creak layer has something to track
|
||||
probe.set(0, 3.2, 0);
|
||||
const sp = wind.speedAt(probe, t);
|
||||
mockSail.corners.forEach((c, i) => {
|
||||
c.load = c.broken ? 0 : sp * sp * 0.021 * (0.7 + i * 0.16);
|
||||
});
|
||||
|
||||
debris.step(DT, t, { player, sail: mockSail });
|
||||
sky.step(DT, t, { sail: mockSail });
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) posAttr.setXYZ(i, nodes[i].x, nodes[i].y, nodes[i].z);
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
camera.position.set(
|
||||
Math.sin(yaw) * Math.cos(pitch) * dist,
|
||||
Math.sin(pitch) * dist + 1.5,
|
||||
Math.cos(yaw) * Math.cos(pitch) * dist,
|
||||
);
|
||||
camera.lookAt(0, 2, 0);
|
||||
|
||||
$('scrub').value = t.toFixed(1);
|
||||
probe.set(0, 1.7, 0);
|
||||
wind.sample(probe, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const tg = wind.gustTelegraph(t);
|
||||
const worst = Math.max(...mockSail.corners.map((c) => (c.broken ? 0 : c.load / c.hw.rating)));
|
||||
hud.innerHTML = `
|
||||
<div><b>${def.name}</b> — ${stormName}</div>
|
||||
<div>t <b>${t.toFixed(1)}</b> / ${def.duration}s (${rate}×)</div>
|
||||
<div>wind <b>${speed.toFixed(1)}</b> m/s (${(speed * 3.6).toFixed(0)} km/h)</div>
|
||||
<div>dir ${(wind.dirAt(t)).toFixed(2)} rad</div>
|
||||
<div>rain <span class="bar"><i style="width:${wind.rainAt(t) * 100}%"></i></span></div>
|
||||
<div>worst <span class="bar"><i style="width:${Math.min(100, worst * 100)}%;background:${worst > 0.8 ? '#f66' : '#3dff8b'}"></i></span></div>
|
||||
<div>debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}</div>
|
||||
<div>flash ${sky.flash.toFixed(2)}</div>
|
||||
${tg ? `<div class="warn">GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}</div>` : '<div> </div>'}
|
||||
${ticker.slice(0, 3).map((s) => `<div class="bad">${s}</div>`).join('')}
|
||||
`;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
window.__bench = { wind, sky, debris, mockSail, def, get t() { return t; } };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user