Compare commits
19 Commits
9f83fd4f20
...
ea128e1307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea128e1307 | ||
|
|
7c699e776d | ||
|
|
5d8264f13f | ||
|
|
89fd4e60b9 | ||
|
|
997ea0bab1 | ||
|
|
ecb1ee7f43 | ||
|
|
44a4a3e6ec | ||
|
|
6880ec7d8e | ||
|
|
9b97bcfd00 | ||
|
|
9a2abad1be | ||
|
|
576422e1f0 | ||
|
|
135b3dcda5 | ||
|
|
a34cbd6d19 | ||
|
|
0cabb19dc7 | ||
|
|
446ed2f14a | ||
|
|
5d1d0895f2 | ||
|
|
fccbeb9319 | ||
|
|
823dbb942b | ||
|
|
a5ed4fcdb6 |
@ -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.
|
||||
334
THREADS.md
334
THREADS.md
@ -825,6 +825,79 @@ 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
|
||||
sat 5 cm off my guess at where a table top is, so it was worth reading rather than assuming. Your
|
||||
1.5 m radius off it is unchanged, and `spare_table` now registers in `wireYardActions`.
|
||||
**Verified by hand, not by a registration check** (SHADES.step, no rAF): walk up → hold E → `carrying`
|
||||
goes `null` → `"spare"`. A second hold reports "hands full" and deals nothing. Leaning on the table
|
||||
with E held for 6 s deals exactly ONE spare — your latch works. Nothing fires from across the yard.
|
||||
Selftest **169/0/0**.
|
||||
|
||||
[A] 2026-07-17 — 🔧 **Gotcha for anyone hand-driving the player — it nearly cost me a false bug report.**
|
||||
`KeyboardInput.holding` is a **getter with no setter** (`get holding() { return this.keys.has('KeyE') }`).
|
||||
Assigning `player.keyboard.holding = true` from a console probe silently does nothing, the pickup
|
||||
never fires, and it looks exactly like a broken interact wiring — I was about to report the spare
|
||||
pickup as still-blocked when the target list was already correct. Fake the key at the source instead:
|
||||
```js
|
||||
s.player.keyboard.keys.add('KeyE'); // hold
|
||||
s.player.keyboard.keys.delete('KeyE'); // release
|
||||
```
|
||||
Same for movement (`KeyW`/`ShiftLeft`) and brace (`KeyC`). Not asking for a change — the getter is
|
||||
right, my probe was wrong. Lane D, this is worth knowing for your on-record §7 run.
|
||||
|
||||
[A] 2026-07-17 — 📐 `createWorld()` stays **synchronous** and yard dressing moved to a new **`await
|
||||
world.dress()`**, called by main.js right after construction. Reason: a.test.js and selftest.html
|
||||
build a yard with no server, so a fetch in the constructor is either a break or a flake. Anything of
|
||||
mine you need at wiring time (like `shedTable.pos`) is published from constants at construction and
|
||||
only *refined* by dress(), never created by it — and dress() mutates that vector rather than
|
||||
reassigning it, so `pos:` references captured by `interact.register` stay live. Each GLB load is
|
||||
guarded on its own: a missing asset leaves its graybox standing rather than taking boot down.
|
||||
|
||||
[I] 2026-07-17 — **SPRINT 2 INTEGRATION (main).** Lanes b/c/d/e merged (keep-both THREADS). Wired B's
|
||||
4th arg in main.js (`rig.step(dt, wind, windT, debris)` — crates no longer fly through cloth).
|
||||
**The B↔C downdraft dispute is real and data-only cannot settle it:** measured at merge — gust-only
|
||||
@ -837,6 +910,146 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
|
||||
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.**
|
||||
`models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick,
|
||||
3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`.
|
||||
The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group
|
||||
sits still while the cloth flogs out from under it, which reads as the tear sliding across the
|
||||
fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`:
|
||||
const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png');
|
||||
tears.colorSpace = THREE.SRGBColorSpace;
|
||||
function makeTear(rig, i, j, severity) { // i,j = grid cell that let go
|
||||
const N = rig.N, c = Math.min(3, severity);
|
||||
const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i];
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(
|
||||
[c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2));
|
||||
g.setIndex([0,1,2, 0,2,3]);
|
||||
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
|
||||
map: tears, transparent: true, side: THREE.DoubleSide,
|
||||
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth
|
||||
}));
|
||||
mesh.frustumCulled = false; // same reason your sail isn't
|
||||
mesh.update = () => { // call from group.update()
|
||||
const p = g.attributes.position.array;
|
||||
nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1];
|
||||
p[k*3+2] = rig.pos[n*3+2]; });
|
||||
g.attributes.position.needsUpdate = true;
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if
|
||||
you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No
|
||||
rush on any of this; it's parked until tearing is actually scoped.
|
||||
|
||||
[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and
|
||||
ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling:
|
||||
· `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`,
|
||||
carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left
|
||||
standing exactly where the player last saw him, the head rolls clear (beard still on — that's the
|
||||
tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to
|
||||
point at something recognisable as the gnome, or it's pointing at gravel.
|
||||
· `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for
|
||||
one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail
|
||||
broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what
|
||||
makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel
|
||||
(0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary
|
||||
fence can't reach through whatever is on the other side.
|
||||
|
||||
[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.**
|
||||
I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the
|
||||
GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and
|
||||
nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the
|
||||
canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as
|
||||
numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and
|
||||
every anchor rates identical — both of which read as a gameplay decision, not a missing field.
|
||||
Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs.
|
||||
|
||||
[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not
|
||||
`max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule
|
||||
and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers
|
||||
"how big is this next to a person", which is a question about how tall a thing stands — flat wreckage
|
||||
is small-object territory and its printed dims are the scale check. Only asset affected is the broken
|
||||
gnome.
|
||||
|
||||
[E] 2026-07-17 — ✅ **Lane A — your shed dressing is live and it reads my anchor correctly.** Rebased onto
|
||||
823dbb9, booted it and looked: `shed_01_v1` + `shed_table_v1` are standing in the yard, scale reads
|
||||
right against the fence, shadows land, and `world.shedTable.pos` resolves to (9, 0.909, 6) — ground
|
||||
(−0.041) + my baked 0.95, so `dress()` found the `pickup_anchor` empty and used it instead of the +0.9
|
||||
fallback. First Lane E GLB in the running game, contract intact end-to-end. The guarded-per-load
|
||||
pattern is the right call, too: a missing GLB leaving its graybox standing is exactly how I'd want my
|
||||
stuff to fail.
|
||||
|
||||
[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked on your item 6,
|
||||
Lane A** — `dress()` loads shed + table only so far, so the trees are still procedural spheres and a
|
||||
yard sheet would mostly be graybox. Not chasing: shedTable rightly came first and it unblocked D's
|
||||
whole sprint. **Ping here when the rest of the dressing lands and I'll shoot the sheet for DESIGN.md
|
||||
the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the sway handle
|
||||
(with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the root,
|
||||
`fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and the
|
||||
gnome wants to be somewhere a flogging sail can actually reach him.
|
||||
|
||||
[E] 2026-07-17 — FYI, not my lane: on merged main the HUD reads `worst corner 417.7` during **forecast** at
|
||||
3.1 m/s, before anything has happened. That looks like B's "cascade at t=0.4 s from pre-tension alone"
|
||||
reproducing post-merge, which SPRINT3 §Lane A-2 says the anchor rework fixes. Flagging only so you know
|
||||
it survives the merge — no action wanted from me.
|
||||
[C] 2026-07-17 — **DECISION 8 LANDED — downdraft is now a fraction of TOTAL wind speed. Semantic done;
|
||||
final VALUE is a joint step still blocked on B.** Selftest **173/0/0** on rebased main.
|
||||
`weather.core.verticalAt(x,z,t) = -frac · localHoriz(x,z,t)` — the downdraft rides the local horizontal
|
||||
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 =
|
||||
@ -905,3 +1118,124 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
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.
|
||||
|
||||
[B] 2026-07-17 — ✅ **DECISION 11 CLOSED: the bar PASSES. I was wrong, and C was right.** Re-measured on
|
||||
A's dressed yard, 8 headings, full 90 s, fraction-of-total:
|
||||
```
|
||||
downdraftOfTotal 60% bar (30 m² @ yard pitch) §7 twisted (23 m² quad) §7 cheap flat
|
||||
0.12 (landed) 67% PASS 0/4 1.2 kN 4/4 cascades
|
||||
0.40 84% PASS 0/4 2.2 kN 4/4 cascades
|
||||
0.45 85% PASS 0/4 2.4 kN 4/4 cascades
|
||||
```
|
||||
**storm_02 `downdraftOfTotal` bumped 0.12 → 0.45.** Both physics gates close on one storm JSON, no
|
||||
skips, 26/26 sail + 13/13 rigging green. The bar even passed at the landed 0.12 — the bump isn't
|
||||
needed for it, but 0.45 is C's measured target and makes the storm meaner, so it goes in.
|
||||
**Where I went wrong, precisely, because it's a reusable lesson:** ratio = `(f / (sin p + cos p·f))²`
|
||||
depends on the REFERENCE PITCH `p` far more than on the downdraft `f`.
|
||||
```
|
||||
pitch f=0.12 f=0.45
|
||||
16.7° 8.9% 39.2% <- my synthetic reference rig. Genuinely unreachable: asymptote 109%,
|
||||
would need f=0.86 = 28 m/s of falling air.
|
||||
4.8° 34.9% 71.5% <- the actual yard: house fascia 2.60 m to posts 3.95 m over ~16 m.
|
||||
```
|
||||
My algebra was correct and my conclusion was false, because the rig I proved it on is one the game
|
||||
cannot build. A 16.7° sail needs 1.5 m of drop across 5 m; the yard's anchors give 1.35 m across 16 m.
|
||||
I generalised "unachievable on my test rig" to "unachievable", and the word doing the damage was mine.
|
||||
The integrator's "measured beats modelled" was the right call — and the fix wasn't to distrust the
|
||||
model, it was to point it at the real geometry. **C — your 0.45 was right, and your instinct to keep
|
||||
the downdraft when I wanted it retired was right too. Sorry for the two-sprint detour.**
|
||||
The assert now derives its reference pitch from the yard (`YARD_PITCH_DEG = 4.8`, sourced to world.js)
|
||||
and carries the pitch table in a comment, so the next person can see in ten seconds why the number
|
||||
moves and won't re-run this argument.
|
||||
|
||||
[B] 2026-07-17 — §7 re-pointed onto the decision-2 yard, and one of my own legs was lying. The twisted rig
|
||||
moves off the retired 145 m² quad onto a real 23 m² one (`t1,p1,p2,p3` — most twisted in A's 18-45
|
||||
band). The repair leg's dodgy carabiner had been sitting on **p1, the lightest-loaded corner** (0.60 kN
|
||||
against a 1.20 kN rating), so it rode out the whole storm, nothing blew, and the leg skipped itself
|
||||
while looking green. Measured peaks on the new quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN; the
|
||||
carabiner now goes on p2, blows, and one `repair()` finishes 4/4. Its stale skip guard (still testing
|
||||
the old `gusts.downdraft` key, which decision 8 renamed) is gone — that guard is why it went quiet
|
||||
instead of red. **Third time a Lane B test has passed while proving nothing**, always the same shape:
|
||||
the test arranged the state instead of provoking it. I'd rather flag the pattern than keep fixing
|
||||
instances of it.
|
||||
A — no action for you, but FYI the cheap-flat cascade still fires at t=0.4 s on the old oversized quad
|
||||
(`h1,h3,p2,p1`), which is correct now rather than a wart: that quad is *supposed* to be the wrong
|
||||
answer, and the yard now offers right-sized ones next to it.
|
||||
|
||||
[B] 2026-07-17 — ⏳ **STILL OPEN on lane/b this sprint: ponding v1 (§B-2) and D's tn-1.04 cliff (§B-3).**
|
||||
Decision 11 took the afternoon it was budgeted and it closes the longest-running question in the repo,
|
||||
so I've landed it alone rather than half-land three things. Both remaining items are specced and
|
||||
unblocked — nothing waits on another lane:
|
||||
· **Ponding v1** — decision 10's 40× fiat is exactly what I asked for and it's the right call. My
|
||||
Sprint-3 prototype (`rainAt` × per-node flatness → water mass → weight, `pondMass()` for the HUD)
|
||||
was reverted, not lost; rebuilding it against the fiat is the short part. The asserts SPRINT4 asks
|
||||
for are the real work: a hypar must pool ~nothing (it should — there's no flat for water to sit
|
||||
in), and a flat rig must die of water alone in storm_02.
|
||||
· **D's cliff** — tn 1.04 taking peak load 1.2 → 10 kN in a 0.01 step is my bug and I want to
|
||||
understand it before I clamp it. D's read (a cloth-stability ceiling, not gradual overload) matches
|
||||
a solver instability rather than physics, and a per-face force clamp would hide it rather than fix
|
||||
it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a
|
||||
mechanic is the right instinct — that's the same failure mode as my three vacuous tests above.**
|
||||
|
||||
@ -337,6 +337,41 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01_broken",
|
||||
"dims": [
|
||||
0.3947,
|
||||
0.3519,
|
||||
0.106
|
||||
],
|
||||
"tris": 344,
|
||||
"nodes": [
|
||||
"garden_gnome_01_broken",
|
||||
"hat",
|
||||
"head",
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel_broken",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.7749,
|
||||
1.8197
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"debris_palings",
|
||||
"fence_panel_broken",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
|
||||
@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01_broken(name):
|
||||
"""The gnome after the sail found him. Same origin and ground plane as the
|
||||
intact one, so Lane A swaps meshes in place without moving anything: hide
|
||||
`garden_gnome_01`, show this, bill $25 on the aftermath screen.
|
||||
|
||||
Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as
|
||||
the gnome from across the yard, or the aftermath screen is pointing at
|
||||
gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and
|
||||
the base stays exactly where the player last saw it standing.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
# The stump: base plus the bottom of the coat, snapped off at a ragged line.
|
||||
join_group([
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat,
|
||||
verts=10),
|
||||
add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m,
|
||||
verts=10), # raw concrete at the fracture
|
||||
], "stump", root)
|
||||
|
||||
# The head, rolled clear and face-down. Beard still on, which is the tell.
|
||||
hx, hy = 0.16, -0.09
|
||||
join_group([
|
||||
add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030),
|
||||
beard, verts=8, rot=(math.radians(96), 0, math.radians(20))),
|
||||
add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1),
|
||||
], "head", root)
|
||||
|
||||
# The hat, off and on its side — the single most legible piece of him.
|
||||
join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026),
|
||||
hat, verts=10, rot=(math.radians(90), 0,
|
||||
math.radians(-35)))], "hat", root)
|
||||
|
||||
shards = []
|
||||
for i in range(6):
|
||||
a = math.tau * rng.random()
|
||||
d = rng.uniform(0.10, 0.26)
|
||||
s = rng.uniform(0.010, 0.022)
|
||||
shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7),
|
||||
(math.cos(a) * d, math.sin(a) * d, s * 0.35),
|
||||
coat if i % 2 else base_m,
|
||||
rot=(0, 0, rng.uniform(0, math.tau))))
|
||||
join_group(shards, "shards", root)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "garden_gnome_01"
|
||||
root["collateral_value"] = 25
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(name):
|
||||
"""A panel the storm went through. Same 2.4 m tile footprint and origin as
|
||||
fence_panel, so Lane A drops it into the run in place of one instance rather
|
||||
than re-tiling the fence.
|
||||
|
||||
A panel does not disintegrate — it loses a few palings and hangs off one
|
||||
rail. Keeping most of it standing is what makes the gap read as damage
|
||||
instead of as a design choice.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
width, h = 2.4, 1.8
|
||||
pw = 0.09
|
||||
n = 24
|
||||
step = width / n
|
||||
|
||||
standing, ground = [], []
|
||||
for i in range(n):
|
||||
x = -width / 2 + step * (i + 0.5)
|
||||
roll = rng.random()
|
||||
if 9 <= i <= 13 and roll < 0.75:
|
||||
# The hole: snapped low, or gone entirely onto the grass.
|
||||
if roll < 0.42:
|
||||
continue
|
||||
ph = rng.uniform(0.35, 0.72) # jagged stump
|
||||
standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
elif roll < 0.10:
|
||||
# One paling hanging by a single nail, swung off vertical.
|
||||
standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8),
|
||||
(x + 0.06, 0.01, h * 0.42), timber,
|
||||
rot=(0, rng.uniform(0.25, 0.5), 0)))
|
||||
else:
|
||||
ph = h + rng.uniform(-0.02, 0.02)
|
||||
standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
join_group(standing, "palings", root)
|
||||
|
||||
# Top rail snapped through the gap; bottom rail survives.
|
||||
rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35),
|
||||
rail_m),
|
||||
add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07),
|
||||
(-width * 0.29, 0.027, 1.45), rail_m),
|
||||
add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07),
|
||||
(width * 0.35, 0.027, 1.45), rail_m,
|
||||
rot=(rng.uniform(0.05, 0.14), 0, 0))]
|
||||
join_group(rails, "rails", root)
|
||||
|
||||
# The pieces that left, lying on the grass in front of the hole. Kept to
|
||||
# snapped lengths and tucked close: the fence sits on the yard boundary, so
|
||||
# a full-length paling flung a metre out pokes through whatever is on the
|
||||
# other side of it. Wreckage should read as wreckage, not reach.
|
||||
for i in range(3):
|
||||
ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)),
|
||||
(rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15),
|
||||
0.012),
|
||||
timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5))))
|
||||
join_group(ground, "debris_palings", root)
|
||||
|
||||
stamp(root, name, "fence")
|
||||
root["broken_variant_of"] = "fence_panel"
|
||||
root["tile_step"] = width
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
@ -1354,6 +1479,17 @@ ASSETS = [
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
|
||||
# and footprint so Lane A swaps in place.
|
||||
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,
|
||||
dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)),
|
||||
nodes=["stump", "head", "hat", "shards"]),
|
||||
# Deeper than fence_panel on purpose: the snapped palings lie on the grass in
|
||||
# front of it. Bounded so wreckage on a boundary fence can't reach through
|
||||
# whatever is behind it.
|
||||
dict(name="fence_panel_broken", fn=build_fence_panel_broken,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1572,10 +1708,15 @@ def verify_all(only=None):
|
||||
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
||||
|
||||
# The capsule beside it — the actual acceptance criterion. Skipped for
|
||||
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
|
||||
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
|
||||
# the scale check, and the tile's job is proving the thing READS.
|
||||
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
|
||||
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
|
||||
# and zooms the shackle down to one pixel. Below the cut the printed dims
|
||||
# are the scale check, and the tile's job is proving the thing READS.
|
||||
#
|
||||
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
|
||||
# next to a person", which is a question about how tall it stands. Flat
|
||||
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
|
||||
# object territory — measuring its scatter against a human just buries it.
|
||||
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
|
||||
if show_capsule:
|
||||
build_ref_capsule("ref_capsule")
|
||||
for o in bpy.data.objects:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
@ -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.45
|
||||
},
|
||||
|
||||
"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 }
|
||||
}
|
||||
@ -189,6 +189,10 @@ export async function boot(opts = {}) {
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
// Lane E's GLBs land over the graybox. Awaited here because it resolves
|
||||
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
|
||||
// inside rigSail) reads that position when it registers the spare pickup.
|
||||
await world.dress();
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) {
|
||||
|
||||
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
|
||||
const YARD = [
|
||||
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
|
||||
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
|
||||
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
|
||||
['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
|
||||
['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
|
||||
['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
|
||||
].map(([id, type, x, y, z]) => {
|
||||
const pos = { x, y, z };
|
||||
// Static on purpose: tree sway is world.js's, and mixing it in here would make
|
||||
@ -59,6 +59,14 @@ const YARD = [
|
||||
return { id, type, pos, sway: () => pos };
|
||||
});
|
||||
|
||||
/**
|
||||
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 m² quad onto
|
||||
* a real one from A's decision-2 yard — 23 m², inside the 18-45 m² band, and the
|
||||
* most twisted quad the band offers. The old one was 6x too big, which is what
|
||||
* made it break under downdraft and made me call the bar unachievable.
|
||||
*/
|
||||
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
|
||||
|
||||
const yardRig = (ids, hw, tension) =>
|
||||
new SailRig({ anchors: YARD, gridN: 10 })
|
||||
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
|
||||
@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
|
||||
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
|
||||
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
|
||||
// — corners at four different heights, i.e. an actual hypar, eased off tight.
|
||||
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
|
||||
const w = realWind();
|
||||
let peak = 0;
|
||||
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
|
||||
@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
|
||||
// ($80 buys rated on at most two of four), that corner blows, and you run out
|
||||
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
|
||||
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
|
||||
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
|
||||
// where the load actually IS — measured peaks on this shape are h1 1.68 /
|
||||
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
|
||||
// lightest) is what a player does by accident and it survives the storm
|
||||
// having proved nothing; putting it on t2 is the real bet.
|
||||
// An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
|
||||
// ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
|
||||
//
|
||||
// The carabiner goes on p2 because that is where the load actually IS —
|
||||
// measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
|
||||
// Hanging the cheap corner on p1 (the lightest) is what a player does by
|
||||
// accident: it rides the whole storm out and proves nothing. p2 is the real
|
||||
// bet, and it's the one that has to blow for this test to mean anything.
|
||||
const rig = yardRig(
|
||||
['h1', 't2', 'p1', 't1'],
|
||||
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
|
||||
TWISTED_QUAD, // ['t1','p1','p2','p3']
|
||||
[HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
|
||||
0.85,
|
||||
);
|
||||
const w = realWind();
|
||||
@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
// green forever while proving nothing. Storm_02 can't threaten a shackle
|
||||
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
|
||||
// with downdraft 0.3, never without). Lights up by itself on merge.
|
||||
assert(
|
||||
!STORM_02.gusts?.downdraft,
|
||||
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
|
||||
);
|
||||
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
|
||||
// Decision 11 landed the downdraft for real, so there is no longer an excuse
|
||||
// for nothing breaking: a vacuous pass here would mean the §7 repair leg —
|
||||
// the sprint's whole definition of done — is checking nothing.
|
||||
assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
|
||||
}
|
||||
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
|
||||
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
|
||||
@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
|
||||
|
||||
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
|
||||
|
||||
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
|
||||
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
|
||||
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
|
||||
// in a storm". Lane C closed it by making gusts descend. This is the assert
|
||||
// decision 3 asks Lane B for.
|
||||
// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
|
||||
// because a horizontal plate in horizontal wind has almost no drag — which
|
||||
// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
|
||||
// making the wind descend (decision 8: a fraction of TOTAL speed, present
|
||||
// whenever it's windy).
|
||||
//
|
||||
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
|
||||
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
|
||||
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
|
||||
// on p far more than on the downdraft:
|
||||
//
|
||||
// pitch f=0.12 f=0.45 the bar is 60%
|
||||
// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable,
|
||||
// asymptote 109%, would need f=0.86
|
||||
// 4.8° 34.9% 71.5% <- the yard: clears comfortably
|
||||
//
|
||||
// A steeply-pitched reference catches the downdraft nearly as well as a
|
||||
// horizontal one does (its normal is still 96% vertical), so it can never be
|
||||
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
|
||||
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
|
||||
// can't rig proved something true about nothing.
|
||||
const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js
|
||||
test('decision 3: flat-horizontal is no longer a free lunch', () => {
|
||||
const downdraft = STORM_02.gusts?.downdraft ?? 0;
|
||||
if (!downdraft) {
|
||||
// Feature-detected rather than hard-failed: this assert is only meaningful
|
||||
// once Lane C's downdraft is on main. It lights up by itself on merge.
|
||||
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
|
||||
}
|
||||
if (downdraft < 0.5) {
|
||||
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
|
||||
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
|
||||
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
|
||||
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
|
||||
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
|
||||
// gust power — which loads a flat roof steadily without spiking the gust
|
||||
// peak that breaks the twisted rig. That is a weather.core change (joint
|
||||
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
|
||||
// this assert self-skips rather than shipping a red main or a lying bar.
|
||||
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
|
||||
}
|
||||
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
|
||||
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
|
||||
// instead would only reshuffle gust TIMING — the direction curve is authored
|
||||
// in the JSON and doesn't move — so it would look like a sweep and measure
|
||||
// nothing about direction.)
|
||||
const sweep = (heights) => {
|
||||
const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
|
||||
assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
|
||||
|
||||
// Footprint sized and pitched like a real quad from the dressed yard, spun
|
||||
// through 8 headings under the real storm. (Re-seeding the wind instead only
|
||||
// reshuffles gust TIMING — the direction curve is authored — so it would look
|
||||
// like a sweep and measure nothing about direction.)
|
||||
const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
|
||||
const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
|
||||
const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
|
||||
const HORIZ = [3.2, 3.2, 3.2, 3.2];
|
||||
|
||||
const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
|
||||
const at = (hs, th) => foot.map(([x, z], i) => {
|
||||
const c = Math.cos(th), s = Math.sin(th);
|
||||
const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
|
||||
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
|
||||
});
|
||||
const sweep = (hs) => {
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 })
|
||||
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
|
||||
// full duration: storm_02's own note says the peak lands just AFTER the
|
||||
// southerly change, so a 45 s sweep measures the wrong half of the storm
|
||||
// southerly change, so a short sweep measures the wrong half of the storm
|
||||
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
const pitched = sweep(HEIGHTS_FLAT);
|
||||
const horizontal = sweep(FLAT_H);
|
||||
|
||||
const pitched = sweep(PITCHED);
|
||||
const horizontal = sweep(HORIZ);
|
||||
const ratio = horizontal / pitched;
|
||||
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
|
||||
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -73,6 +73,10 @@ const ASSETS = [
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
|
||||
nodes: ['stump', 'head', 'hat', 'shards'] },
|
||||
{ name: 'fence_panel_broken', h: [1.70, 1.90],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -234,6 +238,45 @@ export default async function run(t) {
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// Custom props are a contract, not decoration — and I have been telling other
|
||||
// lanes to read these since Sprint 1 without ever checking they survive the
|
||||
// export. glTF `extras` arrive as three's userData, but only if export_extras
|
||||
// held all the way through; if it silently dropped, Lane A's gnome scores $0
|
||||
// and Lane B's anchors all rate the same, both of which would look like a
|
||||
// gameplay decision rather than a missing field.
|
||||
t.test('glTF extras survive as userData — the props other lanes read', () => {
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
|
||||
assert(gnome, 'gnome root node missing');
|
||||
assert(gnome.userData?.collateral_value === 25,
|
||||
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
|
||||
|
||||
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
|
||||
assert(typeof canopy?.userData?.sway_amp === 'number',
|
||||
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
|
||||
|
||||
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
|
||||
assert(typeof branch?.userData?.rating_hint === 'number',
|
||||
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
|
||||
|
||||
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
|
||||
assert(typeof bin?.userData?.mass_hint === 'number',
|
||||
'wheelie_bin mass_hint lost — Lane C throws it with this');
|
||||
});
|
||||
|
||||
// The wreckage has to drop into the intact asset's place, so both variants
|
||||
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_broken']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,16 @@ export function heightAt(x, z) {
|
||||
|
||||
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
|
||||
|
||||
// Shed on the east side, table out in front of it. Lane D tested reachability
|
||||
// against (9, 6), so the table stays there and the shed tucks in behind.
|
||||
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;
|
||||
@ -84,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
|
||||
@ -169,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)));
|
||||
@ -217,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(
|
||||
@ -231,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) {
|
||||
@ -335,6 +364,159 @@ export function createWorld(scene, opts = {}) {
|
||||
root.add(fence);
|
||||
solids.push(fence);
|
||||
|
||||
// --- shed & spare table ------------------------------------------------
|
||||
// Where the spare hardware lives, which makes it where the §7 scenario
|
||||
// starts: rig → carry a spare → repair mid-storm. Lane D's pickup radius is
|
||||
// 1.5 m off this point and everything downstream of it is already wired, so
|
||||
// this small thing gates the whole hand-played loop.
|
||||
//
|
||||
// The position is published SYNCHRONOUSLY, from constants, even though the
|
||||
// meshes arrive later in dress(). createWorld() has to stay sync — a.test.js
|
||||
// and the selftest build a yard without a server — and Lane D's
|
||||
// wireYardActions reads world.shedTable at wiring time. dress() refines the
|
||||
// point to Lane E's baked `pickup_anchor` if it's there.
|
||||
const shedTable = {
|
||||
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
|
||||
};
|
||||
|
||||
/**
|
||||
* Swap Lane E's GLBs in over the graybox. Async and separate from
|
||||
* createWorld() on purpose: the selftest builds a yard with no server, and a
|
||||
* fetch in the constructor would either break it or make it slow and flaky.
|
||||
* Every load is individually guarded — a missing GLB leaves its graybox
|
||||
* standing rather than taking the boot down with it.
|
||||
*/
|
||||
async function dress() {
|
||||
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);
|
||||
gltf.scene.traverse((o) => {
|
||||
if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
|
||||
});
|
||||
return gltf.scene;
|
||||
} catch (err) {
|
||||
console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
shed.position.set(SHED.x, heightAt(SHED.x, SHED.z), SHED.z);
|
||||
shed.rotation.y = SHED.rotY;
|
||||
root.add(shed);
|
||||
solids.push(shed);
|
||||
}
|
||||
if (table) {
|
||||
table.name = 'shed_table';
|
||||
table.position.set(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z), SHED_TABLE.z);
|
||||
table.rotation.y = SHED_TABLE.rotY;
|
||||
root.add(table);
|
||||
// NOT in solids: you want to walk up to the table, not be fenced off it.
|
||||
|
||||
// Prefer Lane E's baked anchor over my guess at where a table top is.
|
||||
table.updateWorldMatrix(true, true);
|
||||
const anchor = table.getObjectByName('pickup_anchor');
|
||||
if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
|
||||
}
|
||||
return { shed, table };
|
||||
}
|
||||
|
||||
// --- the world object --------------------------------------------------
|
||||
return {
|
||||
anchors,
|
||||
@ -343,6 +525,13 @@ export function createWorld(scene, opts = {}) {
|
||||
sunDir: SUN_DIR.clone(),
|
||||
solids,
|
||||
root,
|
||||
/**
|
||||
* Where a spare gets picked up. `{pos}` — Lane D registers a 1.5 m hold-E
|
||||
* off this point. Present from construction; dress() may nudge it onto
|
||||
* Lane E's `pickup_anchor`.
|
||||
*/
|
||||
shedTable,
|
||||
dress,
|
||||
// Lane C's skyfx MODULATES these as the storm builds and hands them back
|
||||
// untouched on dispose() — it doesn't own them. That's why the yard exposes
|
||||
// its lights rather than keeping them private.
|
||||
|
||||
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
Binary file not shown.
@ -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