Compare commits
18 Commits
4d4f2d7d7f
...
0cee198702
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cee198702 | ||
|
|
63beec87a7 | ||
|
|
855106d37e | ||
|
|
d3258baaa8 | ||
|
|
1f25c05313 | ||
|
|
b9bd65e67b | ||
|
|
e8fa8a736e | ||
|
|
3e769dbae9 | ||
|
|
6fa7644aba | ||
|
|
e4436248b0 | ||
|
|
1fc8b8e453 | ||
|
|
942709d2e9 | ||
|
|
553d3edd94 | ||
|
|
19f4ab6c63 | ||
|
|
e576f5cb67 | ||
|
|
601adedf45 | ||
|
|
0d8e36ba0f | ||
|
|
391722970a |
@ -523,3 +523,48 @@ THREADS' last [I] entry then SPRINT9.md.
|
||||
> carport — anchor-poor on purpose; its personality is "nowhere to tie off".
|
||||
> Deterministic factory, contact sheet, and pyrrhic-ending card support when
|
||||
> A rules.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# SPRINT 10 prompts (sites are data)
|
||||
|
||||
Same rules: own clone, own branch, rebase onto latest main FIRST. Read
|
||||
THREADS' last [I] entry then SPRINT10.md. Gate 1 is A's, uninterrupted —
|
||||
everyone else's site work is already on disk waiting for it.
|
||||
|
||||
## Lane A — Sprint 10
|
||||
> You are Lane A on SHADES 3D, Sprint 10. Rebase onto main, read SPRINT10.md.
|
||||
> ONE headline, from day one: extract the yard into data/sites/backyard_01.json
|
||||
> (world.js builds from data; byte-identical yard proven by the existing
|
||||
> anchor tripwires and quad-band asserts), then author site_02_corner_block
|
||||
> from the pieces already waiting (E's carport GLB, C's venturi schema — give
|
||||
> C their two gap coordinates, D's work:"cloth"|"bracket" anchor field). The
|
||||
> week gains a site per night; night 3 moves to the corner block. Nothing
|
||||
> else until this lands.
|
||||
|
||||
## Lane B — Sprint 10
|
||||
> You are Lane B on SHADES 3D, Sprint 10. Rebase onto main, read SPRINT10.md.
|
||||
> Build tools/site_audit/ from your quad/coverage/afford sweep — one command,
|
||||
> any site JSON, reports the winnable lines or names the blocking corner.
|
||||
> Run it on site_02 the moment A's JSON exists; if there's no $80 line, tell
|
||||
> E to move the tree (their standing offer). Answer C's porosity-into-
|
||||
> gardenHailExposure question in THREADS.
|
||||
|
||||
## Lane C — Sprint 10
|
||||
> You are Lane C on SHADES 3D, Sprint 10. Rebase onto main, read SPRINT10.md.
|
||||
> Wire venturi from A's site JSON when it lands; decide the week's storm/site
|
||||
> pairing so the corner block teaches the venturi (southerly on night 3?);
|
||||
> land the porosity/gardenHailExposure one-liner if B says yes.
|
||||
|
||||
## Lane D — Sprint 10
|
||||
> You are Lane D on SHADES 3D, Sprint 10. Rebase onto main, read SPRINT10.md.
|
||||
> Land needsLadder on A's work-field (mechanism, not type — your own audit),
|
||||
> then play site_02 cold and log feel notes. The carport traps should read
|
||||
> tempting, not buggy.
|
||||
|
||||
## Lane E — Sprint 10
|
||||
> You are Lane E on SHADES 3D, Sprint 10. Rebase onto main, read SPRINT10.md.
|
||||
> Support site_02 as it lands: dressing adjustments, contact sheets, and a
|
||||
> carport-collateral aftermath line/card if the trap earns one. If B's audit
|
||||
> says the site has no winnable line, moving the tree is yours.
|
||||
|
||||
62
SPRINT10.md
Normal file
62
SPRINT10.md
Normal file
@ -0,0 +1,62 @@
|
||||
# SPRINT 10 — SITES ARE DATA (instructions for Opus 4.8 lanes)
|
||||
|
||||
*Sprint 9 closed the balance book: the wild night has a CLEAN $80 win (porous
|
||||
cloth · dd 0.40 · the four-post quad), fabric is a real choice that decides
|
||||
one corner's life, the pyrrhic ending has a rule, a verdict and a card, and
|
||||
the settled-at-entry guard finally measures the thing the words mean — with a
|
||||
proof that it can fail. Three retractions were filed this sprint, all
|
||||
graceful, all measured. The engineering culture is the co-star now.*
|
||||
|
||||
*What did NOT land, for the second sprint running, is A's site extraction —
|
||||
the week ate Sprint 8, the pyrrhic ruling ate Sprint 9. Every other lane's
|
||||
site work is DONE and waiting: E's carport trap, C's venturi, D's ladder-seam
|
||||
spec, B's audit sweep. Sprint 10 has one headline and it's A's, uninterrupted.*
|
||||
|
||||
Read THREADS from the last [I] entry.
|
||||
|
||||
## Gate 1 — SITE EXTRACTION (A, from day one, nothing else first)
|
||||
|
||||
`data/sites/backyard_01.json`: anchors, bed, house line, trees, posts, shed,
|
||||
gnome, dressing list — world.js BUILDS FROM DATA, byte-identical yard as the
|
||||
assert of success (E's tripwires already pin the anchor positions; a.test's
|
||||
quad-band asserts must pass unchanged). Then `site_02_corner_block.json`:
|
||||
E's carport (anchor trap, ratings baked in the GLB), ONE tree, paling fences,
|
||||
smaller bed, C's venturi list (their schema is in THREADS — answer their
|
||||
"where's the gap?" question with two coordinates). Week gains `site` per
|
||||
night; night 3 moves to the corner block.
|
||||
Include D's field: anchors carry `work: "cloth" | "bracket"` (or your
|
||||
spelling) so needsLadder keys on the actual mechanism — their audit showed
|
||||
the carport silently disables the ladder otherwise, failing open.
|
||||
|
||||
## Gate 2 — site_02 is FAIR before it ships (B, then D)
|
||||
|
||||
**B**: `tools/site_audit/` — the quad/coverage/afford sweep as a one-command
|
||||
tool (your own words: geometry decides winnability, audit at authoring time).
|
||||
Run it on site_02 the moment A's JSON exists; post the winnable line(s) or
|
||||
tell E to move the tree. Every future site runs this before landing.
|
||||
**D**: land needsLadder on the new field; play site_02 cold; feel notes in
|
||||
THREADS. The carport traps should read as tempting, not as bugs.
|
||||
|
||||
## Everyone else (small)
|
||||
|
||||
**C**: wire the venturi from site JSON once A lands the shape; storm/night
|
||||
pairing for the corner block (does the southerly buster move to night 3 so
|
||||
the venturi teaches?). Optional: gardenHailExposure reading sail.porosity
|
||||
(your offered one-liner) — B said yes or no in THREADS first.
|
||||
**E**: whatever site_02's dressing needs once it's in-game (fence lengths,
|
||||
bed size), refreshed contact sheets, and the site_02 aftermath cards if the
|
||||
carport collateral wants its own line ("you took the carport with you").
|
||||
|
||||
## Gate 3 — JOHN PLAYS THE WEEK (carried; the game is ready)
|
||||
|
||||
`python3 server.py` → play five nights with the mouse, through the real
|
||||
buttons. Three sentences in THREADS. Sprint 11 (jobs, clients, pay — the
|
||||
landscaper service proper) gets shaped by them.
|
||||
|
||||
```
|
||||
gate 1: both sites load from JSON; backyard_01 byte-identical; night 3 is
|
||||
the corner block
|
||||
gate 2: site_audit says site_02 has a winnable line; D's cold playthrough
|
||||
logged; needsLadder keyed on mechanism, not type
|
||||
gate 3: John's three sentences
|
||||
```
|
||||
291
THREADS.md
291
THREADS.md
@ -825,6 +825,49 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
[A] 2026-07-18 — ⚖️ **RULING — the pyrrhic win. WIN = `hp >= 50`. Corners are PRICED, not gated. Wired,
|
||||
asserted, on main.** B, D: you both got there independently with the numbers and you're right.
|
||||
**The garden is the client's. The sail is yours.** Saving the bed with a rig that tore itself apart is
|
||||
the job done expensively, not a failure. DESIGN.md never calls a cascade a loss — it calls it *"a
|
||||
firework you paid for"*, and **paid for** is the whole argument: the aftermath already bills the broken
|
||||
hardware, the collateral, and a fee you didn't earn cleanly. Gating the win on corners charges you
|
||||
twice for one night and then lies about it, which is the same species as the verdict that used to tell
|
||||
a 4/4 hold they'd skimped. B — thank you for writing that assert as *the measurement, not the verdict*;
|
||||
that's what made this rulable instead of arguable. The pyrrhic ending has its own verdict now
|
||||
(*"THE GARDEN MADE IT. The sail didn't — the carabiner at P1 went first and took 1 more with it. That
|
||||
is what the sail was for."*) and it had to be checked FIRST, because `lost >= 2` was unconditionally a
|
||||
cascade-and-a-loss and is now sometimes the best night the game has. Selftest **276 / 0**.
|
||||
|
||||
[A] 2026-07-18 — 🎯 **LANE C — your two flagged calls, both ANSWERED, both YES.** You brought the numbers;
|
||||
here are the rulings.
|
||||
**1. The 25%-cover squeak-win is CORRECT, not merely acceptable.** `p1,p2,p3,p4` wins on the HAIL
|
||||
shadow rather than sun cover — and that is decision 13 working exactly as designed, not a loophole.
|
||||
We made hail the garden's killer *precisely because* cloth honestly stops ice and honestly does not
|
||||
stop driving rain (your 73° vs 20° receipts). A rig winning on the mechanic we chose is the mechanic
|
||||
paying off. And 25% cover, $80, on the hardest night, surviving: that is DESIGN.md's *"small twisted
|
||||
steep = storm-proof, patchy shade"* stated in numbers. A thin squeak on night five is the right
|
||||
shape — a comfortable win there would mean night five wasn't night five.
|
||||
**2. Accept the outright-survivable line. The repair is the MARGIN, not the toll.** SPRINT6's "the
|
||||
line must NEED the repair" is superseded and I'm retiring it. To make the repair mandatory we'd have
|
||||
to tune so the best rig the shop sells *always* breaks — that's scripted drama wearing physics, and
|
||||
this repo has spent nine sprints refusing exactly that trade. The repair's job is to save you when
|
||||
you got it wrong or got unlucky. **And the pyrrhic ruling gives it a better home than a gate ever
|
||||
did:** the repair is now what turns a pyrrhic win into a clean one. That's a real decision with a
|
||||
real payoff, every night, instead of a mandatory button on one.
|
||||
**Say go on 0.40 — you have it.** You declined that lever twice when it bought nothing, and both
|
||||
times you were right; it earns its keep now only stacked with B's porous fabric, exactly as you
|
||||
measured. Land it as the one paired change with B, per SPRINT9 decision 2.
|
||||
|
||||
[A] 2026-07-18 — 🙈 **My ninth harness artifact, and the same family as the other eight — logging it because
|
||||
the count is the point.** I ran the selftest, read **274/0/0**, and nearly reported the ruling verified.
|
||||
It was a DIFFERENT TREE: my `server.py` had died with the port already bound (another session holds
|
||||
8815), so the browser was serving someone else's checkout — the `a.test.js` it served had **zero**
|
||||
occurrences of "pyrrhic" while mine on disk had two. The tell was sitting right there: **the pass count
|
||||
didn't move when I added two asserts** (274 → 274). On a free port it reads 276 and Lane A goes 32 → 34.
|
||||
Nine for nine, every one of them me measuring a different thing than I thought I was — never a lane's
|
||||
code. If you take one thing from this lane's whole run: **check the harness is pointed at your tree
|
||||
before you believe a number, especially a green one.**
|
||||
|
||||
[A] 2026-07-18 — 🗓️ **THE WEEK IS IN.** Five nights (Sea Breeze → Southerly → Early Buster → Wild Night →
|
||||
Ice Night), one wallet, an ending either way. `week.js` is a thin wrapper over the phase machine, not
|
||||
a new system — pure data, no THREE, so the ladder and the money are testable at zero cost. Forecast
|
||||
@ -2699,3 +2742,251 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
Standing: **ping me when the week runs and I'll reshoot `docs/yard_day/night` with a winnable rig in
|
||||
frame.** And when A's `data/sites/*.json` lands I'll dress site_02 against the real numbers — the
|
||||
factory is site-agnostic and the carport is the only new geometry it needed.
|
||||
[C] 2026-07-19 — 🅱️ **B — the paired-landing recipe (SPRINT9 decision 2). A said go on 0.40; it's on your
|
||||
fabric.** I re-verified the $80 win on rebased main — identical to Sprint 8:
|
||||
```
|
||||
today membrane · dd 0.45 · rated p4/p2 · shackle p3 · shackle p1 = $90 → hp 52, 1 lost WIN
|
||||
paired POROUS · dd 0.40 · rated p4/p2 · shackle p3 · CARABINER p1 = $80 → hp 52, 1 lost WIN
|
||||
```
|
||||
The whole $10 comes from ONE corner: porous+0.40 drops p1 from 1.49 → 1.08 kN, under the carabiner's
|
||||
1.2 rating, so its shackle ($15) becomes a carabiner ($5). Neither lever moves a tier alone.
|
||||
**I am NOT flipping 0.40 until your fabric is selectable in the shop** — the win doesn't exist without
|
||||
porous cloth, and flipping solo just makes storm_02 slightly softer for no reason and drifts your
|
||||
balance.test inputs. **When you land fabric, ping me and I flip 0.40 the same PR** (one-line data edit,
|
||||
storm_02 `downdraftOfTotal` 0.45 → 0.40 — the comment there already documents it as pending). Then
|
||||
balance.test's THE LINE re-points to `p1,p2,p3,p4` at that $80 loadout with porous, and the old
|
||||
t2-quad stays as the pyrrhic/sacrifice control (A retired "the win must NEED the repair"; the repair is
|
||||
now the margin that turns pyrrhic → clean, so an outright-survivable $80 line is exactly right).
|
||||
Fabric price, per my Sprint-7 ruling: membrane cheap+dangerous, shade cloth dear+safe, and porous
|
||||
honestly leaks pea hail (`hailBlockFor(size, 0.3)`: blocks the 1.3/1.4 storm ice fully, passes ~26% of
|
||||
storm_03's 0.7 pea hail). So porous is NOT free — it costs a slice of garden on nights 2–3 while saving
|
||||
your rig on nights 4–5. Whatever you price it at, that's the honest tradeoff to price against.
|
||||
⚠️ **One integration seam to decide together:** the $80 win was measured with the sail's porosity
|
||||
reducing WIND load only (that's what drops p1). If you ALSO want porous to leak hail into the garden
|
||||
score (`hailBlockFor`), `sky.gardenHailExposure` needs to fold it in — I left that seam untouched
|
||||
(your mechanic). Say the word and I make gardenHailExposure read `sail.porosity` (one line, no-op for
|
||||
membrane); or you wire it cloth-side. Either way it doesn't change the $80 *survival* — only the
|
||||
garden score under porous on the pea-hail nights.
|
||||
|
||||
[C] 2026-07-19 — 🅰️ **A — venturi/site-wind for site_02 is LANDED (physics + setter + asserts, 278/0/0);
|
||||
it needs its data shape from your site extraction.** `wind.setVenturi(list)` is on the wind and the
|
||||
router (tripwire green). A venturi is a shelter's opposite — a gap SPEEDS the wind up when it blows
|
||||
ALONG the gap's axis, so the corner block is calm until the southerly swings to match, then it screams.
|
||||
Proposed site-JSON shape (adjust freely — I read whatever list you hand me):
|
||||
```json
|
||||
"wind": { "venturi": [ { "x": -6, "z": 4, "axis": 0.9, "gain": 1.5, "radius": 5, "sharp": 3 } ] }
|
||||
```
|
||||
`axis` = radians the gap runs (the wind funnels either way along it); `gain` = peak speed ×; `radius` =
|
||||
metres the throat reaches; `sharp` = how aligned the wind must be (higher = pickier). Wire it after the
|
||||
yard builds, beside your `setSheltersFromTrees`: `wind.setVenturi(site.wind?.venturi ?? [])`. Empty/
|
||||
absent = perfect no-op, so backyard_01 is byte-identical (asserted). Measured on a synthetic corner
|
||||
block: aligned wind boosts ×1.5, crosswind ×1.0 (nothing), continuous through the change (0.50 m/s max
|
||||
frame jump), and the downdraft rides the funnel for free. When your extraction lands, drop the venturi
|
||||
list in site_02's JSON and it just works. **Where do you want the block's gap?** — give me the two
|
||||
building edges and the storm's southerly heading and I'll hand back the exact {x,z,axis} so the funnel
|
||||
lines up with the wind change instead of me guessing yard coords.
|
||||
---
|
||||
|
||||
## SPRINT9 — Lane B — 2026-07-17
|
||||
|
||||
**C: you were right about p1. I was wrong, and this is the third time.** My SPRINT8 comment said your
|
||||
1.08 kN didn't reproduce, that p1 peaked at 1.27 with cloth at dd 0.40, and that the line therefore
|
||||
won on TIME (an overshoot too brief to trip OVERLOAD_SECS) rather than on headroom. Re-measured on the
|
||||
dressed yard, live browser, same flight, one variable:
|
||||
|
||||
shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost
|
||||
membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO
|
||||
|
||||
p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Your number and mine agree to
|
||||
within noise; my "correction" was the fiction. Retracted in the file, not just here.
|
||||
|
||||
**Where 1.27 came from, because the mechanism matters to everyone:** a loadout where a corner BROKE.
|
||||
Hardware looks like it can't touch the loads — `rating` only feeds `_checkFailure` — but a corner that
|
||||
lets go dumps its share onto the survivors, and p1 is who catches it. The same quad with hardware the
|
||||
budget can't actually buy (setHardware fails, cheap hardware stays on, p2/p4 tear off) reads p1 at
|
||||
**2.48 kN**. *Any p1 number gathered without checking `lost` is measuring a cascade, not a fabric.*
|
||||
If you have loose corner numbers in your table, that is the first thing to check.
|
||||
|
||||
**A — the fabric is the decision, and it's yours to sell in prep.** Both fabrics are $0 (charging for
|
||||
membrane would ship a trap; the bet is the forecast — DESIGN.md). What the player is actually choosing,
|
||||
on the wild night, is whether their cheapest corner survives: cloth 0.98 vs membrane 1.23 against a
|
||||
1.20 rating. Membrane buys +26% hail block on pea-hail nights and ~5% rain, and pays for it by tearing
|
||||
p1 off the post. `balance: fabric decides p1` asserts it, so the prep screen can promise it.
|
||||
|
||||
**D — the settled-at-entry guard: redesigned, and your demotion diagnosis was half right.** The
|
||||
ponding mechanism you and the integrator identified is real. It is also not the bug, and neither of
|
||||
the two suggested fixes works. Measured, dressed yard, 0 s settle vs 12 s:
|
||||
|
||||
probe under CALM, held in prep 13% vs 17%
|
||||
probe under STORM, held clock 24% vs 104%
|
||||
probe under STORM, advancing clock 24% vs 104%
|
||||
probe under STORM, RAINLESS 17% vs 96%
|
||||
|
||||
Every variant is either **flat** (calm can't excite the cloth → the guard is vacuous and passes
|
||||
forever) or **INVERTED** (fires hardest on the properly settled rig). Rain isn't the culprit: the
|
||||
rainless probe ponds 1 kg and still inverts. Advancing the clock isn't a fix either — RAIN_TIME_COMPRESSION
|
||||
is 40×, so 4 s of advancing storm is 160 s of rain and 43 kg in the belly either way.
|
||||
|
||||
The observable was the bug. A load-trend measures the rig **loading up when the wind changes**, and a
|
||||
taut settled cloth ramps *harder* than a limp unsettled one (0.63→1.23 kN vs 0.44→0.52). It was reading
|
||||
the storm's arrival, not the cloth. So: ask the cloth. `rig.nodeSpeed()` (new, sail.js) is RMS node
|
||||
speed out of verlet, sampled over 2 s of the calm prep the rig already stands in:
|
||||
|
||||
settle 0 s 4 s 12 s 30 s
|
||||
speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 → 0.028)
|
||||
|
||||
An order of magnitude, in the direction the word means. Absolute check at 0.08 m/s. **Promoted back to
|
||||
a hard failure**, and it earns that: `the settled-at-entry guard can fail` flies the line with NO settle
|
||||
and asserts the guard refuses it. A guard nobody has seen fail is indistinguishable from one that
|
||||
cannot — which is how this one survived two sprints being both vacuous and inverted. **Yours to veto.**
|
||||
|
||||
Also: the old guard left 43 kg of water and 4 s of storm load in the rig *before* the storm started.
|
||||
It didn't move the peaks, but the suite was flying a wet rig into a dry entry.
|
||||
|
||||
**A — selftest.html was hiding the merge gate, and it's my bug.** The render loop read `['A'..'E']`
|
||||
while the import list also carries `BAL`, so the balance suite ran, counted toward the summary, and had
|
||||
every row silently dropped — including, had one gone red, the row saying *which* assert failed and why.
|
||||
I added the import and not the renderer. Now derived from the report, so the next joint suite can't hit
|
||||
it. This is why nobody could see the balance numbers they were arguing about.
|
||||
|
||||
**tools/site_audit/ — new, and the SPRINT6 check that was missing.** `node tools/site_audit/audit.mjs
|
||||
[site.json] [--storm name]`, ~20 s, no browser. Enumerates in-band quads that shade the bed, flies the
|
||||
real storm headless, maps each corner to the cheapest tier that holds it, verdicts against $80. On
|
||||
backyard_01 it independently reproduces THE LINE — p1,p2,p3,p4, $65 + $15 spare — matching balance.test
|
||||
to within 2% by a completely separate path. **A/E: run it on site_02 before it ships.** It fails loudly
|
||||
with the corner and the number when no affordable line exists, which is the SPRINT6 p1=7.4 kN failure
|
||||
class, and it found two more in the current yard (t2b at 10.2 kN, p1 at 6.7 kN — unholdable at any price).
|
||||
|
||||
**⚠️ EVERYONE, the trap the audit dug up: `createWorld()` SUCCEEDS in node and hands back the GRAYBOX
|
||||
yard.** `dress()` cannot run headless — it needs GLTFLoader (bare `'three'` specifier) and fetches .glb
|
||||
over `file://`. It fails, gets caught, and you are left with **the house at x=±5 and no branch anchors
|
||||
at all**. That is the fictional yard from SPRINT6 that reported the wild night unwinnable. So a headless
|
||||
tool that "reads world.js for the real yard" gets the *wrong* yard, silently — same shape as the skyfx
|
||||
camera trap (a headless caller quietly getting zero shadow). **Reading live code is not the same as
|
||||
reading truth.** The audit therefore carries a dressed-yard dump, verified in-browser against all 12
|
||||
anchors, and hard-fails if the four posts drift from live world.js — posts being the only anchors
|
||||
`dress()` never touches. That check exists because it caught me: I typed p4 from world.js's `postSpecs`
|
||||
and missed that posts are RAKED 8° away from centre, putting it 0.56 m from where the game has it.
|
||||
|
||||
This is the strongest argument yet for **gate 2 (sites as DATA)**: the moment `data/sites/backyard_01.json`
|
||||
exists, none of that apparatus is needed and the audit just reads it. Until then every headless tool in
|
||||
this repo is one `createWorld()` call away from auditing a yard the player never sees.
|
||||
|
||||
sail.js gains two small APIs other lanes may want: `resetPeaks()` (peakLoad is peak-since-ATTACH and
|
||||
was folding settle transients into storm peaks) and `nodeSpeed()`.
|
||||
|
||||
Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
|
||||
[D] 2026-07-18 — 🔧 **GUARD REDESIGNED (§Lane D, with B) — it measures SHAPE now, and I proved it fires.**
|
||||
Integrator: thank you for demoting it rather than deleting it, but **your stated cause doesn't
|
||||
hold, and neither did my design.** Both worth recording:
|
||||
· **The ponding diagnosis is wrong.** Every storm's rain curve starts at `[0, 0]`, so
|
||||
`storm_02.rainMmPerHour(0)` is **0.0 mm/h** — a held clock rains *nothing*. (Also: your 2 s
|
||||
windows are shorter than the cloth's breath. A settled rig reads a 32% trend at 2 s and **6%
|
||||
at 3 s**. The window was the flake, not the water.)
|
||||
· **The real confound was mine.** The guard measured in the STORM's wind while the rig settled
|
||||
in CALM — and that calm→storm step is one **the real game also has** (`wind.use(to ===
|
||||
'storm' ? winds[stormKey] : calmWind)` fires on the phase change). It was never a harness
|
||||
artifact; the suite was being faithful and my guard was calling it a bug.
|
||||
· **And fixing the clock isn't enough, because LOAD cannot answer this question at all.** In
|
||||
calm there is no transient to see — the unsettled rig is *lighter* (0.30 kN vs 0.47 settled)
|
||||
and the trend test **passes at every settle length from 0 s to 20 s**. A guard that cannot
|
||||
fail is decoration, which is exactly what StumbleBack and the fake skips were.
|
||||
So it asks the physical question instead — **has the cloth stopped moving** — and mean node drift
|
||||
separates ~6× either side, immune to wind and water alike:
|
||||
```
|
||||
unsettled (0 s) → 212 mm/s (cover quad) · 208 (miss quad)
|
||||
settled (8–20 s) → 35 mm/s worst breath, typically 4–6
|
||||
```
|
||||
Limit **100 mm/s**. **Proved it fails before trusting it to pass** (the habit this repo keeps
|
||||
earning): sabotaged the settle to 0 s and it fired — *"line entered the storm with the cloth
|
||||
still drifting 212 mm/s (limit 100)"*. Restored, all five balance asserts green, **275/0/0**.
|
||||
**Your 600 N floor: blessed as reasoning, not carried as code.** It was the right amendment to a
|
||||
LOAD design — "the trend only matters at a scale that can move a verdict" is correct and I'd have
|
||||
kept it. Drift has no units of force to need a floor, and 212-vs-35 isn't a judgement call.
|
||||
|
||||
[D] 2026-07-18 — 🪜 **SITE_02 PREP — I audited the ladder against it rather than wait, and there IS a
|
||||
seam. A/E: one field in the site JSON and I'm done.** site_02 isn't landed so I can't play it cold
|
||||
yet; this is the half I could do now.
|
||||
The prompt's hunch was right but the mechanism isn't height. `needsLadder` was spelled
|
||||
**`type === 'house'`**, and contracts.js types anchors as a **closed enum `'house'|'tree'|'post'`
|
||||
that a carport doesn't fit.** The failure mode is why it couldn't wait: if `needsLadder` says
|
||||
false, **`canReach` in interact.js returns `true` UNCONDITIONALLY** — so on site_02 the player
|
||||
re-rigs a 2.6 m carport bracket standing on the grass, and the ladder mechanic *silently stops
|
||||
existing*. No error, no red. That's the same disease as StumbleBack's dead threshold, the fake
|
||||
skips, and the camera-less shadow grid: **a rule keyed on the wrong thing, failing open.**
|
||||
**A pure height test is NOT the fix** — posts (3.95 m) and tree limbs (5.05 m) are *higher* than
|
||||
the 2.48 m fascia and need no ladder, because there you work the **cloth** (which has fallen to
|
||||
where you stand) and here you work the **bracket** (which is on a wall). Height is a coincidence
|
||||
of that distinction, not the distinction.
|
||||
So it reads **data first**: `anchor.worksAtBracket` — precisely the pattern `anchor.ratingHint`
|
||||
already uses from the GLB's `userData.rating_hint` — falling back to `type === 'house'`.
|
||||
**Nothing on site_01 carries the flag, so it's a no-op there** (asserted both ways).
|
||||
**Lane A:** when you extract the yard to `data/sites/*.json`, give anchors an optional
|
||||
`worksAtBracket`. **Lane E:** type the carport however you like — set that flag on its anchors and
|
||||
the ladder follows the site with zero code change. If you'd rather it were named or shaped
|
||||
differently, say so now; it's one line and I have no attachment to the spelling.
|
||||
Also: `ladder.js` is now headless-importable (lazy GLTFLoader, same as broom.js), because
|
||||
`d.test.js` had been testing **fakeLadder's hand-copied duplicate** of `needsLadder` instead of
|
||||
the rule itself. A rule the suite re-implements is a rule the suite cannot catch drifting — which
|
||||
is the whole reason this repo has a balance suite that reads the yard from world.js.
|
||||
|
||||
[D] 2026-07-18 — ⏳ **Still owed by me: play site_02 cold** (blocked — A's site-as-data + E's corner
|
||||
block haven't landed), and the full-week feel pass. Both are queued and neither blocks anyone. My
|
||||
douse guard is still green, so pond masses haven't moved.
|
||||
|
||||
[I] 2026-07-19 — **SPRINT 9 INTEGRATION (main).** All lanes merged; selftest **287/0/0**. The balance
|
||||
suite now reads like the design doc: CLEAN $80 win on the wild night (porous cloth · dd 0.40 ·
|
||||
p1,p2,p3,p4) · fabric decides p1 (membrane tears the cheap corner off) · the t2 quad stays pyrrhic
|
||||
(the sacrifice play) · the settled-at-entry guard measures cloth motion and PROVES it can fail.
|
||||
Merge notes: B and D redesigned the same guard independently (nodeSpeed vs drift) — kept B's (it
|
||||
reserved D's veto), mapped D's settleDrift field onto it same-unit. Both of my Sprint-8 guard
|
||||
diagnoses were refuted with measurements (rain curves start at [0,0]; the 2 s window was the flake)
|
||||
— proper. The pyrrhic ruling is wired with its own verdict mode and E's third card.
|
||||
**Still open, carried to SPRINT10:** A's site extraction (data/sites/*.json — not started; the week
|
||||
ate Sprint 8 and the ruling ate Sprint 9's A-capacity), D's needsLadder site-field (specced, fails
|
||||
open on carports until landed), C's venturi (landed, waiting on site JSON shape), E's carport
|
||||
(landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep).
|
||||
Gate 3 (John plays the week) remains open — the game has never been more ready for it.
|
||||
|
||||
[E] 2026-07-18 — 💰 **LANE A — the carport trap was UNSCOREABLE and that's on me. Fixed.** I shipped the
|
||||
anchors last sprint carrying `collateral="carport"`, but the root carried no `collateral_value` — so
|
||||
the thing your aftermath reaches for (`world.gnome.collateralValue`, main.js:548) simply wasn't there
|
||||
for the carport. A trap with no price isn't a trap, it's a warning label. It now carries
|
||||
`collateral_value` + `collateral_label` on the root, same shape and same place as the gnome, and
|
||||
e.test pins it above the gnome's 25.
|
||||
**The number is YOURS — this is a design call, not an art one.** My proposal is **180**, and the
|
||||
reasoning is in the code beside it so you can argue with it: the gnome is 25, a night's shop is 80, a
|
||||
good week banks ~475. At 180 it's 2.25 nights' budget — it turns a good week into a broke one and is
|
||||
felt for the rest of the run, without instantly ending a strong one. Below ~120 the trap is a shrug;
|
||||
above ~250 tying off the carport once is a silent game over, which teaches nothing because the player
|
||||
never gets to act on the lesson. It should be the worst bill on the site and still a week you can dig
|
||||
out of. Change the constant, not the asset.
|
||||
|
||||
[E] 2026-07-18 — **`carport_01_wrecked_v1.glb`** — the payoff for the trap, same origin and footprint as
|
||||
`carport_01` so you swap mesh-for-mesh like the gnome and the fence. 6.28 × 5.57 × **2.20 m** (the
|
||||
intact one is 2.69 — a wreck must stand SHORTER than the thing it was, and there's an assert for that
|
||||
now because my first pass came out taller). It fails the way a light structure actually does: the pads
|
||||
stay put — they were never the weak part, which is the joke — the frame racks over like a
|
||||
parallelogram, and the roof sheet peels off downwind in one piece. A carport doesn't shatter; it leans,
|
||||
and then it's somewhere else. Copy for the line you asked about:
|
||||
· collateral row: **"the carport — $180"**
|
||||
· verdict/ticker: **"you took the carport with you"**
|
||||
· if you want the knife in: *"They'll want that back up by Thursday."*
|
||||
|
||||
[E] 2026-07-18 — 🐞 two measured bugs on the way in, both caught by measuring instead of reasoning — the
|
||||
exact lesson from my Sprint 9 phantom-post entry, applied earlier this time and it cost minutes rather
|
||||
than an afternoon: (1) I rotated the wreck's beams 31° about Y, but a beam spans the short axis and the
|
||||
frame racks in that SAME axis — both post tops travel together, so the beam rides across level. Tilting
|
||||
it swung one end to 2.96 m and made the wreck taller than the carport. (2) The roof sheets dipped to
|
||||
z = −0.41, i.e. through the ground, which is what inflated the measured height. Both are now comments
|
||||
where the numbers are.
|
||||
|
||||
[E] 2026-07-18 — status: selftest **291/0/0**, Lane E is 64 asserts, **40/40 output files byte-identical**
|
||||
across two full runs. §E dressing (fence lengths, bed size) is **blocked on gate 1** — `data/sites/`
|
||||
doesn't exist yet, and that's correct, it's yours from day one. **Ping me the moment
|
||||
`site_02_corner_block.json` lands** and I'll fit the dressing to the real numbers and refresh the
|
||||
sheets same session; the factory is site-agnostic and the carport was the only new geometry it needed.
|
||||
**Lane B — audit the carport before it ships.** If the one tree plus ground anchors can't make an
|
||||
in-band quad at $80, say so and **I'll move the tree, not the ratings** — the ratings are the site.
|
||||
Carried, still yours either way: grass_atlas (**nine sprints, 0 refs** — bin it), the screenshot POST,
|
||||
`moon.png`.
|
||||
|
||||
@ -155,6 +155,24 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carport_01_wrecked",
|
||||
"dims": [
|
||||
6.2819,
|
||||
5.5704,
|
||||
2.1983
|
||||
],
|
||||
"tris": 144,
|
||||
"nodes": [
|
||||
"beams",
|
||||
"carport_01_wrecked",
|
||||
"footings",
|
||||
"posts",
|
||||
"roof_down"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
|
||||
@ -781,6 +781,21 @@ def build_house_yardside(name):
|
||||
return root
|
||||
|
||||
|
||||
# What it costs to take the client's carport with you. Lane A owns this number —
|
||||
# it's a design call, not an art one — but the trap needs A number or it isn't a
|
||||
# trap, and shipping the asset without one is why nothing scored it. My proposal
|
||||
# and the reasoning, to argue with rather than adopt:
|
||||
# · the gnome is 25, a night's shop budget is 80, a good week banks ~475;
|
||||
# · at 180 it's 2.25 nights' budget — it turns a good week into a broke one and
|
||||
# is felt for the rest of the run, without instantly ending a strong one;
|
||||
# · cheaper than ~120 and the trap is a shrug; dearer than ~250 and tying off
|
||||
# the carport once is a silent game over, which teaches nothing because the
|
||||
# player never gets to act on the lesson.
|
||||
# The point is that it should be the worst thing on the site's bill and still be
|
||||
# a week you can dig out of.
|
||||
CARPORT_COLLATERAL = 180
|
||||
|
||||
|
||||
def build_carport_01(name):
|
||||
"""A single-car carport — the corner block's whole personality (SPRINT9 §E).
|
||||
|
||||
@ -858,6 +873,87 @@ def build_carport_01(name):
|
||||
stamp(root, name, "structure")
|
||||
root["site_hint"] = "corner_block"
|
||||
root["is_anchor_trap"] = True
|
||||
# Without this the trap is unscoreable: the anchors say collateral="carport"
|
||||
# but nothing said what a carport COSTS, so Lane A's aftermath had no number
|
||||
# to reach for. main.js already reads world.gnome.collateralValue — same
|
||||
# shape, same place.
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
root["collateral_label"] = "the carport"
|
||||
return root
|
||||
|
||||
|
||||
def build_carport_01_wrecked(name):
|
||||
"""The carport after you tied a sail to it (SPRINT10 §E).
|
||||
|
||||
Same origin and footprint as `carport_01`, so Lane A swaps mesh-for-mesh the
|
||||
way the gnome and fence already do. This is the payoff for the trap: the
|
||||
site's lesson only lands if taking the beam LOOKS like taking the carport,
|
||||
not like a number going down on a card.
|
||||
|
||||
It fails the way a light structure actually fails — not flattened. The
|
||||
windward pair of posts stays in its pads and the leeward pair folds, so the
|
||||
whole frame racks over like a parallelogram and the roof sheet peels off
|
||||
downwind in one piece. That's the tell: a carport doesn't shatter, it leans
|
||||
and then it's somewhere else.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
W, D = 3.0, 5.4
|
||||
H_HI, H_LO = 2.45, 2.20
|
||||
RACK = math.radians(34) # how far the frame leaned before it stopped
|
||||
|
||||
pads, posts = [], []
|
||||
for sx in (-1, 1):
|
||||
for sy in (-1, 1):
|
||||
x, y = sx * (W / 2 - 0.09), sy * (D / 2 - 0.09)
|
||||
h = H_HI if sy < 0 else H_LO
|
||||
# The pads stay: they were never the weak part. That's the joke.
|
||||
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (0.26, 0.26, 0.09),
|
||||
(x, y, 0.045), conc))
|
||||
lean = RACK * rng.uniform(0.86, 1.0)
|
||||
# Racked over toward +X, hinging at the pad — so the top travels and
|
||||
# the base doesn't.
|
||||
posts.append(add_box(f"{name}_post_{sx}_{sy}", (0.09, 0.09, h),
|
||||
(x + math.sin(lean) * h / 2, y,
|
||||
0.09 + math.cos(lean) * h / 2), steel,
|
||||
rot=(0, lean, 0)))
|
||||
join_group(pads, "footings", root)
|
||||
join_group(posts, "posts", root)
|
||||
|
||||
# The beams stay LEVEL. A beam spans the short axis and the frame racks in
|
||||
# that same axis, so both post tops travel together and the beam rides across
|
||||
# — it does not tilt. Rotating it swung one end to 2.96 m, i.e. the wreck came
|
||||
# out TALLER than the carport it used to be. Measured, not reasoned.
|
||||
beams = []
|
||||
for sy in (-1, 1):
|
||||
h = H_HI if sy < 0 else H_LO
|
||||
beams.append(add_box(f"{name}_beam_{sy}", (W, 0.05, 0.14),
|
||||
(math.sin(RACK) * h, sy * (D / 2 - 0.09),
|
||||
0.09 + math.cos(RACK) * h), dark))
|
||||
join_group(beams, "beams", root)
|
||||
|
||||
# The sheet let go and went downwind — folded, not flat, and clear of the
|
||||
# frame. Nobody's parking under this again.
|
||||
# Flat-ish and ON the grass. The first pass tilted them enough to sink a
|
||||
# corner to z=-0.41 — through the ground — which is both wrong and what
|
||||
# inflated the wreck's measured height. Kept nearer the frame too: a 7 m
|
||||
# footprint would overlap whatever Lane A has parked next door.
|
||||
join_group([add_box(f"{name}_sheet_a", (W + 0.2, D * 0.55, 0.04),
|
||||
(W * 0.95, -D * 0.18, 0.12), sheet,
|
||||
rot=(0, math.radians(3), math.radians(-13))),
|
||||
add_box(f"{name}_sheet_b", (W * 0.8, D * 0.38, 0.04),
|
||||
(W * 0.88, D * 0.30, 0.28), sheet,
|
||||
rot=(math.radians(9), math.radians(-4), math.radians(8)))],
|
||||
"roof_down", root)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "carport_01"
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
return root
|
||||
|
||||
|
||||
@ -2054,6 +2150,11 @@ ASSETS = [
|
||||
nodes=["footings", "posts", "beams", "roof",
|
||||
"beam_anchor_01", "beam_anchor_02",
|
||||
"post_anchor_01", "post_anchor_02"]),
|
||||
# Wider than the intact carport (the sheet goes downwind) but SHORTER — a
|
||||
# wreck that stands taller than the thing it was is a bug, not a wreck.
|
||||
dict(name="carport_01_wrecked", fn=build_carport_01_wrecked,
|
||||
dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)),
|
||||
nodes=["footings", "posts", "beams", "roof_down"]),
|
||||
dict(name="shed_01", fn=build_shed_01,
|
||||
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
|
||||
nodes=["shell", "roof", "doors", "door_anchor"]),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.2 MiB |
252
tools/site_audit/audit.mjs
Normal file
252
tools/site_audit/audit.mjs
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* site_audit — is this site winnable, BEFORE it ships? [Lane B, SPRINT9]
|
||||
*
|
||||
* node tools/site_audit/audit.mjs # the shipped yard
|
||||
* node tools/site_audit/audit.mjs web/world/data/sites/site_02.json
|
||||
* node tools/site_audit/audit.mjs --storm storm_03_southerly
|
||||
*
|
||||
* WHY THIS EXISTS, in one number: p1 = 7.4 kN.
|
||||
*
|
||||
* In SPRINT6 the yard shipped with a bed-covering quad whose corner pulled
|
||||
* 7.4 kN against a shop whose best hardware holds 6.5. No loadout could hold it
|
||||
* at any price, so the wild night was unwinnable — and it took four lanes two
|
||||
* sprints to find out, because nothing checked the site's GEOMETRY against the
|
||||
* shop's PRICE LIST at authoring time. Geometry decides winnability. A site is a
|
||||
* balance decision disguised as art, and it should be audited the minute it's
|
||||
* drawn, not the sprint after it ships.
|
||||
*
|
||||
* What it does NOT do: score the garden. Winnability is decided before any of
|
||||
* that — by whether a quad exists that (a) covers the bed and (b) has peak
|
||||
* corner loads the budget can hold. The garden drain only decides how BADLY you
|
||||
* lose a site that was already unwinnable. That's also why this runs in node:
|
||||
* no skyfx, no document, no browser, ~20 s per site.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const SPARE_COST = 15;
|
||||
const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band
|
||||
const MIN_COVER = 0.25; // A's "shades the bed" bar
|
||||
const SETTLE_S = 12; // D's settle — a player plays through prep
|
||||
const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep
|
||||
const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
|
||||
const sitePath = argv.find((a) => !a.startsWith('--') && a !== stormArg) || null;
|
||||
|
||||
/**
|
||||
* THE SHIPPED YARD, headless — a dump of the DRESSED yard, and it has to be.
|
||||
*
|
||||
* Node cannot dress: dress() needs GLTFLoader (a bare 'three' specifier) and
|
||||
* fetch()es .glb over file://, neither of which works outside a browser. And
|
||||
* that matters far more than it sounds, because createWorld() still SUCCEEDS in
|
||||
* node — it just hands back the GRAYBOX yard:
|
||||
*
|
||||
* graybox (node) dressed (browser, what the player plays)
|
||||
* h1 x = -5.00 h1 x = -3.00 ← dress() adopts E's fascia
|
||||
* t1 (-9.00, 2.00) t1 (-9.96, 0.54) ← dress() adopts branch_anchor_01
|
||||
* t1b/t1c/t2b: ABSENT t1b/t1c/t2b: exist ← dress() adds them
|
||||
*
|
||||
* A headless tool that "reads world.js for the real yard" therefore gets the
|
||||
* house at x=±5 — the exact fictional yard that made the SPRINT6 harness report
|
||||
* the wild night unwinnable, memorialised at the top of balance.test.js. Reading
|
||||
* live code is not the same as reading truth. The dump below is the real yard;
|
||||
* live world.js, in node, is not.
|
||||
*
|
||||
* So the deal is: dumped values for what only dress() knows, and a HARD
|
||||
* CROSS-CHECK against live world.js for everything dress() never touches.
|
||||
* dress() only adopts h1..h3 / t1 / t2 and adds the branch anchors — the four
|
||||
* posts are pure world.js, so they are re-verified on every run (verifyPosts).
|
||||
* That check exists because the first draft of this file typed p4 straight from
|
||||
* world.js's `postSpecs` and missed that posts are RAKED 8° away from centre:
|
||||
* it sat 0.56 m off, and the audit dutifully reported on a post that isn't there.
|
||||
*
|
||||
* This whole apparatus is why SPRINT9 gate 2 (sites as DATA) is worth it. The
|
||||
* moment `data/sites/backyard_01.json` exists, pass it and none of this is used.
|
||||
*/
|
||||
const BACKYARD_01 = {
|
||||
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
|
||||
dumped: true,
|
||||
bed: { x: 1, z: 2, w: 6, d: 4 },
|
||||
anchors: [
|
||||
// dress()-only: adopted from E's GLBs. Unverifiable headless.
|
||||
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
|
||||
['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
|
||||
['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
|
||||
// pure world.js — cross-checked against live code on every run.
|
||||
['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
|
||||
['p4', 'post', -3.72, 4.00, -1.40],
|
||||
].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
|
||||
};
|
||||
|
||||
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
|
||||
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
|
||||
|
||||
/**
|
||||
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
|
||||
* The one guard-rail a hand-typed yard can actually have.
|
||||
*/
|
||||
async function verifyPosts(site) {
|
||||
const THREE = await import('../../web/world/vendor/three.module.js');
|
||||
const { createWorld } = await import('../../web/world/js/world.js');
|
||||
const stub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
|
||||
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
|
||||
const drift = [];
|
||||
for (const a of site.anchors) {
|
||||
if (!VERIFIABLE.has(a.id)) continue;
|
||||
const l = live.get(a.id);
|
||||
if (!l) { drift.push(`${a.id}: gone from world.js entirely`); continue; }
|
||||
const d = Math.hypot(a.pos.x - l.x, a.pos.y - l.y, a.pos.z - l.z);
|
||||
if (d > 0.01) drift.push(`${a.id}: dump (${a.pos.x.toFixed(2)}, ${a.pos.y.toFixed(2)}, ${a.pos.z.toFixed(2)}) ` +
|
||||
`vs world.js (${l.x.toFixed(2)}, ${l.y.toFixed(2)}, ${l.z.toFixed(2)}) — ${d.toFixed(2)} m out`);
|
||||
}
|
||||
const missing = [...live.keys()].filter((id) => !site.anchors.some((a) => a.id === id));
|
||||
if (missing.length) drift.push(`world.js has anchors this dump has never heard of: ${missing.join(', ')}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
async function loadSite(path) {
|
||||
if (!path) return BACKYARD_01;
|
||||
const j = JSON.parse(await readFile(path, 'utf8'));
|
||||
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
}));
|
||||
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors };
|
||||
}
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
function 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);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */
|
||||
const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
const drift = await verifyPosts(site);
|
||||
if (drift.length) {
|
||||
console.log('\n✗ FAIL — the built-in yard dump no longer matches world.js:\n');
|
||||
for (const d of drift) console.log(` ${d}`);
|
||||
console.log('\n Every number this tool prints would be about a yard that does not exist.');
|
||||
console.log(' Fix the dump (posts are RAKED 8° — use the anchor pos, not postSpecs), or pass a site JSON.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` posts verified against live world.js ✓ ` +
|
||||
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
|
||||
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
|
||||
}
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
const A = anchors;
|
||||
for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++)
|
||||
for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) {
|
||||
const q = [A[a], A[b], A[c], A[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < BAND.lo || area > BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
if (!cands.length) {
|
||||
console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`);
|
||||
console.log(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check.
|
||||
//
|
||||
// This drives the wind the way main.js does, and every clause below is here
|
||||
// because the first draft skipped it and MIS-MEASURED:
|
||||
//
|
||||
// · createWind (not the raw field) + setSheltersFromTrees — trees knock a
|
||||
// hole downwind, and a quad hanging off tree anchors sits in it. main.js
|
||||
// does this at boot.
|
||||
// · the settle runs on the CALM day on a RUNNING clock, because that is what
|
||||
// wind.use() hands the rig during prep. The first draft settled on STORM
|
||||
// wind frozen at t=0 — the exact anti-pattern balance.test documents at
|
||||
// 1.94 kN vs 0.40 kN of standing load at storm entry.
|
||||
// · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and
|
||||
// was quietly folding the attach transient + settle into the storm peak.
|
||||
//
|
||||
// A tool that vets sites is worthless if it doesn't fly the storm the game
|
||||
// flies. It reported p1 at 1.1 kN with all three of those wrong.
|
||||
const trees = site.anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth: the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad }));
|
||||
const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
console.log('');
|
||||
if (!winners.length) {
|
||||
const best = rows[0];
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
(best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
|
||||
console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const w = winners[0];
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });
|
||||
@ -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": "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_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. LANDED at 0.45 in Sprint 4 once A's decision-2 anchors gave properly-sized quads and B re-pointed §7 — it clears B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) and lets 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). SPRINT9 NOTE: a paired drop to 0.40 is the second half of the $80 clean-win line (p1,p2,p3,p4 with B's porous shade cloth — the downdraft alone moves no hardware tier, the two levers only close the $10 gap together). That flip lands WITH B's fabric choice, not before it — see THREADS [C] 2026-07-18/-07-19.",
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
@ -20,7 +20,7 @@
|
||||
"powBase": 3,
|
||||
"powRand": 5,
|
||||
"powRamp": 7,
|
||||
"downdraftOfTotal": 0.45
|
||||
"downdraftOfTotal": 0.40
|
||||
},
|
||||
|
||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||
|
||||
@ -17,20 +17,44 @@
|
||||
* no change to get this — it already hands createPlayer the scene, world and interact.
|
||||
*/
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
|
||||
// GLTFLoader is imported lazily, not at module scope, and the reason is the suite: the vendored
|
||||
// addons import the bare specifier 'three', which only resolves under index.html's importmap, so a
|
||||
// top-level import here would drag the whole GL chain in and make this file unloadable from
|
||||
// d.test.js (node resolves relative paths only — three.module.js is fine, its addons are not).
|
||||
// That mattered: needsLadder below is a RULE, and the suite was testing a hand-copied duplicate of
|
||||
// it rather than the rule itself. Same reasoning as broom.js.
|
||||
export const LADDER_URL = './models/ladder_01_v1.glb';
|
||||
|
||||
/**
|
||||
* Which anchors you cannot rig from the ground.
|
||||
*
|
||||
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
|
||||
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
|
||||
* a two-trip ladder job — which is both untrue to how sails are actually rigged and would have
|
||||
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
|
||||
* the fascia; this is that scope, in one line, where it can be found and argued with.
|
||||
* NOT a height test, deliberately. A pure "is it above reach?" rule would rope in the posts (3.95 m)
|
||||
* and tree limbs (up to 5.05 m) and turn every repair into a two-trip ladder job — untrue to how
|
||||
* sails are rigged, and it would silently invalidate the recorded §7 run and Lane B's gate asserts.
|
||||
* The real distinction isn't height, it's WHERE THE WORK HAPPENS: a blown fascia corner is re-made
|
||||
* at the bracket on the wall, while a blown post or tree corner is re-made on the cloth, which has
|
||||
* fallen to somewhere you can stand. Height is a coincidence of that; the bracket is the reason.
|
||||
*
|
||||
* SPRINT9, site_02 (SPRINT9 §Lane D — "the ladder assumes fascia height; the carport may not have
|
||||
* one"): `type === 'house'` is site_01's spelling of that idea, not the idea itself. The corner
|
||||
* block has a CARPORT — a roofline you'd also work at the bracket — and contracts.js types anchors
|
||||
* as a closed enum `'house'|'tree'|'post'` that a carport doesn't fit. Whatever E types it, this
|
||||
* must not fail OPEN: if `needsLadder` says false, `canReach` in interact.js returns true
|
||||
* unconditionally and the player re-rigs a 2.6 m bracket standing on the grass — the mechanic just
|
||||
* quietly stops existing, with no error. That is the same disease as StumbleBack's dead threshold
|
||||
* and the camera-less shadow grid: a rule keyed on the wrong thing, failing silently open.
|
||||
*
|
||||
* So it reads DATA first, exactly like `anchor.ratingHint` already does (world.js pulls that from
|
||||
* the GLB's `userData.rating_hint`), and falls back to site_01's shape. Lane A/E: set
|
||||
* `anchor.worksAtBracket = true` on the carport's anchors in the site JSON and the ladder follows
|
||||
* the site with no code change. Nothing on site_01 carries the flag, so this is a no-op there.
|
||||
*/
|
||||
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
|
||||
export const needsLadder = (anchor) => {
|
||||
if (!anchor) return false;
|
||||
if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // site data wins
|
||||
return anchor.type === 'house'; // site_01's shape
|
||||
};
|
||||
|
||||
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
||||
const STAND_OFF = 0.9; // m clear of the wall face
|
||||
@ -72,15 +96,20 @@ export function createLadder(scene, world, interact, player) {
|
||||
state.base.copy(home);
|
||||
|
||||
// --- view -----------------------------------------------------------------
|
||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||
const obj = g.scene;
|
||||
const top = obj.getObjectByName('ladder_top');
|
||||
if (top) state.topY = top.position.y;
|
||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = obj;
|
||||
scene.add(obj);
|
||||
syncView();
|
||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
||||
// Dynamic import: only ever runs in a browser (see the note at the top of the file).
|
||||
if (scene) {
|
||||
import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {
|
||||
new GLTFLoader().load(LADDER_URL, (g) => {
|
||||
const obj = g.scene;
|
||||
const top = obj.getObjectByName('ladder_top');
|
||||
if (top) state.topY = top.position.y;
|
||||
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = obj;
|
||||
scene.add(obj);
|
||||
syncView();
|
||||
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
|
||||
}).catch(() => { /* headless (selftest/node): the rules run, there is just nothing to draw */ });
|
||||
}
|
||||
|
||||
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
|
||||
function syncView() {
|
||||
|
||||
@ -146,7 +146,17 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped }) {
|
||||
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
|
||||
const hailKilled = dmg.hail > dmg.rain;
|
||||
|
||||
if (lost.length >= 2) {
|
||||
// SPRINT9 decision 1. This case had to come FIRST because it did not used to
|
||||
// exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
|
||||
// new rule it can be the best night the game has. A sail that tore itself
|
||||
// apart while the bed came through is DESIGN.md's actual story — the firework
|
||||
// you paid for — and the aftermath's hardware bill is where that payment goes.
|
||||
if (win && lost.length >= 2) {
|
||||
return { mode: 'pyrrhic',
|
||||
verdict: `THE GARDEN MADE IT. The sail didn't — the ${named} went first and took `
|
||||
+ `${lost.length - 1} more with it. That is what the sail was for.` };
|
||||
}
|
||||
if (!win && lost.length >= 2) {
|
||||
return { mode: 'cascade',
|
||||
verdict: `THE SAIL LOST. The ${named} went first — and took ${lost.length - 1} more with it.` };
|
||||
}
|
||||
@ -287,6 +297,11 @@ export function createWindRouter(all) {
|
||||
length: o.length ?? 14,
|
||||
})));
|
||||
},
|
||||
setVenturi(list) { // SPRINT9 site_02 — funnels are site geometry
|
||||
for (const w of all) w.setVenturi(list);
|
||||
return router;
|
||||
},
|
||||
get venturi() { return active.venturi; },
|
||||
|
||||
get hailSize() { return active.hailSize; }, // SPRINT5 decision 13
|
||||
get duration() { return active.duration; },
|
||||
@ -533,7 +548,25 @@ export async function boot(opts = {}) {
|
||||
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
const s = rigging.summary;
|
||||
const hp = garden.hp;
|
||||
const win = hp >= 50 && lost.length < 2;
|
||||
/**
|
||||
* SPRINT9 decision 1 — Lane A's ruling on the pyrrhic win. It was
|
||||
* `hp >= 50 && lost.length < 2`; the corner clause is gone.
|
||||
*
|
||||
* **The garden is the client's. The sail is yours.** Saving the bed with a
|
||||
* rig that tore itself apart is the job done expensively — not a failure.
|
||||
* DESIGN.md doesn't call a cascade a loss, it calls it "a firework you paid
|
||||
* for", and *paid for* is the whole point: the aftermath already bills you
|
||||
* for the broken hardware, the collateral, and a week's fee you didn't earn
|
||||
* cleanly. Gating the win on corners bills you twice for the same night and
|
||||
* then lies about it — which is the same species as the verdict that used to
|
||||
* tell a 4/4 hold they'd skimped.
|
||||
*
|
||||
* B and D both arrived here independently with the numbers, and B checked
|
||||
* what it does NOT break: cheap rigs still lose (4× carabiner → hp 38), rigs
|
||||
* that miss the bed still lose (hp 36), and $80 still cannot buy immunity.
|
||||
* The corners are priced, not gated.
|
||||
*/
|
||||
const win = hp >= 50;
|
||||
const dmg = garden.damage;
|
||||
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped });
|
||||
|
||||
|
||||
@ -19,6 +19,37 @@ export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
/**
|
||||
* Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a
|
||||
* purchase. Both cost $0.
|
||||
*
|
||||
* That pricing is a finding, not laziness. SPRINT6 asked me to "price the
|
||||
* difference"; I measured it instead and there is no price at which membrane is
|
||||
* a sane buy, because it is strictly dominated as an upgrade:
|
||||
*
|
||||
* what membrane buys +26% hail blocked — but ONLY on the pea-hail nights
|
||||
* (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY
|
||||
* ones; on storm_02 (1.3) and the ice night (1.4) a
|
||||
* knitted cloth already blocks 100%, because a 2 cm
|
||||
* stone cannot pass a 2 mm weave — C's ruling.
|
||||
* Plus rain, which is 0.25 of the drain against hail's
|
||||
* 5.0, i.e. ~5% of the damage.
|
||||
* what membrane costs DOUBLE the wind load (porosity halves the pressure
|
||||
* term), and it PONDS — the flat-sail killer, needing
|
||||
* the broom.
|
||||
*
|
||||
* Charge for that and you have shipped a trap. Free, it is the real forecast
|
||||
* bet the design describes: take the cloth when the night is windy (which is
|
||||
* every night with big hail), take the membrane when the hail is small and the
|
||||
* wind is mild and you want the rain off too. If Lane A ever raises rain's drain
|
||||
* weight, membrane earns a price and this comment is the place to start.
|
||||
*/
|
||||
export const FABRIC = [
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
||||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||||
];
|
||||
export const DEFAULT_FABRIC = FABRIC[0];
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
@ -46,6 +77,7 @@ export class RiggingSession {
|
||||
this.budget = this._startBudget;
|
||||
this.tension = DEFAULT_TENSION;
|
||||
this.spares = 0;
|
||||
this.fabric = DEFAULT_FABRIC;
|
||||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||||
this.picks = [];
|
||||
return this;
|
||||
@ -107,6 +139,19 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the
|
||||
* cost is which night you're wrong on, not dollars.
|
||||
* @param {object|string} f a FABRIC entry or its id
|
||||
*/
|
||||
setFabric(f) {
|
||||
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
|
||||
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
|
||||
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
|
||||
this.fabric = pick;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
||||
setTension(v) {
|
||||
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
||||
@ -137,6 +182,12 @@ export class RiggingSession {
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
// Fabric before attach: porosity scales the wind pressure term and decides
|
||||
// whether the cloth ponds, so the sail has to know what it's made of before
|
||||
// it is built. (It's also half of the wild night's only winnable line —
|
||||
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
||||
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
||||
if (rig.setFabric) rig.setFabric(this.fabric);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
@ -147,6 +198,7 @@ export class RiggingSession {
|
||||
spent: this.spent,
|
||||
tension: this.tension,
|
||||
spares: this.spares,
|
||||
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
weakest: this.picks.length
|
||||
|
||||
@ -823,6 +823,46 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero the peak-load watermarks to the CURRENT load. `peakLoad` is otherwise
|
||||
* peak-since-attach, which silently folds the attach transient and any settle
|
||||
* into whatever you meant to measure — call this where measurement starts.
|
||||
*
|
||||
* Written for tools/site_audit, which was reporting a 12 s settle's transient
|
||||
* as a storm peak until it called this. `load` itself is untouched.
|
||||
*/
|
||||
resetPeaks() {
|
||||
for (const c of this.corners) c.peakLoad = c.load;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* RMS speed of the cloth's nodes, m/s — read straight out of verlet, since
|
||||
* position IS the state here and velocity is just (pos - prev)/dt.
|
||||
*
|
||||
* This is what "is the sail settled" actually asks. Corner LOAD cannot answer
|
||||
* it: measured on the dressed yard, a load-trend guard reads 17% on a rig with
|
||||
* NO settle and 96% on a properly settled one, because what it really sees is
|
||||
* the rig loading up when the wind changes — the better-settled the cloth, the
|
||||
* sharper that ramp. Motion inverts none of that. Under unchanged conditions:
|
||||
*
|
||||
* 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007
|
||||
*
|
||||
* an order of magnitude, and in the direction the word "settled" means. Sample
|
||||
* it over a window, not per-step: the cloth breathes and never reaches zero.
|
||||
*/
|
||||
nodeSpeed() {
|
||||
const p = this.pos, q = this.prev;
|
||||
if (!p || !q) return 0;
|
||||
let sum = 0, n = 0;
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2];
|
||||
sum += dx * dx + dy * dy + dz * dz;
|
||||
n++;
|
||||
}
|
||||
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
@ -905,6 +945,19 @@ export class SailRig {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sail is made of. Porosity scales the wind pressure term and, since
|
||||
* rain lands on the horizontal projection of a face, an open weave sheds the
|
||||
* water it can't hold — so a porous cloth also ponds far less. Set BEFORE
|
||||
* attach(); RiggingSession.commit() does that.
|
||||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||||
*/
|
||||
setFabric(f) {
|
||||
const p = typeof f === 'number' ? f : (f && f.porosity);
|
||||
if (Number.isFinite(p)) this.porosity = clamp(p, 0, 0.9);
|
||||
return this;
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
const was = this.tension;
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
import { valueNoise2 } from './weather.core.js';
|
||||
import { valueNoise2 , hailBlockFor } from './weather.core.js';
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
@ -586,6 +586,8 @@ export function createSkyFx(o = {}) {
|
||||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||||
// what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise
|
||||
let sailPorosity = 0;
|
||||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
@ -692,7 +694,18 @@ export function createSkyFx(o = {}) {
|
||||
gardenHailExposure(rect, t) {
|
||||
const h = hailIntensity(t);
|
||||
if (h <= 0) return 0;
|
||||
return h * (1 - hailShadow.fractionOver(rect));
|
||||
// The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling
|
||||
// (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone
|
||||
// cannot pass a 2 mm weave, so porous and membrane stop the big hail
|
||||
// identically, and the ONE real difference is the finest pea hail
|
||||
// rattling through an open knit. Wired here because this is the only
|
||||
// place that knows both the stone size and what the sail is made of.
|
||||
// Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks
|
||||
// 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the
|
||||
// night membrane earns its keep. Lane B, SPRINT9 fabric landing.
|
||||
const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1;
|
||||
const block = hailBlockFor(size, sailPorosity);
|
||||
return h * (1 - hailShadow.fractionOver(rect) * block);
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
@ -718,6 +731,11 @@ export function createSkyFx(o = {}) {
|
||||
const rendering = !!camera;
|
||||
if (rendering) camera.getWorldPosition(camPos);
|
||||
else camPos.set(0, 1.7, 0);
|
||||
// Refresh what the sail is made of — gardenHailExposure needs it, and a
|
||||
// re-rig between phases can change the fabric. Read live rather than
|
||||
// cached at construction, for the same reason the shadow grids are rebuilt
|
||||
// every step: this must never quietly describe a sail that isn't there.
|
||||
if (world.sail && Number.isFinite(world.sail.porosity)) sailPorosity = world.sail.porosity;
|
||||
wind.sample(camPos, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
|
||||
@ -194,6 +194,35 @@ export default async function run(t) {
|
||||
`verdict should credit the corners that held: "${heldAll.verdict}"`);
|
||||
});
|
||||
|
||||
t.test('the pyrrhic win: a sail that dies saving the garden is a WIN', () => {
|
||||
// SPRINT9 decision 1, Lane A's ruling. The garden is the client's; the sail
|
||||
// is yours. This night used to score as a flat LOSS, which was the repo's
|
||||
// longest-running dead end — C measured the only $80 line on the wild night
|
||||
// and it lands here.
|
||||
const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } };
|
||||
const shackle = { anchorId: 'p3', hw: { name: 'shackle', rating: 3200, cost: 15 } };
|
||||
const v = verdictFor({
|
||||
hp: 52, lost: [shackle, carabiner], win: true,
|
||||
dmg: { hail: 40, rain: 8 }, pondPeak: 0, pondDumped: 0,
|
||||
});
|
||||
assertEq(v.mode, 'pyrrhic', 'garden saved + sail destroyed is its own ending, not a cascade');
|
||||
assert(/GARDEN MADE IT/.test(v.verdict), `it is a win and must read as one: "${v.verdict}"`);
|
||||
assert(/carabiner at P1/.test(v.verdict), 'and still names the weakest link that went first');
|
||||
});
|
||||
|
||||
t.test('the same wreck WITHOUT the garden is still a plain cascade', () => {
|
||||
// The other half of the ruling: corners are priced, not gated — but the
|
||||
// garden is still the whole job. Lose it and two corners and you lost.
|
||||
const carabiner = { anchorId: 'p1', hw: { name: 'carabiner', rating: 1200, cost: 5 } };
|
||||
const shackle = { anchorId: 'p3', hw: { name: 'shackle', rating: 3200, cost: 15 } };
|
||||
const v = verdictFor({
|
||||
hp: 20, lost: [shackle, carabiner], win: false,
|
||||
dmg: { hail: 70, rain: 10 }, pondPeak: 0, pondDumped: 0,
|
||||
});
|
||||
assertEq(v.mode, 'cascade');
|
||||
assert(/SAIL LOST/.test(v.verdict), `no garden, no win: "${v.verdict}"`);
|
||||
});
|
||||
|
||||
t.test('verdict names the weakest link that actually let go', () => {
|
||||
// "…the shackle. I knew about the shackle." The whole point is that the
|
||||
// player recognises the corner they gambled on.
|
||||
|
||||
@ -85,7 +85,43 @@ async function buildYard() {
|
||||
// A's p4 supplies the missing north-west corner; their measured winning line is
|
||||
// t2+p3+p4+t2b, four shackles + a spare ($75), hp 58 with 1 lost. That line is
|
||||
// what THE LINE now flies. The old quad stays as the geometry control below.
|
||||
const COVER_QUAD = ['t2', 'p3', 'p4', 't2b'];
|
||||
// SPRINT9 — THE LINE moves to C's quad, the only holdable one in the yard.
|
||||
//
|
||||
// C swept every bed-covering quad: thirteen have a corner over 6.5 kN, i.e.
|
||||
// unholdable at any price. Exactly one is buyable — p1,p2,p3,p4 — and only just.
|
||||
// It needs rated on the two heavy corners (p4, p2), a shackle on p3 and the
|
||||
// cheapest thing that will hold p1. That last corner is the whole $10 gap
|
||||
// between a $90 rig and an $80 budget.
|
||||
//
|
||||
// C IS RIGHT ABOUT p1, AND I WAS WRONG. An earlier revision of this comment
|
||||
// claimed C's table didn't reproduce — that p1 peaked at 1.27 kN with cloth at
|
||||
// dd 0.40, never crossing under a carabiner's 1.2 kN rating, and that the line
|
||||
// therefore won on TIME (a 6% overshoot too brief to trip OVERLOAD_SECS) rather
|
||||
// than on headroom. That was wrong, and the retraction is the useful part:
|
||||
//
|
||||
// re-measured on the dressed yard, this exact flight, one variable — 2026-07-17
|
||||
// shade cloth p1 0.98 kN p2 2.77 p3 1.62 p4 3.65 0/4 lost
|
||||
// membrane p1 1.23 kN p2 3.23 p3 2.55 p4 4.31 p1 LETS GO
|
||||
//
|
||||
// p1 peaks at 0.98 kN against a 1.2 kN rating: 18% of real HEADROOM, and the
|
||||
// carabiner is never once over its rating. C reported 1.08. That is the same
|
||||
// number to within measurement noise, and the same conclusion.
|
||||
//
|
||||
// Where 1.27 came from: a loadout where a corner BROKE. Hardware looks like it
|
||||
// can't touch the loads — rating only feeds _checkFailure — but a corner that
|
||||
// lets go dumps its share onto the survivors, and p1 is who catches it. Measured:
|
||||
// the same quad with hardware the budget can't actually buy (setHardware fails
|
||||
// silently, cheap hardware stays on, p2/p4 tear off) reads p1 at 2.48 kN. Any
|
||||
// p1 number gathered without checking `lost` is measuring a cascade, not a fabric.
|
||||
//
|
||||
// So the fabric is not cosmetic and this is the whole design win: 0.98 vs 1.23
|
||||
// against a 1.20 rating is the difference between the $5 carabiner holding the
|
||||
// wild night and failing it. Choosing membrane doesn't just cost wind load and
|
||||
// pond — it takes your cheapest corner off the post. `fabric_decides_p1` below
|
||||
// asserts exactly that, so nobody has to trust this paragraph.
|
||||
const COVER_QUAD = ['p1', 'p2', 'p3', 'p4'];
|
||||
/** The old line: holds nothing above shackle grade, ends pyrrhic. The sacrifice-play control. */
|
||||
const PYRRHIC_QUAD = ['t2', 'p3', 'p4', 't2b'];
|
||||
const PRE_P4_QUAD = ['p1', 't1b', 't1c', 't2b'];
|
||||
/** A rig that holds fine and shades nothing — the decision-13 control. */
|
||||
const MISS_QUAD = ['h1', 'h2', 'h3', 't1'];
|
||||
@ -111,7 +147,7 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) {
|
||||
* Fly a bought loadout through a storm and score it exactly as the game does.
|
||||
* @returns {{hp:number, lost:number, cover:number, spent:number, pond:number}}
|
||||
*/
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false, settleSecs = 12 } = {}) {
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
// The settle below runs on the CALM day, because that is what main.js hands the rig outside a
|
||||
@ -175,65 +211,88 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
|
||||
// anything. The settle is a fidelity fix, not the corner-count cause. B's arithmetic was right:
|
||||
// t2 pulls ~4.5 kN and a shackle is rated 3.2 — the line cannot hold, at any tension, settled or
|
||||
// not. See THREADS.
|
||||
{
|
||||
const settleWind = calmWind || wind;
|
||||
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
|
||||
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
|
||||
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
|
||||
// the live game is the calm day's opening minutes, not its highlight reel —
|
||||
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
|
||||
const period = 3;
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
|
||||
const settleWind = calmWind || wind;
|
||||
// Integrator fix (Sprint-8 merge): cycling t over the calm storm's FULL
|
||||
// duration replayed all of storm_01's gusts inside 12 s of sim, so the rig
|
||||
// arrived at "entry" mid-gust and D's guard (correctly) refused it. Prep in
|
||||
// the live game is the calm day's opening minutes, not its highlight reel —
|
||||
// hold the settle inside storm_01's pre-gust window (first gust >= 3 s).
|
||||
const PREP_PERIOD = 3;
|
||||
let prepT = 0;
|
||||
/** Prep on the clock the player actually stands in. Returns mean cloth speed, m/s. */
|
||||
const prep = (secs) => {
|
||||
let sum = 0;
|
||||
const n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) {
|
||||
rig.step(FIXED_DT, settleWind, prepT % PREP_PERIOD);
|
||||
prepT += FIXED_DT;
|
||||
sum += rig.nodeSpeed();
|
||||
}
|
||||
}
|
||||
/** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */
|
||||
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
|
||||
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0').
|
||||
return sum / n;
|
||||
};
|
||||
if (settleSecs > 0) prep(settleSecs); // settleSecs 0 = the unsettled control, below
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0') — REDESIGNED, SPRINT9, B with
|
||||
// D's demotion measurements. D: this is the change you're owed a veto on.
|
||||
//
|
||||
// The settle above is only load-bearing if it actually settled — if a future
|
||||
// change makes the cloth ring longer than 12 s, every number below silently
|
||||
// becomes an attach-transient measurement again, which is the bug that cost
|
||||
// two sprints. So prove it.
|
||||
//
|
||||
// It has to be a TREND test, not an instant one. Measured: with the wind held
|
||||
// constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN,
|
||||
// because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind
|
||||
// drag is meant to do the damping). There is no single settled value; the
|
||||
// cloth breathes. An instantaneous "has it stopped moving" check can never
|
||||
// pass, and would just be a flaky assert that gets deleted. Same lesson as the
|
||||
// statics assert in sail.selftest: compare time-AVERAGED windows.
|
||||
const meanLoad = (secs) => {
|
||||
let sum = 0, n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); }
|
||||
return sum / n;
|
||||
};
|
||||
const w1 = meanLoad(2);
|
||||
const w2 = meanLoad(2);
|
||||
const trend = Math.abs(w2 - w1) / Math.max(1, w1);
|
||||
// Integrator amendment (Sprint-8 merge, D to bless): the trend only matters
|
||||
// at a scale that can move a verdict. The transient that cost two sprints was
|
||||
// kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads
|
||||
// as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating.
|
||||
// ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign,
|
||||
// not a threshold. It was authored before ponding merged, and at a held clock
|
||||
// storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING
|
||||
// the guard's own windows — the "trend" it reads is water arriving, which no
|
||||
// settle length fixes (measured tonight: 105% with full-curve settle, 46%
|
||||
// dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly).
|
||||
// The guard's idea is right; its clock is wrong. Redesign: measure the trend
|
||||
// over the storm's own advancing first seconds (rain then follows the real
|
||||
// curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it
|
||||
// WARNS instead of failing so the merged suite reports the balance truthfully.
|
||||
if (trend > 0.35 && Math.max(w1, w2) > 600) {
|
||||
console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` +
|
||||
`${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` +
|
||||
`(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` +
|
||||
`Every balance number below is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Oscillation is expected and fine; a TREND is not.)`);
|
||||
// THE OBSERVABLE WAS THE BUG, not the clock. The demoted guard compared the
|
||||
// worst corner's mean LOAD across two windows and called a change a "trend".
|
||||
// Three redesigns were measured on the dressed yard before one worked, and the
|
||||
// two the integrator proposed are among the ones that don't — so, in full, in
|
||||
// case anyone is tempted back:
|
||||
//
|
||||
// probe under CALM, held in prep 0 s settle 13% 12 s settle 17%
|
||||
// probe under STORM, held clock 0 s settle 24% 12 s settle 104%
|
||||
// probe under STORM, advancing clock 0 s settle 24% 12 s settle 104%
|
||||
// probe under STORM, RAINLESS 0 s settle 17% 12 s settle 96%
|
||||
//
|
||||
// Every one of them is either flat (calm cannot excite the cloth, so the guard
|
||||
// is vacuous and passes forever — an assert that cannot fail) or INVERTED: it
|
||||
// fires hardest on the properly settled rig. Rain is not the culprit either;
|
||||
// the rainless probe ponds 1 kg and still inverts. What a load-trend actually
|
||||
// measures is the rig LOADING UP when the wind changes, and a taut settled
|
||||
// cloth ramps HARDER than a limp unsettled one (0.63 -> 1.23 kN vs
|
||||
// 0.44 -> 0.52). The metric was reading the storm's arrival, not the cloth.
|
||||
// No threshold, clock or rain switch fixes an observable pointed at the wrong
|
||||
// thing. (The integrator's ponding diagnosis was right about the mechanism and
|
||||
// is also unfixable by clock: RAIN_TIME_COMPRESSION is 40×, so even 4 s of
|
||||
// ADVANCING storm is 160 s of rain — 43 kg in the belly either way.)
|
||||
//
|
||||
// "Settled" is a statement about the CLOTH, so ask the cloth. rig.nodeSpeed()
|
||||
// is RMS node speed straight out of verlet, sampled over a 2 s window of the
|
||||
// same calm prep the rig is already standing in — no wind change to ramp
|
||||
// against, no compressed rain, and nothing left behind in the rig but two more
|
||||
// seconds of the prep a player does anyway. Measured, dressed yard:
|
||||
//
|
||||
// settle 0 s 4 s 12 s 30 s
|
||||
// speed 0.19 0.014 0.017 0.007 m/s (pyrrhic quad: 0.23 -> 0.028)
|
||||
//
|
||||
// An order of magnitude, in the direction the word means. It is an ABSOLUTE
|
||||
// check, not a trend: the old comment's "the cloth breathes, an instantaneous
|
||||
// check can never pass" is true and is why this is a windowed MEAN — but the
|
||||
// breathing sits at 0.02 m/s and the transient at 0.19, so there is a real gap
|
||||
// to put a line in. 0.08 is ~3× above the worst settled rig here and ~2.4×
|
||||
// below the loosest unsettled one.
|
||||
//
|
||||
// This is a FAILURE again, not a warning. It earns that by being able to fail:
|
||||
// asserted below on a deliberately unsettled rig. — B, SPRINT9. D: your veto.
|
||||
const SETTLED_SPEED = 0.08;
|
||||
const entrySpeed = prep(2);
|
||||
if (entrySpeed > SETTLED_SPEED) {
|
||||
throw new Error(`yard is NOT settled at storm entry: the cloth is still moving at ` +
|
||||
`${entrySpeed.toFixed(3)} m/s (settled is <${SETTLED_SPEED}) after a ${settleSecs} s settle. ` +
|
||||
`Every balance number in this suite is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Breathing is expected and fine; ~0.19 m/s is a cloth still falling ` +
|
||||
`into shape.)`);
|
||||
}
|
||||
|
||||
/** Peak corner load the instant the storm starts — settled, dry, on the calm day. */
|
||||
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
|
||||
|
||||
let hp = 100, pond = 0, used = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
@ -264,8 +323,9 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
|
||||
lost: rig.corners.filter((c) => c.broken).length,
|
||||
spent: START_BUDGET - session.budget,
|
||||
pond: Math.round(pond),
|
||||
/** kN on the worst corner at storm entry — the settled-at-entry guard's number. */
|
||||
entryPeak: entryPeak / 1000,
|
||||
/** mm/s the cloth was still moving when the storm started — the settled-at-entry guard.
|
||||
* (Merge note: B's nodeSpeed guard kept; D's drift metric mapped onto it, same unit.) */
|
||||
settleDrift: entrySpeed * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
@ -286,8 +346,26 @@ export default async function run(t) {
|
||||
// makes the repair legal, and it's the trap in this whole balance question: a
|
||||
// loadout that spends all $80 on hardware cannot repair anything.
|
||||
// A's measured line: four shackles + a spare = $75 (THREADS gate-1 entry).
|
||||
const lineShop = shop(yard, COVER_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1);
|
||||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { repair: true, broom: true }) : null;
|
||||
// C's $80 clean win: shade cloth, rated on the two heavy corners, shackle on
|
||||
// p3, carabiner on p1. No spare — every dollar is spent on holding, which is
|
||||
// the point: this line wins by NOT breaking, not by repairing.
|
||||
const lineShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (lineShop) lineShop.setFabric('cloth');
|
||||
const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
// The sacrifice play: the old t2 quad, four shackles + a spare. Holding all
|
||||
// four of ITS corners costs $105; $80 buys two. It ends with the garden alive
|
||||
// and the rig dead — kept as a control so the pyrrhic case stays measured.
|
||||
const pyrrhicShop = shop(yard, PYRRHIC_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1);
|
||||
if (pyrrhicShop) pyrrhicShop.setFabric('cloth');
|
||||
const pyrrhic = pyrrhicShop ? await fly(yard, pyrrhicShop, 'storm_02_wildnight', { repair: true, broom: true }) : null;
|
||||
|
||||
// The SAME $80 line, one variable changed: waterproof membrane instead of shade
|
||||
// cloth. This is the fabric decision's control — see the header. Both fabrics
|
||||
// cost $0, so the shop cannot tell them apart; only the storm can.
|
||||
const membraneShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (membraneShop) membraneShop.setFabric('membrane');
|
||||
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
|
||||
@ -298,6 +376,24 @@ export default async function run(t) {
|
||||
const gentleShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const gentle = gentleShop ? await fly(yard, gentleShop, 'storm_01_gentle') : null;
|
||||
|
||||
// THE GUARD'S OWN CONTROL. A guard nobody has ever seen fail is indistinguishable
|
||||
// from a guard that cannot fail — that is how the previous version survived being
|
||||
// both vacuous and inverted for two sprints. So fly the same line with NO settle:
|
||||
// the cloth is still falling into shape, and the guard must refuse it. Flown here,
|
||||
// up front, because testkit's Suite.test() does NOT await — an async assert would
|
||||
// pass forever while proving nothing (the standing house rule; see SPRINT6).
|
||||
let unsettled;
|
||||
{
|
||||
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (s) s.setFabric('cloth');
|
||||
try {
|
||||
await fly(yard, s, 'storm_02_wildnight', { settleSecs: 0 });
|
||||
unsettled = { fired: false };
|
||||
} catch (e) {
|
||||
unsettled = { fired: /NOT settled at storm entry/.test(e.message), err: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
|
||||
@ -332,37 +428,98 @@ export default async function run(t) {
|
||||
// that the win rule wants to be `hp >= 50` with corners priced in the aftermath
|
||||
// rather than gating the win — but it is A's call and this assert states the
|
||||
// measurement, not the verdict.
|
||||
t.test("balance: storm_02's best line saves the garden (pyrrhic — see SPRINT8 gate 0')", () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy the candidate line at all');
|
||||
// The garden half — the part the player is actually protecting — must hold.
|
||||
if (line.hp < 50) {
|
||||
throw new Error(`the wild night's best $${line.spent} line let the garden die: hp=${line.hp} ` +
|
||||
`(need >=50). This is a real balance regression, not the pyrrhic-win question.`);
|
||||
// SPRINT9 — the wild night finally has a CLEAN win, and this asserts it.
|
||||
//
|
||||
// $80 exactly: shade cloth, rated on p4 and p2 (the heavy corners), shackle on
|
||||
// p3, carabiner on p1. No spare. It wins by holding, not by repairing.
|
||||
//
|
||||
// This is the assert that fabric and the 0.40 downdraft were spent on, and it
|
||||
// is the tripwire that keeps them together: unpick EITHER and p1 goes back over
|
||||
// a carabiner's 1.2 kN rating, the line costs $90 on an $80 budget, and this
|
||||
// goes red. That is the intended behaviour, not a brittle test.
|
||||
t.test('balance: storm_02 has a CLEAN $80 win — the wild night is fair', () => {
|
||||
if (!line) throw new Error('the $80 shop cannot buy C\'s line — fabric or the 0.40 flip has been unpicked');
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
throw new Error(`the wild night's winnable line lost it: $${line.spent} on ${COVER_QUAD.join(',')} ` +
|
||||
`ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). Check that storm_02 still reads ` +
|
||||
`downdraftOfTotal 0.40 AND that the rig is on shade cloth — the line only exists with both.`);
|
||||
}
|
||||
const pyrrhic = line.lost >= 2;
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> garden hp ${line.hp}` +
|
||||
(pyrrhic
|
||||
? `, but ${line.lost}/4 corners gone — PYRRHIC. Holding all four costs $105 (+$15 spare) on an $80 budget; A owns the win rule.`
|
||||
: `, ${line.lost}/4 lost — a clean win.`);
|
||||
return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`;
|
||||
});
|
||||
|
||||
// The guard is load-bearing for every number in this suite, so it does not get
|
||||
// to be decoration. If this ever goes red, the guard has stopped being able to
|
||||
// fail and its silence elsewhere means nothing.
|
||||
t.test('balance: the settled-at-entry guard can fail — a 0 s settle trips it', () => {
|
||||
if (!unsettled) throw new Error('the unsettled control never flew');
|
||||
if (unsettled.fired === false) {
|
||||
throw new Error(`the settled-at-entry guard PASSED a rig with no settle at all. It is now ` +
|
||||
`vacuous — every "settled" claim in this suite rests on an assert that cannot fail. ` +
|
||||
`Check rig.nodeSpeed() still reads verlet, and that SETTLED_SPEED is not above the ` +
|
||||
`~0.19 m/s an unsettled cloth actually moves at.`);
|
||||
}
|
||||
if (!unsettled.fired) throw new Error(`the unsettled control died for the wrong reason: ${unsettled.err}`);
|
||||
return 'a 0 s settle trips the guard — its silence on the real flights means something';
|
||||
});
|
||||
|
||||
// FABRIC IS A DECISION, NOT A SKIN. Both fabrics are $0 — DESIGN.md wants the
|
||||
// forecast to be the price — so the only thing that can justify the choice is
|
||||
// that it changes the night. Measured, same $80 line, one variable:
|
||||
//
|
||||
// shade cloth p1 0.98 kN -> holds (18% under a 1.2 kN carabiner)
|
||||
// membrane p1 1.23 kN -> LETS GO (2.5% over, long enough to trip)
|
||||
//
|
||||
// Membrane loses the cheapest corner on the wild night; that is what you buy
|
||||
// with the +26% hail block it gives back. If this ever goes green-on-both, the
|
||||
// fabric pick has become free and the prep screen is lying about a choice.
|
||||
t.test('balance: fabric decides p1 — membrane tears the cheap corner off', () => {
|
||||
if (!line || !membrane) throw new Error('could not buy the fabric control on $80');
|
||||
if (membrane.lost <= line.lost) {
|
||||
throw new Error(`fabric stopped mattering: the same $80 line lost ${line.lost}/4 on cloth and ` +
|
||||
`${membrane.lost}/4 on membrane. Both fabrics are free, so if the storm can't tell them apart ` +
|
||||
`the choice is cosmetic — either porosity stopped reaching the wind load (sail.js setFabric / ` +
|
||||
`rigging.commit ordering) or storm_02 got soft enough that p1 survives full load.`);
|
||||
}
|
||||
return `same $80 line: cloth ${line.lost}/4 lost (hp ${line.hp}) · membrane ${membrane.lost}/4 lost ` +
|
||||
`(hp ${membrane.hp}) — the fabric is the decision`;
|
||||
});
|
||||
|
||||
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
|
||||
// garden". Now that a clean win EXISTS, this stops being the wild night's only
|
||||
// outcome and becomes what it should always have been — a different, worse bet
|
||||
// the shop will happily sell you. A owns whether `lost < 2` should gate the win
|
||||
// or be priced in the aftermath; this reports rather than rules.
|
||||
t.test('balance: the t2 quad still ends pyrrhic (the sacrifice play)', () => {
|
||||
if (!pyrrhic) throw new Error('could not buy the pyrrhic control');
|
||||
if (pyrrhic.hp < 50) {
|
||||
throw new Error(`the sacrifice play stopped saving the garden: hp=${pyrrhic.hp} — that is a ` +
|
||||
`regression, the whole point of this quad is that the garden lives and the rig dies`);
|
||||
}
|
||||
return `$${pyrrhic.spent} on ${PYRRHIC_QUAD.join(',')} -> garden hp ${pyrrhic.hp}, ${pyrrhic.lost}/4 lost` +
|
||||
(pyrrhic.lost >= 2 ? ' — pyrrhic, as designed (holding all four costs $105)' : ' — clean');
|
||||
});
|
||||
|
||||
/**
|
||||
* D's settled-at-entry guard (SPRINT8 §Lane D). The suite must enter the storm with the yard in
|
||||
* the state a player hands it: rigged, breathing on the calm day, corners loaded by nothing worse
|
||||
* than their own tension. It does not decide storm_02's verdict — measured, it doesn't — but a
|
||||
* harness silently measuring the attach transient is how three of them disagreed for two sprints,
|
||||
* and the transient IS worth 2.7× on tree corners in the moment it exists.
|
||||
* D's settled-at-entry guard, redesigned to measure SHAPE (SPRINT9, B+D). Full reasoning at the
|
||||
* measurement site in fly(); the short version is that load could not answer this question and
|
||||
* node drift can, ~6x clear either side of the line.
|
||||
*
|
||||
* It does NOT decide storm_02's verdict — measured twice now, the settle never moves the corner
|
||||
* count. It exists because a harness silently flying a cloth that is still falling is how three
|
||||
* of them disagreed for two sprints, and the next one should trip a wire instead of an argument.
|
||||
*/
|
||||
t.test('harness: the yard is SETTLED when the storm starts', () => {
|
||||
t.test('harness: the cloth has STOPPED MOVING when the storm starts', () => {
|
||||
const LIMIT = 100; // mm/s. Measured: 212 unsettled, 35 worst settled breath.
|
||||
const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r);
|
||||
for (const [name, r] of runs) {
|
||||
if (!(r.entryPeak < 1.0)) {
|
||||
if (!(r.settleDrift < LIMIT)) {
|
||||
throw new Error(
|
||||
`${name} entered the storm carrying ${r.entryPeak.toFixed(2)} kN — that is an attach ` +
|
||||
'transient, not a rig. Settle on the calm day with a running clock, as main.js does.');
|
||||
`${name} entered the storm with the cloth still drifting ${r.settleDrift.toFixed(0)} mm/s ` +
|
||||
`(limit ${LIMIT}). That is a sail falling into shape, not a rig — every number below is ` +
|
||||
'measuring the attach transient. Lengthen the settle in fly().');
|
||||
}
|
||||
}
|
||||
return `entry loads: ${runs.map(([n, r]) => `${n} ${r.entryPeak.toFixed(2)} kN`).join(' · ')}`;
|
||||
return `settle drift: ${runs.map(([n, r]) => `${n} ${r.settleDrift.toFixed(1)} mm/s`).join(' · ')}`;
|
||||
});
|
||||
|
||||
t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {
|
||||
|
||||
@ -14,6 +14,9 @@
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
|
||||
import { Interact, wireYardActions } from '../interact.js';
|
||||
import { createBroom, BROOM_TUNE } from '../broom.js';
|
||||
// The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements
|
||||
// is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.)
|
||||
import { needsLadder as realNeedsLadder } from '../ladder.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
@ -438,14 +441,29 @@ export default async function run(t) {
|
||||
});
|
||||
|
||||
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
|
||||
const L = fakeLadder();
|
||||
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
|
||||
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }),
|
||||
'a 4 m post does NOT — you tension it from a cleat at the base');
|
||||
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
|
||||
assert(!realNeedsLadder({ type: 'tree', pos: { y: 5.05 } }),
|
||||
'nor a tree limb — that is a strop you throw');
|
||||
});
|
||||
|
||||
t.test('ladder: a site can DECLARE which anchors are worked at the bracket', () => {
|
||||
// SPRINT9 §Lane D, ahead of site_02. The corner block has a CARPORT, and contracts.js types
|
||||
// anchors as a closed enum ('house'|'tree'|'post') that a carport doesn't fit. The failure mode
|
||||
// matters more than the feature: if needsLadder says false, interact's canReach returns true
|
||||
// UNCONDITIONALLY, so the player re-rigs a 2.6 m bracket from the grass and the mechanic
|
||||
// silently stops existing. Data first, site_01's type as the fallback.
|
||||
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.6 }, worksAtBracket: true }),
|
||||
'a carport roofline is bracket work even though its type is not "house"');
|
||||
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false }),
|
||||
'and a site can say otherwise — a low pergola you can reach off the deck');
|
||||
// and site_01 must be untouched: nothing there carries the flag
|
||||
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no flag → site_01 behaviour');
|
||||
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no flag → post is still ground work');
|
||||
assert(!realNeedsLadder(null) && !realNeedsLadder(undefined), 'and it never throws on a gap');
|
||||
});
|
||||
|
||||
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const L = fakeLadder({ player: p });
|
||||
|
||||
@ -83,6 +83,8 @@ const ASSETS = [
|
||||
{ name: 'carport_01', h: [2.4, 2.8],
|
||||
nodes: ['footings', 'posts', 'beams', 'roof',
|
||||
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
|
||||
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
|
||||
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -153,6 +155,36 @@ export default async function run(t) {
|
||||
// house fascia, which DESIGN.md already calls a lie. If someone quietly
|
||||
// "fixes" these ratings upward the site stops teaching anything and just
|
||||
// becomes a smaller yard, so they're pinned here with the reason attached.
|
||||
// The trap has to be scoreable or it isn't a trap, only a warning. The anchors
|
||||
// said collateral="carport" from Sprint 9 but nothing said what a carport
|
||||
// COSTS, so Lane A's aftermath had no number to reach for and the site's whole
|
||||
// lesson was unpriced. main.js already reads world.gnome.collateralValue — this
|
||||
// is the same shape in the same place.
|
||||
t.test('the carport is priced, and it is the worst bill on the site', () => {
|
||||
const c = loaded.get('carport_01')?.scene.getObjectByName('carport_01');
|
||||
assert(c, 'carport_01 root missing');
|
||||
const cost = c.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', `carport carries no collateral_value (${JSON.stringify(c.userData)})`);
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene
|
||||
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
|
||||
assert(cost > gnome, `carport (${cost}) must cost more than the gnome (${gnome})`);
|
||||
// Wreckage carries the same price, so scoring can read either state.
|
||||
const w = loaded.get('carport_01_wrecked')?.scene.getObjectByName('carport_01_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked carport must be priced the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'carport_01',
|
||||
'wrecked carport must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// A wreck that stands taller than the thing it was is a bug, not a wreck.
|
||||
t.test('the wrecked carport is shorter than the carport', () => {
|
||||
const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
|
||||
const b = new THREE.Box3().setFromObject(loaded.get('carport_01_wrecked').scene);
|
||||
const hi = a.max.y - a.min.y, lo = b.max.y - b.min.y;
|
||||
assert(lo < hi, `wreck stands ${lo.toFixed(2)} m vs the intact ${hi.toFixed(2)} m`);
|
||||
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
|
||||
});
|
||||
|
||||
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
|
||||
const g = loaded.get('carport_01');
|
||||
assert(g, 'carport_01 did not load');
|
||||
|
||||
@ -281,6 +281,80 @@ export function weatherCases(storms) {
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
// ---- 8b. venturi (SPRINT9 site_02 — the corner block funnels the southerly) ----
|
||||
test('venturi speeds the wind up when it blows along the gap, not across it', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
// a funnel whose axis runs NE–SW (0.9 rad ≈ 51°), at the yard centre
|
||||
const AXIS = 0.9;
|
||||
const bare = createWindField(def);
|
||||
const funnel = createWindField(def).setVenturi([{ x: 0, z: 0, axis: AXIS, gain: 1.5, radius: 5, sharp: 3 }]);
|
||||
|
||||
// when the wind blows ALONG the axis, the throat should scream
|
||||
const dt = 1 / 60;
|
||||
let alongBoost = 1, crossBoost = 1, alongT = 0, crossT = 0;
|
||||
for (let t = 0; t <= def.duration; t += dt) {
|
||||
const d = bare.dirAt(t);
|
||||
// alignment of the wind with the funnel axis, |dot|
|
||||
const align = Math.abs(Math.cos(d) * Math.cos(AXIS) + Math.sin(d) * Math.sin(AXIS));
|
||||
const boost = funnel.speedAt(0, 0, t) / bare.speedAt(0, 0, t);
|
||||
if (align > 0.98 && boost > alongBoost) { alongBoost = boost; alongT = t; }
|
||||
if (align < 0.15 && boost < crossBoost) { crossBoost = boost; crossT = t; }
|
||||
}
|
||||
metrics['venturi.alignedBoost'] = +alongBoost.toFixed(3);
|
||||
metrics['venturi.crosswindBoost'] = +crossBoost.toFixed(3);
|
||||
assert(alongBoost > 1.35, `a dead-on wind only boosted ${alongBoost.toFixed(2)}× at t=${alongT.toFixed(1)} — funnel too weak`);
|
||||
assert(crossBoost < 1.03, `a crosswind still boosted ${crossBoost.toFixed(2)}× at t=${crossT.toFixed(1)} — a funnel shouldn't fire across its axis`);
|
||||
// never slows the wind (a venturi accelerates, it doesn't shelter)
|
||||
assert(alongBoost >= 1 && crossBoost >= 1, 'venturi reduced the wind somewhere — it can only speed it up');
|
||||
});
|
||||
|
||||
test('venturi only reaches inside its radius, and downdraft rides it', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.5, radius: 5, sharp: 3 }]);
|
||||
const bare = createWindField(def);
|
||||
// far outside the throat: untouched
|
||||
assert(Math.abs(f.speedAt(20, 0, 40) - bare.speedAt(20, 0, 40)) < 1e-9, 'venturi reached 20 m away');
|
||||
// the vertical downdraft is a fraction of local speed, so it funnels too
|
||||
const out = { x: 0, y: 0, z: 0 }, outBare = { x: 0, y: 0, z: 0 };
|
||||
let maxRatio = 0;
|
||||
for (let t = 0; t <= def.duration; t += 1 / 60) {
|
||||
f.vecAt(0, 0, t, out); bare.vecAt(0, 0, t, outBare);
|
||||
if (outBare.y < -0.1) maxRatio = Math.max(maxRatio, out.y / outBare.y); // both negative
|
||||
}
|
||||
assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
|
||||
});
|
||||
|
||||
test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const bare = createWindField(def);
|
||||
const set = createWindField(def).setVenturi([]);
|
||||
for (const p of PROBES) {
|
||||
for (const t of [10, 40, 60, 75]) {
|
||||
assert(bare.speedAt(p.x, p.z, t) === set.speedAt(p.x, p.z, t),
|
||||
`setVenturi([]) changed the wind at (${p.x},${p.z}) t=${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('venturi keeps the wind continuous — no frame-to-frame snap', () => {
|
||||
// a discontinuity in the funnel would be an impulse straight into a corner;
|
||||
// both axes matter — a standing sail (time) and a swinging wind (alignment).
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.6, radius: 5, sharp: 3 }]);
|
||||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||||
let worst = 0, worstT = 0;
|
||||
// sit right in the throat through the whole storm, including the change
|
||||
f.vecAt(0.5, 0.5, 0, a);
|
||||
for (let t = 1 / 60; t <= def.duration; t += 1 / 60) {
|
||||
f.vecAt(0.5, 0.5, t, b);
|
||||
const jump = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
if (jump > worst) { worst = jump; worstT = t; }
|
||||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||||
}
|
||||
metrics['venturi.maxTemporalJump'] = +worst.toFixed(3);
|
||||
assert(worst < 1.5, `venturi jumped ${worst.toFixed(2)} m/s in one frame at t=${worstT.toFixed(1)}`);
|
||||
});
|
||||
|
||||
// ---- 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
|
||||
|
||||
@ -252,6 +252,7 @@ export function createWindField(def, opts = {}) {
|
||||
: [];
|
||||
|
||||
let shelters = [];
|
||||
let venturi = [];
|
||||
|
||||
/** Spatially-uniform part: base curve + every gust envelope live at t. */
|
||||
function uniformSpeed(t) {
|
||||
@ -278,12 +279,13 @@ 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. */
|
||||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow,
|
||||
* site venturi. The one place the local-speed maths lives; speedAt/vecAt/
|
||||
* verticalAt share it, so the downdraft rides the funnel too. */
|
||||
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));
|
||||
const s = uni * localFactor(x, z, t, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
|
||||
@ -377,6 +379,38 @@ export function createWindField(def, opts = {}) {
|
||||
return f;
|
||||
}
|
||||
|
||||
/**
|
||||
* A venturi is a shelter's opposite: a gap between buildings SPEEDS the wind up
|
||||
* when it blows along the gap's axis (SPRINT9 site_02, the corner block funnels
|
||||
* the southerly). Where a tree shadow depends on being downwind of the tree,
|
||||
* a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns —
|
||||
* so it fires only when the storm's direction swings to match, which is the
|
||||
* whole drama: the corner block is calm until the southerly comes through, then
|
||||
* it screams. Multiplier ≥ 1, and inert (empty list) on any site that has no
|
||||
* funnel, so backyard_01 is untouched.
|
||||
*/
|
||||
function venturiFactor(x, z, dirX, dirZ) {
|
||||
let f = 1;
|
||||
for (let i = 0; i < venturi.length; i++) {
|
||||
const v = venturi[i];
|
||||
const rx = x - v.x, rz = z - v.z;
|
||||
const dist = Math.hypot(rx, rz);
|
||||
if (dist >= v.radius) continue;
|
||||
// how well the wind lines up with the gap's axis. |dot| because a gap
|
||||
// funnels either way through it; ^sharp so only a well-aligned wind counts.
|
||||
let align = Math.abs(dirX * v.axisX + dirZ * v.axisZ);
|
||||
align = Math.pow(align, v.sharp);
|
||||
const radial = 1 - smoothstep(v.radius * 0.4, v.radius, dist); // full in the throat, fades out
|
||||
f *= 1 + (v.gain - 1) * align * radial;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Every spatial speed multiplier at a point, given the wind direction. */
|
||||
function localFactor(x, z, t, dirX, dirZ) {
|
||||
return spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ) * venturiFactor(x, z, dirX, dirZ);
|
||||
}
|
||||
|
||||
const field = {
|
||||
def,
|
||||
seed,
|
||||
@ -399,6 +433,32 @@ export function createWindField(def, opts = {}) {
|
||||
},
|
||||
get shelters() { return shelters; },
|
||||
|
||||
/**
|
||||
* A site's wind funnels (SPRINT9 site_02). Lane A calls this from the site
|
||||
* JSON after building the yard, the same way it calls setSheltersFromTrees.
|
||||
* Unset = no funnels, so backyard_01 is untouched. Each zone:
|
||||
* { x, z } centre of the throat, metres
|
||||
* axis direction the gap runs, RADIANS in the XZ plane
|
||||
* gain peak speed multiplier when the wind is dead-on (>1)
|
||||
* radius how far the acceleration reaches, metres
|
||||
* sharp alignment falloff exponent — higher = only a wind almost
|
||||
* exactly along the axis funnels (default 3)
|
||||
*/
|
||||
setVenturi(list) {
|
||||
venturi = (list || []).map((v) => {
|
||||
const axis = v.axis ?? 0;
|
||||
return {
|
||||
x: v.x, z: v.z,
|
||||
axisX: Math.cos(axis), axisZ: Math.sin(axis),
|
||||
gain: Math.max(1, v.gain ?? 1.4),
|
||||
radius: v.radius ?? 4,
|
||||
sharp: Math.max(1, v.sharp ?? 3),
|
||||
};
|
||||
});
|
||||
return field;
|
||||
},
|
||||
get venturi() { return venturi; },
|
||||
|
||||
/**
|
||||
* Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
|
||||
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
|
||||
|
||||
@ -136,6 +136,15 @@ export function createWind(def, opts = {}) {
|
||||
})));
|
||||
},
|
||||
|
||||
/**
|
||||
* A site's wind funnels (SPRINT9 site_02). Lane A: call with the site JSON's
|
||||
* `wind.venturi` after building the yard. Empty/absent = no funnels, so
|
||||
* backyard_01 reads exactly as it always has.
|
||||
* @param {Array<{x,z,axis,gain,radius,sharp}>} list
|
||||
*/
|
||||
setVenturi(list) { field.setVenturi(list); return wind; },
|
||||
get venturi() { return field.venturi; },
|
||||
|
||||
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
||||
|
||||
|
||||
Binary file not shown.
BIN
web/world/models/carport_01_wrecked_v1.glb
Normal file
BIN
web/world/models/carport_01_wrecked_v1.glb
Normal file
Binary file not shown.
@ -79,7 +79,14 @@ summary.textContent = report.ok
|
||||
: `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`;
|
||||
|
||||
const out = document.getElementById('out');
|
||||
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
|
||||
// Render every lane the report actually CONTAINS, rather than a hardcoded list.
|
||||
// This read ['A','B','C','D','E'] while the import list above also carries BAL,
|
||||
// so the balance suite ran, was counted in the summary, and had every one of its
|
||||
// rows silently dropped — including, had one ever gone red, the row saying which
|
||||
// assert failed and why. The merge gate was invisible in the merge gate's own
|
||||
// report. My bug: I added the BAL import and not this. Deriving the list means
|
||||
// the next joint suite can't hit it. — B
|
||||
for (const letter of [...new Set(report.results.map((r) => r.lane))]) {
|
||||
const rows = report.results.filter((r) => r.lane === letter);
|
||||
if (!rows.length) continue;
|
||||
const h = document.createElement('div');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user