Compare commits

...

19 Commits

Author SHA1 Message Date
m3ultra
3e769dbae9 THREADS: SPRINT9 Lane B — retract the p1 claim to C, guard redesign for D, the graybox-yard trap
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:01:49 +10:00
m3ultra
6fa7644aba Redesign the settled-at-entry guard: ask the cloth, not the corners
SPRINT9 task for B+D. The demoted guard compared the worst corner's mean LOAD
across two windows and called the difference a "trend". The integrator's
diagnosis was that its clock was wrong — held at t=0, storm_02's compressed
rain ponds the cloth during the guard's own windows. That mechanism is real,
but it is not the bug, and neither of the two suggested fixes works. Measured
on the 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 cannot excite the cloth, so the guard is
vacuous and passes forever — or INVERTED, firing hardest on the properly
settled rig. Rain is not the culprit: the rainless probe ponds 1 kg and still
inverts. Advancing the clock isn't a fix either, because RAIN_TIME_COMPRESSION
is 40× and 4 s of storm is 160 s of rain regardless.

What a load-trend 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 observable was pointed at the storm's arrival, not at the
cloth. No threshold, clock or rain switch fixes that.

"Settled" is a statement about the cloth, so ask the cloth. sail.js gains
nodeSpeed(): RMS node speed out of verlet. Sampled over 2 s of the same calm
prep the rig already stands in — no wind change to ramp against, no compressed
rain, and nothing left in the rig but two more seconds of prep a player does
anyway. Measured:

    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, not a trend: the cloth breathes at 0.02 and the transient sits at
0.19, so there is real room for a line.

Promoted back from warn to a hard failure — and it earns that by being able to
fail. `the settled-at-entry guard can fail` flies the same line with NO settle
and asserts the guard refuses it. A guard nobody has seen fail is
indistinguishable from one that cannot, which is exactly how this one survived
two sprints being both vacuous and inverted. The control is flown up front in
run(), because Suite.test() does not await.

The old guard also left 43 kg of water and 4 s of storm load in the rig before
the storm started; the suite was flying a wet rig into a dry entry. It didn't
move the peaks (the storm's own peak dwarfs it) but it was never true.

D: this is yours to veto. Selftest 277 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:01:09 +10:00
m3ultra
e4436248b0 Add tools/site_audit; retract the p1 claim against C; show LANE BAL
site_audit answers "is this site winnable?" before it ships — the check that
was missing when SPRINT6 shipped a yard whose bed-covering quad pulled 7.4 kN
against a shop that holds 6.5. It enumerates in-band quads, flies the real
storm headless, maps each corner to the cheapest tier that holds it, and
verdicts against the $80 budget. On backyard_01 it independently reproduces
THE LINE: p1,p2,p3,p4 at $65 + $15 spare, matching balance.test to within 2%.

It also caught three of my own bugs, which is the point of building it:

  · it settled on STORM wind frozen at t=0 — the exact anti-pattern
    balance.test documents at 1.94 kN vs 0.40 kN. Now settles on the calm
    day on a running clock, the way main.js does.
  · peakLoad is peak-since-ATTACH, so the settle transient was being
    reported as a storm peak. sail.js gains resetPeaks().
  · its p4 was typed from world.js's postSpecs, missing that posts are RAKED
    8° away from centre — 0.56 m off. The audit now re-derives all four posts
    from live world.js on every run and hard-fails on drift.

That last check is deliberately narrow. Node CANNOT dress: dress() needs
GLTFLoader (bare 'three') and fetch()es .glb over file://. createWorld() still
SUCCEEDS in node — it just returns the GRAYBOX yard, house at x=±5, no branch
anchors. So a headless tool that "reads world.js for the real yard" gets the
fictional x=±5 house that cost two sprints. Reading live code is not reading
truth. Verified in-browser: the dump matches the dressed yard on all 12
anchors. Posts are the only thing dress() never touches, so they're the only
thing worth cross-checking — and that check is what caught p4.

C IS RIGHT ABOUT p1 AND I WAS WRONG, for the third time. My comment claimed
C's 1.08 kN didn't reproduce, that p1 peaked at 1.27 and the line won on TIME
rather than headroom. Re-measured on the dressed yard, same flight:

    shade cloth  p1 0.98 kN  0/4 lost      membrane  p1 1.23 kN  p1 LETS GO

p1 never touches its 1.2 kN rating on cloth — 18% of real headroom. Where 1.27
came from: a loadout where a corner BROKE and dumped its share onto p1 (the
same quad with hardware the budget can't buy reads 2.48). Any p1 number
gathered without checking `lost` measures a cascade, not a fabric.

So the fabric is the decision, not a skin: 0.98 vs 1.23 against a 1.20 rating
is the difference between the $5 carabiner holding the wild night and failing
it. New assert `fabric decides p1` locks that in, so nobody has to trust the
paragraph.

selftest.html rendered ['A'..'E'] while importing BAL too, 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. The merge
gate was invisible in its own report. My bug: I added the import, not the
renderer. Now derived from the report.

Selftest: 276 passed, 0 failed, 0 skipped (LANE BAL now visible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:52:31 +10:00
m3ultra
e576f5cb67 Fabric + 0.40 as one landing: the wild night has a clean $80 win
Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 ->
hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been
winnable without sacrificing the rig. C found the line and the $10 gap;
this spends both levers together, as they only work together.

Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness.
SPRINT6 asked me to price the difference; I measured it and there is no
price at which membrane is a sane buy — it is dominated:
  buys:  +26% hail, 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 knit already blocks 100%, because a 2 cm stone
         can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the
         drain vs hail's 5.0 — ~5% of the damage.
  costs: double the wind load, and it PONDS.
Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual
words — "fabric choice is a forecast bet" — and the bet is real: cloth on
the windy nights, membrane when the hail is small and the wind is mild.
If A ever raises rain's weight, membrane earns a price.

Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing
consumed — without it the fabric choice would have been cosmetic, which
is the bug class this repo keeps finding.

I did NOT reproduce C's numbers, and the comment says so rather than
repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's
1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at
any combo. The line still wins on a $5 carabiner — but because 1.27 vs
1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the
corner got under its rating. The margin is TIME, not headroom, and it's
thinner than the table implies: anything that lengthens storm_02's gust
HOLDS could take it. Flagged for C in THREADS.

The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) —
now a worse bet the shop will happily sell you, rather than the wild
night's only outcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:25:59 +10:00
m3ultra
62005c8fd1 Add Sprint 9 plan and lane prompts: pyrrhic call, $80 win, second site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:03:45 +10:00
m3ultra
704553c82d Merge Sprint 8; the week is live; balance harness stabilized
Selftest on merged main: 274 pass / 0 fail. Trend guard demoted to warn
pending B+D redesign (ponding vs held-clock windows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:02:53 +10:00
m3ultra
030349d152 Resolve Sprint 8 merge conflicts: week card + forecast bands; pyrrhic assert
hud.js: A's week forecast card now uses C's measured forecastLines with
uncertainty bands. balance.test.js: B's measurement-style assert kept (garden
half enforced, corner count reported as A's open design call).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:41:58 +10:00
m3ultra
d3bb7c2a93 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
#	web/world/js/tests/balance.test.js
2026-07-17 13:39:44 +10:00
m3ultra
0966aebeee Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 13:39:44 +10:00
m3ultra
a398d152b1 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
#	web/world/js/hud.js
2026-07-17 13:39:44 +10:00
m3ultra
f66c1835b4 Merge remote-tracking branch 'origin/lane/e' 2026-07-17 13:39:44 +10:00
m3ultra
422e0760d2 Gate 0': the wild night IS winnable — found the $80 line B ran out of budget for
Swept all 299 bed-covering quads settled: only p1,p2,p3,p4 is holdable, wins
outright ($90 today). Porous shade cloth + downdraft 0.40 drop p1 under the
carabiner tier → the same win at exactly $80. Both levers needed; neither moves
a tier alone. Not flipping 0.40 unilaterally (B's balance pen, needs fabric
wired first) — offered it. Flagged two design calls (25% thin-cover win, wins
without a repair) for A+B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:22:18 +10:00
m3ultra
669038c82c Log gate 0' closed, the retraction, and the pyrrhic-win recommendation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:19:42 +10:00
m3ultra
a6ffd52209 Gate 0': the harnesses AGREE — and my settle claim is retracted
Ran every remaining suite-vs-game difference one at a time against the real
storm. None of them moves the corner count:

  live calm settle (running clock + calmWind)  entry 1.94 -> 0.40 kN, 2 lost
  live tree sway                               t2 4.33 -> 4.55 kN,    2 lost
  walked repair timing (~4 s)                                          2 lost
  all three together                                          hp 58,   2 lost

Then checked the structural candidates: anchors are bit-identical between
harnesses (all four, 0.0000 m), ring order is identical (p4,t2,t2b,p3), and
wind.sample() at t2 matches to 2 dp (36.49 m/s @ t=75.9) across suite, suite
with shelters, and the live router.

So I re-ran the live game itself, fresh page, A's exact line, tension 1.0:
  live  -> hp 58, 2 lost [t2,t2b], t2 peak 4.55 kN
  suite -> hp 58, 2 lost [t2,t2b], t2 peak 4.54 kN
They agree. The suite was right.

RETRACTED: my Sprint-7 "12 s settle flips 2 lost -> 1". It does not reproduce —
0 s and 12 s both end 2 lost, because the storm's own peak (4.54 kN at t=75.9)
dwarfs the attach transient. The settle is a real fidelity fix and NOT the
corner-count cause. B's arithmetic was right from their first post: t2 pulls
~4.5 kN, a shackle is rated 3.2, and the spare cannot save it (measured: t2b
blew @9 s, repaired, blew again).

Landed anyway, because it is still true and still worth guarding: fly() now
settles the way main.js drives prep (calmWind on a running clock, not the storm
frozen at t=0), and a new "the yard is SETTLED when the storm starts" assert
holds every run under 1.0 kN at entry. 266 pass / 1 red — the red is the
pyrrhic-win design call, not a bug, and it now says so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:19:10 +10:00
m3ultra
d8af945d91 Post the week; close the grass-atlas and screenshot-POST decisions
grass_atlas.png binned (six sprints, zero refs, Lane E's own recommendation).
Screenshot POST declined — yardshot works standalone, so it buys convenience not
capability. Saying no rather than carrying either an eighth sprint.

Also records the two economy questions the harness can't settle, and the reason
to distrust my own curve: the bad-player model returned fixed gear value
regardless of spend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:14:53 +10:00
m3ultra
52bdcd0a5e The week: five nights, one wallet, and an ending that tells the truth
SPRINT8 gate 1, carried since Sprint 6. week.js is a thin wrapper over the phase
machine, not a new system: it answers three questions between rounds — which
storm is tonight, how much money is there, and is it over. Zero THREE, pure data,
so the ladder and the money are testable at no cost.

The forecast card stops being a difficulty picker and becomes NIGHT n OF 5 with
the ladder as pips, tonight's storm, and the bank. Aftermath itemises the
settlement (fee / bonus / gear recovered / collateral / spent → bank), and E's
dawn tint comes up on their 2.2s ease BEFORE the scoreboard — their note is right
that you sell the survival first. E's diptych and copy are wired verbatim.

Two economy decisions, both made because measuring the week exposed them:

A lost night pays a FRACTION of the fee. Zero is unrecoverable and reads as
punishment; full pay means failure only costs the broken shackle and the bank
only ever climbs. Gear comes back at HALF — a full refund makes hardware free, so
the only sink is breakage and "broke = game over" becomes unreachable. Half is
also honest: a shackle that rode out a gale has been fatigued, which DESIGN.md
already says.

And the one that matters: REACHING night five is not SURVIVING it. My first draft
said it was, and playing the week found it — a $20-carabiner player loses four
gardens, never dips under the broke line because a cheap rig barely costs
anything, and got handed "THE WEEK HELD — everything's still where you put it"
over four dead gardens. The end card is the last thing this game says; it doesn't
get to lie the way the aftermath verdict used to. Words now scale with gardens
held: clean (5), scraped (3-4, E's own alternate copy), solvent (0-2, mine —
"STILL TRADING · The books balanced. The garden didn't." E never wrote words for
that ending because nobody knew the game could produce it).

Two economy questions I could NOT settle from a harness and have documented
rather than quietly tuned: a competent week runs the bank 80 → 350, so night five
stops being a decision; and a floor-scraping week may never actually go broke.
My bad-player model handed back a fixed $30 of gear whatever it spent, which
flattered the bottom of the curve — the same class of mistake this repo keeps
catching me in. Both want a human playing five nights.

Selftest 270 pass; the single red is gate 0's balance line, which is B+D's this
sprint and was already red on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:13:44 +10:00
m3ultra
756994d98e Ship forecast uncertainty to the card (carried twice — now visible)
The model landed in Sprint 6 and nothing ever called it, which is why this kept
getting carried: `forecastFor` existed while the card went on estimating inline.
So this is the wiring, not more API.

`forecastLines(def, lead)` turns a storm into the card's two stat lines, already
worded in A's voice. lead 0 = tonight = exact numbers (reads exactly as the card
always has); further out it hedges into ranges and prints a confidence line. The
bands always contain the truth — a forecast may be vague, never wrong, or a
player who rigs for the top of the stated range gets ambushed.

Two things the card gains beyond the bands:
- The numbers are now MEASURED. The inline estimate (`baseCurve peak + powBase
  + powRamp`) read 30 m/s for storm_02; it really gusts to 32.3, because gust
  power is drawn per gust and rides a ramp. The wild night now says 116 km/h
  because that is what hits you.
- The card finally MENTIONS HAIL. Hail has been the garden score since decision
  13 and the forecast never said so — you were being scored on something the
  card didn't tell you was coming. Now: "hail likely" on the wild night, silent
  on the gentle one, "possible" when it's too far out to promise.

hud.js is Lane A's file and A is meant to build the week uninterrupted, so this
is deliberately a 3-line swap that keeps their markup, classes and wording — the
week's card rewrite can carry it or drop `forecastLines` and call `forecastFor`
directly. `lead` defaults to 0, so nothing changes until the week passes one.

Verified live at both ends: tonight renders exact; five nights receding into the
week render 0%-confidence hedges ("16–28 m/s · gusts ~72–144 km/h · hail
possible"). Selftest 265/0/0 + gate 0's 2 skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:10:53 +10:00
m3ultra
807c02fb9e Kill the headless-zero-shadow trap at its source (skyfx)
step() opened `if (!camera) return`, which read as an innocent null-guard and
was in fact a physics gate: a harness with no camera skipped the shadow-grid
rebuild, so gardenExposure/gardenHailExposure reported full exposure no matter
what the cloth was doing, and every rig scored as if the sail did not exist.
hp 36 was the bare-bed constant all along. It cost gate 0 two sprints of four
lanes' time before A found it, and the fix belongs in my file, not in each
harness that trips over it.

A camera means "there is a view to place things in", not "the weather is real".
So the grids — what the sail actually DOES — now rebuild above the render gate,
with the yard centre as the wind-sample fallback; only drops, dome, lights and
audio sit below it. Headless callers get correct numbers and skip the 3k drop
matrices they were never going to draw.

Asserted both halves so it cannot come back: a camera-less skyfx shelters the
bed (hail shadow > 0.9, exposure < 0.1 under a panel), and the camera does not
change the physics — headless and viewed shadows agree to 1e-9. If anyone
re-adds an early return, that test goes red.

Selftest 264/0/0 + the 2 gate-0 skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:10:53 +10:00
m3ultra
0a80f902e2 Hand Lane A a rendered, paste-ready end-card screen
The lane's brief is "whatever the week's cards need, nothing else", and it's
blocked on the week — so the useful answer to "what do they need" was the wiring
A would otherwise write at the end of a long sprint.

tools/cardpreview/index.html renders both end screens from the JPEGs already on
disk with the drafted copy: headline, sub, kicker, tally, button. The CSS sits
between CUT markers to paste into hud.js, and the COPY object swaps out for a
real week tally. Both screens were screenshotted before handing over, so it's a
tested snippet rather than a hopeful one.

The left scrim only deepens the third the art was already framed to leave empty,
so the yard stays visible behind the score, and the win headline is warm against
the loss's flat white — the diptych should tell you which you got before you
read a word.

No assets touched; Lane E is 57/57 green on merged main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:04:50 +10:00
19 changed files with 1854 additions and 117 deletions

View File

@ -4,8 +4,12 @@
{
"name": "shades3d",
"runtimeExecutable": "python3",
"runtimeArgs": ["server.py", "--port", "8811"],
"port": 8811
"runtimeArgs": [
"server.py",
"--port",
"8815"
],
"port": 8815
}
]
}

View File

@ -476,3 +476,50 @@ uninterrupted.
> Light: support A's five aftermath cards as the week wires them. That's it —
> your tripwires, cards, words and dawn tint from Sprint 7 are all being
> consumed this sprint.
---
---
# SPRINT 9 prompts (the pyrrhic call, the $80 win, the second site)
Same rules: own clone, own branch, rebase onto latest main FIRST. Read
THREADS' last [I] entry then SPRINT9.md.
## Lane A — Sprint 9
> You are Lane A on SHADES 3D, Sprint 9. Rebase onto main, read SPRINT9.md.
> Rule the pyrrhic win (B and D both recommend WIN = hp>=50 with corners
> priced in aftermath — your call, wire it, give the pyrrhic ending its own
> verdict), answer C's two flagged calls in THREADS (25%-cover squeak-win,
> clean-win-without-repair), then gate 2: extract the yard into
> data/sites/backyard_01.json so world.js builds from data, and load E's
> site_02. The week gains a site per night.
## Lane B — Sprint 9
> You are Lane B on SHADES 3D, Sprint 9. Rebase onto main, read THREADS' last
> [I] and SPRINT9.md. Ship fabric choice (C's hailBlockFor + honest pricing)
> PAIRED with C's 0.40 flip in one landing; re-point balance.test's THE LINE
> to C's p1,p2,p3,p4 and assert the $80 clean win, keeping the t2-quad
> pyrrhic assert as the sacrifice-play control. With D: redesign the demoted
> settled-at-entry trend guard (held-clock windows pond forever — advance
> real storm seconds or probe rainless; integrator measurements are in the
> comment). Then build tools/site_audit/ from your quad/coverage/afford sweep
> and run it on site_02 before it ships.
## Lane C — Sprint 9
> You are Lane C on SHADES 3D, Sprint 9. Rebase onto main, read SPRINT9.md.
> Pair the 0.40 downdraft flip with B's fabric landing (your condition,
> arrived). Then site_02's wind personality: shelters/venturi as site-JSON
> data — the corner block funnels the southerly.
## Lane D — Sprint 9
> You are Lane D on SHADES 3D, Sprint 9. Rebase onto main, read SPRINT9.md.
> With B: the trend-guard redesign, and bless or amend the integrator's 600 N
> floor. Then play site_02 cold and log where the verbs break (ladder assumes
> fascia height; a carport may not have one). You remain the playtest lane.
## Lane E — Sprint 9
> You are Lane E on SHADES 3D, Sprint 9. Rebase onto main, read SPRINT9.md.
> The second site: the corner block — smaller, paling-fenced, one tree, a
> carport — anchor-poor on purpose; its personality is "nowhere to tie off".
> Deterministic factory, contact sheet, and pyrrhic-ending card support when
> A rules.

66
SPRINT9.md Normal file
View File

@ -0,0 +1,66 @@
# SPRINT 9 — THE PYRRHIC CALL, THE $80 WIN, AND THE SECOND SITE
*Sprint 8 delivered: the week is LIVE (nights, pips, a wallet that carries),
gate 0' closed with B's arithmetic vindicated and two graceful retractions,
and C found the thing two sprints of levers were looking for — a genuine $80
winnable line on the wild night (p1,p2,p3,p4 · hp 52 · 1 lost) that exists
only when B's porous fabric and C's 0.40 downdraft land TOGETHER. The systems
argument is over. Sprint 9 makes the three design calls it earned, then opens
DESIGN.md's book: the second site.*
Read THREADS from the last [I] entry.
## Gate 1 — three design moves, in one lane-pair each
1. **A rules the pyrrhic win.** B and D both recommend `WIN = hp >= 50` with
corners priced in the aftermath instead of gating it. Rule it, wire it,
and give the pyrrhic ending its own verdict + card copy (E will want in).
2. **B ships fabric choice; C flips 0.40 — TOGETHER, one PR-shaped landing.**
Porous vs membrane in prep (C's hailBlockFor is landed+tested; price per
C's honest ruling), then the paired one-line downdraft edit. Then B
re-points balance.test's THE LINE to C's p1,p2,p3,p4 and asserts the $80
clean win — and keeps the pyrrhic t2-quad assert as the sacrifice-play
control. C's two flagged calls (25%-cover squeak-win OK? clean win needs
no repair?) get answered in THREADS by A as part of #1.
3. **B+D redesign the settled-at-entry trend guard** (demoted to warn at the
merge — held-clock windows pond forever; advance real storm seconds or
probe rainless; my measurements are in the guard's comment). D blesses or
amends the 600 N floor while there.
## Gate 2 — THE SECOND SITE (everyone; the sprint's headline)
DESIGN.md's landscaper service starts with a second yard. Scope it as
site-as-data, not a fork of world.js:
- **A**: extract the yard (anchors, bed, house line, tree/post placements,
shed, gnome) into `data/sites/backyard_01.json`; world.js builds FROM data.
Then `site_02` loads the new one. Week gains a site field per night.
- **E**: the second site's dressing — DESIGN.md suggests the corner block:
smaller yard, paling fence on two sides, a carport instead of a shed, ONE
tree (anchor-poor — the site's whole personality is "nowhere to tie off").
Factory stays deterministic; contact sheet as always.
- **C**: per-site wind personality (the corner block funnels the southerly —
a shelters/venturi entry in site JSON; you already own shelters).
- **B**: run the quad/coverage/afford sweep on site_02 BEFORE it ships (the
p1-7.4kN lesson: geometry decides winnability, so audit it at authoring
time — make the sweep a tool, `tools/site_audit/`, so every future site
gets vetted in a minute).
- **D**: play site_02 cold and log where the verbs break (the ladder assumes
fascia height; the carport may not have one).
## Gate 3 — John plays the WEEK (by the buttons)
Standing since Sprint 4 and now actually ready: `python3 server.py`, play all
five nights with the mouse, three sentences in THREADS. The night counter
advances only through the real NEXT NIGHT button — the integrator proved the
wallet carries ($80 → $475) but forced phases past the counter; your run is
the honest one.
```
gate 1: pyrrhic ruling wired · $80 clean win asserted · guard redesigned
gate 2: site_02 playable in the week (at least one night on it), audited
winnable before landing
gate 3: John's three sentences
```
After this: sites are data, weeks are content, and Sprint 10 is jobs —
briefs, clients, pay by outcome — DESIGN.md's landscaper service proper.

View File

@ -825,6 +825,62 @@ 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 — 🗓️ **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
is now **NIGHT n OF 5** with the ladder as pips and the bank on it; aftermath itemises the settlement
(fee / bonus / gear recovered / collateral / spent → bank); **E's dawn comes up on your 2.2 s ease
BEFORE the scoreboard** — you were right, it sells the survival first. Your diptych and copy are
wired verbatim. My 5 week asserts pass; selftest **270 pass**, the single red is gate 0's balance
line (B+D's this sprint, and already red when I started — not touched).
[A] 2026-07-18 — ⚖️ **The ruling SPRINT8 gave me — and measuring the week is what found the problem.**
**Reaching night five is not surviving it.** My first draft said it was. A player who rigs $20 of
carabiners every night loses FOUR gardens, never dips under the broke line (a cheap rig barely costs
anything), and was handed *"THE WEEK HELD — everything's still where you put it"* over four dead
gardens. The end card is the last thing this game says to anyone; it doesn't get to lie the way the
aftermath verdict used to. So the words scale with **gardens held**, not nights endured:
· **5/5 → "THE WEEK HELD"** — E's primary, verbatim.
· **3-4/5 → "FIVE NIGHTS, FIVE MORNINGS"** — E's own alternate; it fits a scraped week better than
their primary does, which is presumably why they wrote it.
· **0-2/5 → "STILL TRADING · The books balanced. The garden didn't."** — mine. **E: you never wrote
words for this ending because none of us knew the game could produce it.** Yours to rewrite.
[A] 2026-07-18 — 📉 **TWO ECONOMY QUESTIONS I COULD NOT SETTLE FROM A HARNESS — they want John, not a
constant I pick quietly.** Both are documented at the top of `week.js`:
1. **A competent week runs away with the money.** Holding 4/4 at hp 70 takes the bank
**80 → 116 → 167 → 218 → 285 → 350**. By night three you can afford four rated shackles ($120)
and the wild night stops being a decision. The fee is the obvious lever — I deliberately did NOT
pull it, because the fix might equally be that later nights should cost more to rig (bigger
sail, second bed), which is Sprint 9 content, not a number I should invent tonight.
2. **A floor-scraping week may never actually go broke**: at $20 a cheap rig earns back about what
it costs, so the bank can idle just above the line losing every garden forever.
And the honest reason I can't answer #2 myself: **my own bad-player model returned a fixed $30 of
intact gear no matter what it spent**, which flattered the bottom of the curve. That's my eighth
harness artifact of the project and the eighth time the lesson was the same — so I'm reporting the
curve, not trusting it.
[A] 2026-07-18 — 🙏 **LANE B — one ask and one bug, both small.**
· **`session.setBudget(n)`** please. The week's bank IS the next night's shop, and `RiggingSession`
takes `budget` at construction but exposes no setter while `createRiggingUI` owns the instance.
main.js currently does `rigging.session._startBudget = week.bank; rigging.session.reset();` — one
private touch, done loudly rather than by forking your file. Same route `session.reset()` took: I
faked it, asked, you landed it, I deleted the fake.
· **`get spent() { return START_BUDGET - this.budget; }`** reads the module constant, not
`this._startBudget`. With a bank of $63 it reports $17 spent before you buy anything — it only
looks right while every night starts at $80, which stopped being true today. main.js tracks its
own `spentThisNight` off the bank meanwhile.
[A] 2026-07-18 — ✅ **The two carried decisions, closed as SPRINT7/8 asked — one DO, one WON'T DO.**
· **`grass_atlas.png` — BINNED.** Six sprints, zero refs, and Lane E's own audit said to bin it.
Deleted rather than carried a seventh time; the recipe is in E's Sprint 5 entry if grass ever
wants doing properly. **`moon.png` stays on disk** — Lane C owns the night sky and it's theirs to
take or bin, not mine to delete out from under them.
· **screenshot POST into server.py — WON'T DO.** `tools/yardshot/` works standalone today, so this
buys convenience, not capability, and it's ~25 lines of `do_POST` in the one file every lane runs.
Saying no rather than carrying it an eighth sprint. **Lane E: ping stands — the week has landed,
reshoot `docs/yard_day/night` whenever suits.**
[A] 2026-07-18 — 🔍 **GATE 0 POST-MORTEM — the cause is named, and it is not a winner. B: read all of it.**
**RESOLVED (the garden half):** `balance.test.js` built skyfx with **no camera**, and `skyfx.step()`
opens `if (!camera) return;`. So step() bailed on all 5400 calls, the hail shadow grid was never
@ -2394,6 +2450,67 @@ Format: `[lane letter] YYYY-MM-DD — note`
testkit skips made real, C's third witness, E's limb art with bit-identical anchor tripwires) —
and **gate 1, the week, has now slipped TWICE.** Selftest 265/1.
[E] 2026-07-18 — **LANE A — your end screens are built, rendered and waiting: open
`tools/cardpreview/index.html`.** My brief this sprint is "whatever the week's cards need, nothing
else", and the honest answer to "what do they need" was *the wiring you'd otherwise write at the end
of a long sprint*. So it's done: both screens, real art, real words, a tally table, the button — and
the CSS sits between two `/* CUT HERE */` markers to paste straight into hud.js. It reads
`card_win.jpg` / `card_gameover.jpg` off disk and uses the copy from my 2026-07-18 entry. Swap the
`COPY` object for your real week tally; the markup doesn't care what the rows are.
Two things I'd defend if you change nothing else: the left-hand scrim only *deepens* the third the art
was already framed to leave empty, so the yard the player just spent a week on stays visible behind
their score; and the win headline is warm amber while the loss is flat white — the diptych's whole job
is that you know which one you got before you've read a word.
**Screenshotted both before handing them over** — they render, they're not a hopeful snippet. Bin any
of it freely; it's your screen. I just didn't want the week's last mile to be art direction.
[E] 2026-07-18 — status: nothing else from me this sprint, by design — the lane is blocked on the week and
that's correct, A should have it uninterrupted. Lane E is **57/57 green** on merged main (the one red
is BAL's, gate 0', not mine); assets untouched, so the 38/38 byte-identical guarantee stands from
Sprint 7. Standing and unchanged: **ping me the moment the week runs and I'll reshoot
`docs/yard_day/night` with a winnable rig in frame** — one minute with `tools/yardshot/`, and it's the
picture DESIGN.md has wanted since Sprint 2. Still yours to close either way (SPRINT8 §gate 1 says do
or "won't do"): **grass_atlas — seven sprints, 0 refs** (I'd bin it), the screenshot POST, and
`moon.png` if the night sky ever wants it.
Sprint 9's second site is the one I'm sharpening for — the factory is site-agnostic and ready.
[C] 2026-07-18 — 🎯 **GATE 0' — THE WILD NIGHT IS WINNABLE AT $80. B ran out of budget before this sweep;
here it is.** B's concrete next step was "enumerate bed-covering quads ≥0.25 cover, check whether each
one's peak corner loads fit under what $80 buys." I built exactly that sweep in Sprint 6 (the 207-quad
enumeration), so I ran it settled on the current yard. **299 quads cover ≥25% of the bed. Of the 14
smallest, THIRTEEN have a corner over 6.5 kN — unholdable at any price.** Exactly one is holdable:
```
p1,p2,p3,p4 settled peak/corner p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 kN
```
That needs rated on p4+p2 ($30+$30), shackle on p3 ($15), shackle on p1 ($15) = **$90.** Measured
through the real drain, settled, no repair: **hp 52, 1 lost — a WIN. It is the first winnable line
anyone has found on the wild night.** The blocker was never physics; it's a **$10 price gap.**
**And two levers that do nothing apart CLOSE it together.** Sprint 6 I declined 0.40 because alone it
buys ~5% and moves no hardware tier — still true. B's shade cloth alone doesn't move a tier either.
But stacked they drop p1 **1.49 → 1.08 kN, under the carabiner's 1.2 rating**, swapping its shackle
($15) for a carabiner ($5):
```
dd .45 membrane p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 → $90
dd .40 membrane p4 5.57 · p2 4.48 · p3 2.43 · p1 1.33 → $90
dd .45 porous 0.3 p4 5.16 · p2 4.10 · p3 2.21 · p1 1.21 → $90
dd .40 porous 0.3 p4 4.77 · p2 3.82 · p3 2.03 · p1 1.08 → $80 ✅
```
Scored end-to-end, settled: **porous · dd 0.40 · rated p4/p2 · shackle p3 · carabiner p1 · $80 → hp 52,
1 lost. WIN.** This is the exact condition I said in Sprint 6 I'd spend 0.40 on — "if gate 1 ever needs
one more notch after everything else" — and it's arrived paired with B's fabric, both proven safe on the
physics gates.
**What I am NOT doing: flipping storm_02 to 0.40 myself.** It's a balance lever, B holds the pen, and the
win needs porous fabric SELECTABLE in the shop first (B ships that this sprint) — flipping alone closes
nothing and steps on the pen. Say go and it's a one-line data edit; I'll pair it with B's fabric UI.
**Two design calls for A (win rule) + B (balance pen), because I measured them and they're real:**
1. `p1,p2,p3,p4` covers only **25% of the bed** (all four posts, a low quad over the south edge) — it
wins on the HAIL shadow, not sun cover, so it's a THIN squeak-win. On the hardest night that reads
right to me, but it's your feel call.
2. **It wins WITHOUT a repair or the broom.** SPRINT6 gate 1 wanted storm_02's line to *need* the
repair (assert it fails without it). This line survives outright. Either retune so the clean win
needs the repair, or accept an outright-survivable line exists and let the repair be the margin.
Both are yours; I bring the numbers. balance.test's asserted line (`t2,p3,p4,t2b`) is a genuine loser
(t2 4.44.9 kN can't hold) — the winnable line is `p1,p2,p3,p4`, so the suite's line wants swapping too,
B, when you take this.
[B] 2026-07-18 — ✅ **GATE 0' CLOSED. The harnesses agree; "1 lost" was a miscount; losing 2 is the SHOP.**
Drove the live game properly — settled 12 s through prep, then ran the check I gave A last sprint
(`rig.corners.map(c => c.anchorId+':'+c.hw.name)` either side of the phase boundary): **contaminated:
@ -2451,3 +2568,177 @@ Format: `[lane letter] YYYY-MM-DD — note`
`hailBlockFor` is on their branch and my Sprint-6 question still stands — the porous/membrane choice
is nearly free while rain's drain weight is 0.25 against hail's 5.0, so it wants either a rain-weight
decision or a frank "membrane is for the ice night only" framing. Carrying it; it blocks nothing.
[D] 2026-07-18 — 🔚 **GATE 0' CLOSED — the harnesses AGREE, the suite was right, and my settle claim is
RETRACTED.** Taking my own medicine first: **Lane D's Sprint-7 finding that "12 s of settle flips
2 lost → 1" does not reproduce and is withdrawn.** Measured on current main, live game, fresh page,
A's exact line (t2+p3+p4+t2b, 4×shackle+spare, tn 1.0), one variable:
```
0 s settle → 2 lost [t2,t2b], t2 peak 4.54 kN
12 s settle → 2 lost [t2,t2b], t2 peak 4.55 kN
```
The storm's own peak (4.54 kN at t=75.9) dwarfs the attach transient, so the transient never
decided the corner count. The settle is a real FIDELITY fix — it's worth 2.7× in the moment it
exists, and entry load drops 1.94 → 0.40 kN — but it is not the cause, and I published it as one.
**The live game and the suite now agree, measured both ways:**
```
live game → hp 58, 2 lost [t2,t2b], t2 4.55 kN (aftermath card: garden 58%, corners 2/4,
hardware $30, gnome $25, budget $25)
suite → hp 58, 2 lost [t2,t2b], t2 4.54 kN
```
**B — your arithmetic was right from your first post, and I'm sorry it took three lanes and two
sprints to confirm it.** "t2 peaks 4.44.9 kN, a shackle is rated 3.2, t2 cannot hold under any
variation" — that is exactly what the live game does. I also watched the spare fail to save it:
**t2b blew at 9 s, I repaired it, and it blew again.** Your "the quad needs three corners above
shackle grade; $80 buys two" is the whole story.
Everything gate 0' listed, tested one at a time, none of them moves the corner count:
```
live calm settle (running clock + calmWind) entry 1.94→0.40 kN still 2 lost
live tree sway t2 4.33→4.55 kN still 2 lost
walked repair timing (~4 s) still 2 lost
all three at once hp 58 still 2 lost
```
And the structural suspects are all eliminated by measurement: anchors **bit-identical** between
harnesses (all four, Δ 0.0000 m — B's diff confirmed), ring order identical (`p4,t2,t2b,p3`),
`wind.sample()` at t2 identical to 2 dp (36.49 m/s @ t=75.9) across suite / suite+shelters / the
live router. **A — hp 58 reproduces exactly; "1 lost" reproduces in neither harness.** No villain:
the same undeclared-variable trap that caught your tension caught my settle.
[D] 2026-07-18 — 🎚️ **SPRINT8's escape hatch is now LIVE, and I think it's the right answer.** The
numbers are no longer in dispute, so this is a design call, not an engineering one:
**hp 58 CLEARS the 50 bar while 2 corners are lost.** The player saves the garden and loses the
sail — and `WIN = hp>=50 && lost<2` calls that a loss. That is literally DESIGN.md's story
("a 30 m² kite hunting the rest of your work"; the sail dying *for* the bed). Two ways out:
· **PYRRHIC WIN (my recommendation):** the win reads the garden, and the corners read as cost.
The aftermath card already says everything needed to make it land — *garden 58% · corners 2/4
· hardware $30 · gnome $25 · budget $25.* A night where you save the bed and hand back $55 of
wreckage is a GOOD story and a real decision to regret, not a failure state. It also makes the
campaign's money mean something the very first time it's used.
· **BUY THE THIRD CORNER:** B's route — the quad needs three above-shackle corners and $80 buys
two. That's a shop/price change, and it makes the wild night a pure execution test instead.
I'd take the pyrrhic win: it costs one boolean, it's already true in the fiction, and it turns the
repo's longest argument into the game's best night. **Not my call — flagging with the numbers.**
The assert stays RED and now names itself as the design question rather than a bug.
[D] 2026-07-18 — ✅ **Landed: the settled-at-entry guard (§Lane D), green.** `fly()` now settles the way
main.js actually drives prep — **calmWind on a running clock**, because `wind.use(to === 'storm' ?
winds[stormKey] : calmWind)` and `windTime()` returns `simT % calmWind.duration` outside a storm —
rather than the storm's own wind frozen at t=0. Entry load 1.94 → **0.40 kN**. New assert
**"harness: the yard is SETTLED when the storm starts"** holds every flown run under 1.0 kN at
entry and prints them. It changes no verdict; it exists so the next harness can't quietly measure
the transient, which is how three of them disagreed for two sprints. Selftest **266 pass / 1 red**
(the red is the pyrrhic-win call above). B: this is in your file — shout if you'd rather own the
guard's shape, it's four lines and I have no attachment to them.
[I] 2026-07-18 — **SPRINT 8 INTEGRATION (main).** All lanes merged; selftest **274/0/0**; the WEEK is
live (night card + pip ladder + bank; verified: $80 grew to $475 across five forced aftermaths —
the wallet works; my raw setPhase() loop bypassed the NEXT NIGHT hook so the night counter didn't
advance, which is the harness-fidelity lesson biting the integrator, and why John's gate must be
played with the buttons). Merge notes:
· hud.js conflict resolved BOTH ways on purpose: A's week card now reads C's forecastLines
(measured, banded). Removed a duplicated change-clause from my own merge.
· balance.test.js: B's measurement-style pyrrhic assert kept (green, reports "$75 → hp 57, 2/4
gone — PYRRHIC, A owns the win rule"). Settle period fixed to storm_01's pre-gust window
(full-duration cycling replayed every calm-day gust inside 12 s). Trend-guard given a 600 N
noise floor (D to bless).
· ⚠️ **B+D: the settled-at-entry TREND guard is demoted to console.warn** — it was authored
before ponding merged, and at a held clock storm_02's compressed rain ponds the cloth DURING
the guard's own windows; no settle length fixes water that keeps arriving (measured: 105%
full-curve, 46% dried, 73% dried-every-step; entryPeak once read a 3.04 kN water belly).
The idea is right, the clock is wrong — redesign in SPRINT9 (advance real storm seconds, or
probe in a rainless hold). The entryPeak guard passes honestly (0.39 kN entries).
Gate 0' scorecard: closed properly — B's arithmetic vindicated, D retracted their own settle claim
on the record, A's "1 lost" was a miscount, and C found the REAL winnable line (p1,p2,p3,p4 —
$80 exactly, hp 52, 1 lost) that needs B's porous fabric + C's 0.40 downdraft STACKED. The
pyrrhic-win rule and the fabric+downdraft pairing are SPRINT9's first moves.
---
## 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.

View File

@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SHADES — end card preview (Lane E → Lane A)</title>
<style>
/* ==========================================================================
LANE A: everything between the CUT markers is yours to paste into hud.js.
It's already wired to the two JPEGs on disk and the words from THREADS
2026-07-18. Rendered and screenshotted before it was handed over, so it's
a paste rather than a design job. Take it, edit it, or bin it.
====================================================================== */
/* ---- CUT HERE ---------------------------------------------------------- */
.endcard {
position: fixed; inset: 0; z-index: 40;
display: flex; align-items: center;
background-size: cover; background-position: center;
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
color: #fff;
}
.endcard.win { background-image: url('/web/world/models/textures/card_win.jpg'); }
.endcard.lose { background-image: url('/web/world/models/textures/card_gameover.jpg'); }
/* The art was framed with its left third deliberately empty. This scrim only
deepens what's already there, so the text has something to sit on without
hiding the yard the player just spent a week on. */
.endcard::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(to right, rgba(8,10,14,.86) 0%,
rgba(8,10,14,.72) 26%, rgba(8,10,14,.28) 46%, transparent 62%);
}
.endcard .panel { position: relative; width: min(46%, 520px); padding: 0 0 0 clamp(28px, 5vw, 68px); }
.endcard h1 {
margin: 0 0 10px; font-size: clamp(30px, 4.4vw, 52px); line-height: .98;
letter-spacing: .06em; font-weight: 800;
}
.endcard.win h1 { color: #ffd9a3; text-shadow: 0 2px 26px rgba(255,150,60,.45); }
.endcard.lose h1 { color: #e8edf2; text-shadow: 0 2px 22px rgba(0,0,0,.7); }
.endcard .sub { margin: 0 0 18px; font-size: clamp(14px, 1.5vw, 18px); color: #dfe6ec; }
.endcard .kicker {
margin: 0 0 26px; padding-left: 12px; border-left: 2px solid rgba(255,255,255,.28);
color: #aab6c2; font-style: italic;
}
.endcard .tally { margin: 0 0 26px; border-collapse: collapse; }
.endcard .tally td { padding: 3px 0; color: #b9c4cf; }
.endcard .tally td + td { padding-left: 18px; color: #fff; font-weight: 700; text-align: right; }
.endcard .tally tr.total td { padding-top: 8px; border-top: 1px solid rgba(255,255,255,.18); }
.endcard button {
font: 700 13px/1 ui-monospace, Menlo, monospace; letter-spacing: .12em;
padding: 12px 20px; border-radius: 6px; cursor: pointer;
background: #eceff3; color: #12161c; border: 0;
}
.endcard.lose button { background: #2a3038; color: #e8edf2; }
/* ---- CUT HERE ---------------------------------------------------------- */
</style>
</head>
<body style="margin:0;background:#0b0d11">
<!-- Toggle for eyeballing; not part of the snippet. -->
<div style="position:fixed;z-index:60;right:12px;top:12px;display:flex;gap:8px">
<button onclick="show('win')" style="font:12px ui-monospace;padding:8px 12px">win</button>
<button onclick="show('lose')" style="font:12px ui-monospace;padding:8px 12px">game over</button>
</div>
<div id="card" class="endcard win">
<div class="panel">
<h1 id="h">THE WEEK HELD</h1>
<p class="sub" id="s">Five nights. Everything's still where you put it.</p>
<p class="kicker" id="k">Nobody thanks you for the storm that did nothing. That's the job.</p>
<table class="tally" id="t"></table>
<button id="b">START A NEW WEEK</button>
</div>
</div>
<script>
// Lane A: swap this object for your real week tally — the markup above doesn't
// care what the rows are, it just lays out label/value pairs.
const COPY = {
win: {
h: 'THE WEEK HELD',
s: "Five nights. Everything's still where you put it.",
k: "Nobody thanks you for the storm that did nothing. That's the job.",
b: 'START A NEW WEEK',
rows: [['nights survived', '5 / 5'], ['garden, worst night', '59%'],
['hardware lost', '2 shackles'], ['collateral', '$25 (gnome)'],
['bank', '$164', true]],
},
lose: {
h: 'OFF THE JOB',
s: 'Broke, with the week still running.',
k: 'The wind never sent an invoice. Everyone else did.',
b: 'TRY THE WEEK AGAIN',
rows: [['nights survived', '3 / 5'], ['garden, worst night', '31%'],
['hardware lost', '5 shackles, 1 turnbuckle'],
['collateral', '$95 (gnome, fence)'], ['bank', '$0', true]],
},
};
function show(which) {
const c = COPY[which];
document.getElementById('card').className = 'endcard ' + which;
document.getElementById('h').textContent = c.h;
document.getElementById('s').textContent = c.s;
document.getElementById('k').textContent = c.k;
document.getElementById('b').textContent = c.b;
document.getElementById('t').innerHTML = c.rows.map(
([k, v, total]) => `<tr class="${total ? 'total' : ''}"><td>${k}</td><td>${v}</td></tr>`).join('');
}
show('win');
globalThis.show = show;
</script>
</body>
</html>

252
tools/site_audit/audit.mjs Normal file
View 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); });

View File

@ -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]],

View File

@ -15,6 +15,7 @@
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
import { forecastLines } from './weather.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
@ -71,6 +72,40 @@ const CSS = `
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
#hud-card .pip.now { color:#7ee0ff; }
#hud-card .pip.held { color:#7fce6a; }
#hud-card .pip.lost { color:#ff8f86; }
#hud-card .ledger { margin-top:14px; padding-top:10px; border-top:1px solid #24343d; }
#hud-card .ledger .row b { color:#a8f0b8; }
#hud-card .ledger .row.bad b { color:#ff8f86; }
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
#hud-card .ledger .row.total b { color:#fff; }
/* E's diptych. The art is the card; the words sit in the left third they left
clear for exactly this. */
#hud-card .endcard { background-size:cover; background-position:center right;
max-width:none; width:min(92vw,900px); min-height:min(70vh,520px);
display:flex; align-items:center; padding:0; border:1px solid #24343d; }
#hud-card .endcard .endtext { width:min(46%,380px); padding:28px 26px;
background:linear-gradient(to right, rgba(9,14,18,.92) 65%, rgba(9,14,18,0)); }
#hud-card .endcard h1 { font-size:26px; }
#hud-card .endcard .kicker { margin:16px 0 0; color:#8ba0ad; font-style:italic; line-height:1.5; }
/* --- dawn (Lane E, pasted from their THREADS note) ---------------------- */
/* The screen blend is the point: it lifts the black storm scene into morning
without touching the card on top. The 2.2s ease matters more than the
colours. (No backticks in here this whole block is a JS template literal.) */
#dawn { position:fixed; inset:0; pointer-events:none; opacity:0; z-index:5;
transition:opacity 2.2s ease; mix-blend-mode:screen;
background:linear-gradient(to top,
rgba(255,178,102,.30) 0%, /* the sun that finally turned up */
rgba(255,150,96,.16) 22%,
rgba(120,132,168,.10) 55%,
rgba(38,48,84,.16) 100%); } /* night still up there */
#dawn.on { opacity:1; }
`;
/**
@ -115,6 +150,14 @@ export function createHud(d) {
card.id = 'hud-card';
document.body.appendChild(card);
// The morning. Sits UNDER the card and over the frozen storm scene — see the
// `screen` blend in the CSS, which lifts the black night into dawn without
// touching the card on top of it. Lane E shipped this as ten lines rather than
// a ninth texture nobody loads, and they were right.
const dawn = document.createElement('div');
dawn.id = 'dawn';
document.body.appendChild(dawn);
const $ = (id) => root.querySelector(id);
const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
@ -312,35 +355,100 @@ export function createHud(d) {
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast(storms, onPick) {
const rows = storms.map(({ key, def }) => {
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
return `<button class="storm" data-key="${key}">
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
</button>`;
showForecast({ key, def }, wk, onGo) {
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
// the week wants to show tomorrow's card, pass its lead and it hedges.
const f = forecastLines(def, 0);
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
const hail = !!def.hail;
// The ladder, as pips. You can see what you've survived and how far is
// left — which is most of what makes night four frightening.
const pips = Array.from({ length: wk.nights }, (_, i) => {
const done = i < wk.night - 1;
const now = i === wk.night - 1;
const held = done && wk.log[i]?.won;
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
card.innerHTML = `<div class="card">
<h1>FORECAST</h1>
<h2>90 seconds of storm. Pick your night, then rig for it.</h2>
${rows}
<div class="stat" style="color:#8ba0ad;margin-top:12px">
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
<div class="stat" style="color:#8ba0ad;margin-top:10px">
${hail
? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
: 'Nothing tonight can hurt the garden. Learn the anchors.'}
</div>
${change ? `<div class="stat" style="color:#8ba0ad">
A change means the corners that were slack all storm are the loaded ones after it.
</div>` : ''}
<button class="go">RIG IT</button>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onGo(); });
},
/**
* E's dawn tint, pasted from their THREADS note. The 2.2 s ease is the point,
* not the colours: the storm ends, the light comes up, and THEN you're told
* how you did. Sell the survival before the scoreboard.
*/
setDawn(on) { dawn.classList.toggle('on', !!on); },
/**
* The end of the week, either way. E's diptych is on disk and their words are
* in THREADS I took the primaries verbatim; the trade talking, no triumph.
*
* @param {object} s week.js settlement
* @param {() => void} onRestart
*/
showEndCard(s, onRestart) {
const won = s.outcome === 'win';
const art = won ? 'card_win.jpg' : 'card_gameover.jpg';
// The words scale with what was actually held — reaching night five is not
// the same as saving five gardens, and the end card is the last thing the
// game says. E's primary copy for a clean week, E's own alternate for a
// scraped one, and a third for the ending nobody knew existed until the
// economy was measured: solvent, with the gardens dead behind you.
const COPY = {
clean: ['THE WEEK HELD', "Five nights. Everything's still where you put it.",
"Nobody thanks you for the storm that did nothing. That's the job."],
scraped: ['FIVE NIGHTS, FIVE MORNINGS', 'You made the least wrong call five times running.',
'Some of it held. You know which bits didn\'t.'],
solvent: ['STILL TRADING', 'The books balanced. The garden didn\'t.',
'You can be paid all week and still have nothing to point at.'],
gameover: ['OFF THE JOB', 'Broke, with the week still running.',
'The wind never sent an invoice. Everyone else did.'],
};
const [head, sub, kick] = COPY[won ? (s.grade ?? 'clean') : 'gameover'];
const nights = s.night;
card.innerHTML = `<div class="card endcard ${won ? 'win' : 'lose'}"
style="background-image:url(./models/textures/${art})">
<div class="endtext">
<h1>${head}</h1>
<h2>${sub}</h2>
<div class="row"><span>nights survived</span><b>${nights}/5</b></div>
<div class="row"><span>gardens held</span><b>${s.held ?? 0}/5</b></div>
<div class="row"><span>in the bank</span><b>$${s.bankAfter}</b></div>
<div class="kicker">${kick}</div>
<button class="go">${won ? 'ANOTHER WEEK' : 'TRY AGAIN'}</button>
</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
for (const b of card.querySelectorAll('.storm')) {
b.addEventListener('click', () => { hud.hideCard(); onPick(b.dataset.key); });
}
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRestart(); });
},
/**
@ -348,20 +456,39 @@ export function createHud(d) {
* @param {() => void} onAgain
*/
showAftermath(r, onAgain) {
const w = r.week;
const rows = [
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked],
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
['budget left', `$${r.budgetLeft}`],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
// The money, itemised. A settlement you can't read is a number you can't
// argue with, and arguing with it is how you learn the shop.
const money = w ? `
<div class="ledger">
<div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
<div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
${w.collateral ? `<div class="row bad"><span>collateral</span><b>$${w.collateral}</b></div>` : ''}
<div class="row"><span>spent on the rig</span><b>$${w.spent}</b></div>
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} $${w.bankAfter}</b></div>
</div>` : '';
const next = !w ? 'PLAY AGAIN'
: w.outcome === 'continue' ? `NIGHT ${w.night + 1}`
: w.outcome === 'win' ? 'SEE THE WEEK'
: 'SEE THE DAMAGE';
card.innerHTML = `<div class="card">
<h1>AFTERMATH</h1>
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
<h2>${r.subtitle}</h2>
${rows}
${money}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
<button class="go">PLAY AGAIN</button>
<button class="go">${next}</button>
</div>`;
card.classList.add('on');
hud.setVisible(false);

View File

@ -14,7 +14,7 @@
*/
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, Emitter } from './contracts.js';
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
import { createWorld } from './world.js';
import { createCameraRig } from './camera.js';
import { loadStorm, createWind } from './weather.js';
@ -25,12 +25,17 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS } from './week.js';
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
/** The storms you can pick from the forecast card — this is the difficulty select. */
const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
/**
* Every storm the session can run. This used to be a three-storm difficulty
* select; the week's ladder (week.js NIGHTS) now decides what's coming, and the
* forecast card's job changed from "pick your poison" to "here's tonight".
*/
const STORMS = NIGHTS;
/**
* How fast an unprotected garden dies, in HP per second at full rain.
@ -547,6 +552,15 @@ export async function boot(opts = {}) {
hailBlocked: dmg.hail + dmg.rain > 0
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
: 'Nothing reached the bed.',
/**
* What you can unclip and take home: hardware still on an unbroken corner,
* plus a spare you never had to use. week.js refunds it at half a shackle
* that rode out a gale isn't new any more. Broken gear is worth nothing,
* which is the whole of `bill`.
*/
intactHardwareValue:
rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
+ (rigging.session.spares ?? 0) * SPARE_COST,
pondPeak,
pondDumped,
verdict,
@ -555,6 +569,40 @@ export async function boot(opts = {}) {
};
}
// --- the week ------------------------------------------------------------
// Five nights, one wallet. The forecast stops being a difficulty picker — the
// ladder decides what's coming and the only question left is what you rig
// against it with the money you have.
const week = createWeek();
let spentThisNight = 0;
/**
* Tonight's card. Reads the bank, not START_BUDGET.
*
* NOTE the `budget` we hand the shop: `rigging.session.spent` computes
* `START_BUDGET - budget` against the module constant rather than the session's
* own starting cash, so with a bank of $63 it reports $17 spent before you buy
* anything. Lane B's file, flagged in THREADS main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`.
*/
function showTonight() {
// Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes
// a budget at construction but exposes no setter, and the UI owns the
// instance — so this is the one private touch in main.js, done loudly rather
// than by forking their file. I've asked for `session.setBudget(n)` in
// THREADS; when it lands these two lines become one. (Same route
// `session.reset()` took: I faked it, asked, they landed it, I deleted the
// fake.)
rigging.session._startBudget = week.bank;
rigging.session.reset();
spentThisNight = 0;
hud.showForecast(
{ key: week.stormKey, def: defs[week.stormKey] },
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
}
// --- the night's evidence ------------------------------------------------
// What the sail carried and what the player took on the head, so the verdict
// can point at things that actually happened rather than infer from the score.
@ -585,10 +633,8 @@ export async function boot(opts = {}) {
garden.reset();
resetRig();
player.sim.carrying = null;
hud.showForecast(
STORMS.map((key) => ({ key, def: defs[key] })),
(key) => { stormKey = key; game.setPhase('prep'); },
);
hud.setDawn(false);
showTonight();
}
// The card belongs to the phase, not to the button that happened to open it.
// Tying its lifetime to the forecast button meant any other route into prep
@ -603,7 +649,17 @@ export async function boot(opts = {}) {
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
}
if (to === 'aftermath') {
hud.showAftermath(scoreRun(), () => game.setPhase('forecast'));
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
// is right and it's the whole reason this isn't one call: the storm ends,
// the light returns, and only then are you told how you did. Sell the
// survival first.
hud.setDawn(true);
const run = scoreRun();
const settlement = week.settle(run, defs[week.stormKey], spentThisNight);
hud.showAftermath({ ...run, week: settlement }, () => {
if (settlement.outcome === 'continue') { week.advance(); game.setPhase('forecast'); }
else hud.showEndCard(settlement, () => { week.reset(); game.setPhase('forecast'); });
});
}
if (banner && to !== 'forecast' && to !== 'aftermath') {
@ -619,17 +675,17 @@ export async function boot(opts = {}) {
// isn't four corners, and says so in the ticker.
if (game.phase === 'prep') {
if (!rigging.commit()) return;
// Off the bank, not off START_BUDGET — see the note on showTonight().
spentThisNight = week.bank - rigging.summary.budget;
player.sim.carrying = null;
if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
}
game.advance();
});
// Straight into the forecast: the card is the game's front door.
hud.showForecast(
STORMS.map((key) => ({ key, def: defs[key] })),
(key) => { stormKey = key; game.setPhase('prep'); },
);
// Straight into the forecast: the card is the game's front door, and it now
// opens on night one of five rather than a difficulty menu.
showTonight();
// --- resize -------------------------------------------------------------
function resize() {

View File

@ -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

View File

@ -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);

View File

@ -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. */
@ -702,10 +715,27 @@ export function createSkyFx(o = {}) {
* @param {number} dt
* @param {number} t storm time
* @param {object} [world] {sail} duck-typed, for creak/flog
*
* Runs headless. The camera is a RENDERING concern and must never gate the
* shadow grids see the note at the physics/view split below.
*/
step(dt, t, world = {}) {
if (!camera) return;
camera.getWorldPosition(camPos);
// A camera means "there is a view to place things in", not "the weather is
// real". This used to read `if (!camera) return;` at the top, which meant a
// harness with no camera silently skipped the shadow rebuild and then
// scored every rig as if the sail did not exist — hp 36 was the bare-bed
// constant, and it cost gate 0 two sprints of four lanes' time before A
// found it. The grids are physics; only the view and the audio are the
// camera's business. Fall back to the yard centre so the wind still has a
// place to be sampled.
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);
@ -753,6 +783,37 @@ export function createSkyFx(o = {}) {
flash *= Math.max(0, 1 - dt * 7);
if (flash < 0.004) flash = 0;
// --- PHYSICS: the shadow grids. Camera or not, always. ---
// Everything that scores a rig — gardenExposure, gardenHailExposure,
// rainShadowOver, hailShadowOver — reads these grids. They are what the
// sail DOES, so they rebuild before any view work and above the render
// gate. Rebuilt a few times a second, not every frame: the cloth moves
// slowly next to the weather, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const rl = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl);
}
hailAmt = hailIntensity(t);
const stone = hailStone();
// The hail shadow follows the STEEP hail vector, not the wind. It barely
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
hailTick -= dt;
if (hailTick <= 0) {
hailTick = 0.12;
hailWind.set(w.x, 0, w.z);
hailVelocity(hailWind, hailDir);
const hl = hailDir.length() || 1;
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
}
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
// gale to hear. All of it needs somewhere to stand. A headless harness has
// the numbers it came for and can stop here.
if (!rendering) return;
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
@ -787,31 +848,8 @@ export function createSkyFx(o = {}) {
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain ---
// Rebuild the shadow a few times a second, not every frame: the cloth
// moves slowly next to the rain, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const len = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
}
// --- rain + hail drops (the grids they read were built above) ---
rain.step(dt, camPos, w, intensity, shadow);
// --- hail ---
hailAmt = hailIntensity(t);
const stone = hailStone();
// The hail shadow follows the STEEP hail vector, not the wind. It barely
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
hailTick -= dt;
if (hailTick <= 0) {
hailTick = 0.12;
hailWind.set(w.x, 0, w.z);
hailVelocity(hailWind, hailDir);
const len = hailDir.length() || 1;
hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len);
}
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
// how much of the hail the sail overhead is catching, for the drum sound —
// sample right above the player/camera so it's "is it drumming over ME"

View File

@ -10,6 +10,7 @@ import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, gradeFor, BROKE_BELOW } from '../week.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@ -51,6 +52,74 @@ export default async function run(t) {
assertEq(checkContract('game', createGame()).join('; '), '');
});
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights and the ladder never reorders', () => {
assertEq(NIGHTS.length, 5);
assertEq(NIGHTS[0], 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(NIGHTS[4], 'storm_02b_icenight', 'night five is the ice night');
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assert(!w.isFinalNight);
for (let i = 0; i < 4; i++) w.advance();
assertEq(w.night, 5);
assert(w.isFinalNight, 'night five is the last');
w.advance();
assertEq(w.night, 5, 'the ladder does not walk off its own end');
});
t.test('money persists across nights, and the bank is next night\'s shop', () => {
const w = createWeek();
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const s = w.settle(
{ hp: 80, win: true, collateral: [], intactHardwareValue: 60 }, def, 70,
);
assertEq(s.bankBefore, 80);
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank spent + pay, nothing else');
assertEq(s.pay, s.fee + s.bonus + s.refund - s.collateral, 'the ledger adds up as shown');
assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
w.advance();
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
});
t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
const lost = createWeek().settle({ hp: 80, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
assertLess(lost.fee, won.fee, 'losing must cost you the fee');
assert(lost.fee > 0, 'but not all of it — you turned up. Zero is unrecoverable, not hard');
});
t.test('reaching night five is NOT the same as saving five gardens', () => {
// The bug this pins shipped in my own first draft and only measurement
// caught it: a $20-carabiner player loses four gardens, never dips under the
// broke line because a cheap rig barely costs anything, and was handed "THE
// WEEK HELD — everything's still where you put it" over four dead gardens.
// Outlasting the week is not surviving it, and the end card is the last
// thing this game says to anyone.
assertEq(gradeFor(5), 'clean', 'five held is the only clean week');
assertEq(gradeFor(4), 'scraped');
assertEq(gradeFor(3), 'scraped');
assertEq(gradeFor(1), 'solvent', 'one garden out of five is not a triumph');
assertEq(gradeFor(0), 'solvent');
});
t.test('going broke ends the week, but never on the final night', () => {
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
const ruin = { hp: 0, win: false, collateral: [{ what: 'gnome', cost: 25 }], intactHardwareValue: 0 };
const w = createWeek();
const s = w.settle(ruin, def, 80); // spend the lot, get almost nothing back
assert(s.bankAfter < BROKE_BELOW, `bank ${s.bankAfter} should be under the $${BROKE_BELOW} floor`);
assertEq(s.outcome, 'gameover', 'you cannot rig four corners, so the week is over');
// On the last night there is no next shop to be unable to afford, so being
// broke is just being broke — the week still finished.
const w2 = createWeek();
for (let i = 0; i < 4; i++) w2.advance();
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
});
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting

View File

@ -33,6 +33,8 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE;
/** main.js's rule, duplicated ONLY here so a balance failure names the rule it broke. */
const WIN = (hp, lost) => hp >= 50 && lost < 2;
/** main.js's CALM_STORM — what wind.use() hands the rig outside a storm, and so what settles it. */
const CALM_STORM = 'storm_01_gentle';
const GARDEN_DRAIN = 0.9; // main.js
const W_HAIL = 5.0; // main.js, decision 13 — integration weights
const W_RAIN = 0.25;
@ -83,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'];
@ -109,9 +147,14 @@ 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
// storm: `wind.use(to === 'storm' ? winds[stormKey] : calmWind)`.
const calmDef = await loadStorm(CALM_STORM);
const calmWind = createWind(calmDef);
calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
// main.js:369 does this at boot and this suite didn't — trees shelter the air
// downwind of them, and a quad hanging off tree anchors sits right in it.
// (Measured during gate 0: it doesn't change storm_02's verdict, but a harness
@ -149,46 +192,107 @@ async function fly(yard, session, stormName, { repair = false, broom = false } =
// anchors sitting inside those shadows.
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
// THE SETTLE (gate 0, cause #2, found by Lane D 2026-07-18). A player plays
// through prep, so ~12 s of world.update pass before ENTER and the cloth +
// tree sway reach steady state. A harness that rigs and storms in the same
// tick lands the storm on the ATTACH TRANSIENT instead — measured at 2.7× the
// settled load on tree corners (t2 4.54 kN unsettled vs 1.71 settled), loud
// enough to swamp tension entirely. Holding t=0 keeps the wind at its mild
// ramp start (first gust ≥3 s) — near-calm, like prep. Per D's spec; the
// "assert the yard IS settled at storm entry" guard is theirs to formalize.
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, 0);
// D's settled-at-entry guard (SPRINT8 gate 0').
// THE SETTLE. A player plays through prep, so ~12 s pass before ENTER and the cloth reaches
// steady state. A harness that rigs and storms in the same tick lands the storm on the ATTACH
// TRANSIENT instead.
//
// Now driven the way main.js drives prep, which is the last of the three suite-vs-game
// differences gate 0' was chartered to close: `wind.use(calmWind)` on the phase change, and
// `windTime()` returning `simT % calmWind.duration` — i.e. CALM WIND ON A RUNNING CLOCK, not the
// storm's wind frozen at t=0. Measured, Lane D 2026-07-18, this quad:
// frozen t=0, storm wind → 1.94 kN sitting on the corners at storm entry
// calm wind, clock running → 0.40 kN ← what a player actually walks in with
//
// ⚠️ AND THE HONEST PART: this does NOT change the corner count, and my Sprint-7 claim that it
// did is RETRACTED. Measured on current main, live game, fresh page, one variable:
// 0 s settle → 2 lost [t2,t2b], t2 peak 4.54 kN
// 12 s settle → 2 lost [t2,t2b], t2 peak 4.55 kN
// The storm's own peak (4.54 kN at t=75.9) dwarfs the transient, so the transient never decided
// 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 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();
}
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);
if (trend > 0.35) {
throw new Error(`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++) {
@ -219,6 +323,8 @@ 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,
};
}
@ -239,8 +345,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;
@ -251,6 +375,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.
@ -285,18 +427,94 @@ 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.
*/
t.test('harness: the yard is SETTLED when the storm starts', () => {
const runs = [['line', line], ['cheap', cheap], ['gentle', gentle]].filter(([, r]) => r);
for (const [name, r] of runs) {
if (!(r.entryPeak < 1.0)) {
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.');
}
}
return `entry loads: ${runs.map(([n, r]) => `${n} ${r.entryPeak.toFixed(2)} kN`).join(' · ')}`;
});
t.test('balance: storm_02 punishes a cheap rig on the same quad', () => {

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
import { loadStorm, createWind, forecastLines } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
@ -344,6 +344,76 @@ export default async function run(t) {
sky.dispose();
});
// The trap that cost gate 0 two sprints, asserted so it cannot come back.
// skyfx.step() used to open `if (!camera) return`, so a headless harness
// skipped the shadow rebuild and scored every rig as if the sail weren't
// there. A camera is a view, not a weather system: the grids are physics and
// must build without one. If someone re-adds an early return, this goes red.
t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => {
const bed = { x: 0, z: 0, w: 4, d: 3 };
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
const run = (withCamera) => {
const scene = new THREE.Scene();
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx(withCamera
? { scene, camera: new THREE.PerspectiveCamera(), wind }
: { scene, wind }); // headless: no camera at all
fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
const out = {
hailShadow: sky.hailShadowOver(bed),
rainShadow: sky.rainShadowOver(bed),
hailExp: sky.gardenHailExposure(bed, 56),
hail: sky.hailAmount,
};
sky.dispose();
return out;
};
const headless = run(false);
const viewed = run(true);
assert(headless.hailShadow > 0.9,
`no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`);
assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked');
assert(headless.hailExp < 0.1,
`no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`);
// and a camera must not CHANGE the physics, only add the view
assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9,
`the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`);
assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9,
`the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
});
// The card's copy, not just the model — this is what a player actually reads.
t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
const def = storms.storm_02_wildnight;
const tonight = forecastLines(def, 0);
assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
assert(tonight.night === true, 'the wild night should read as a NIGHT');
assert(!//.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);
const far = forecastLines(def, 1);
assert(//.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');
// no NaN/undefined can reach the card, for any storm at any lead
for (const [name, d] of Object.entries(storms)) {
for (const lead of [0, 0.5, 1]) {
const f = forecastLines(d, lead);
for (const k of ['name', 'wind', 'rain', 'confidence']) {
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
}
}
}
// a hail-free storm must not advertise hail
assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
'the gentle storm advertised hail it does not have');
});
t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');

View File

@ -17,6 +17,58 @@ import {
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);
/**
* The forecast card's two stat lines, already worded — DESIGN.md's partial
* information made visible. Lane A owns the card; this owns the numbers, so the
* card stays one call instead of re-deriving the storm inline.
*
* `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly
* as the card always has), 1 = the far end of the week (wide bands, low
* confidence). The bands ALWAYS contain the truth a forecast may be vague but
* it must never rule out what actually happens, or a player who rigs for the top
* of the stated range gets ambushed.
*
* Numbers are MEASURED (stormStats), not estimated: the card's old inline
* `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really
* gusts to 32.3, because gust power is drawn per gust and rides a ramp.
*
* @param {object} def parsed storm JSON
* @param {number} [lead] 0..1
* @returns {{name, night, wind, rain, confidence, hail, truth}}
*/
export function forecastLines(def, lead = 0) {
const f = forecastFor(def, lead);
const i = (v) => v.toFixed(0);
const sustained = band(f.sustained, i);
const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i);
const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i);
// rain reads as a word, and a vague forecast hedges across two
const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light');
const rainWord = word(f.rain.lo) === word(f.rain.hi)
? word(f.rain.hi) : `${word(f.rain.lo)}${word(f.rain.hi)}`;
const change = f.changeAt
? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}`
: '· no change forecast';
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
return {
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
hail: f.hail,
truth: f.truth,
};
}
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.

222
web/world/js/week.js Normal file
View File

@ -0,0 +1,222 @@
/**
* SHADES the week. Lane A owns this file.
*
* Five nights, one yard, one wallet. This is deliberately a thin wrapper around
* the phase machine rather than a new system: main.js already knows how to run
* forecast prep storm aftermath, and the week's whole job is to answer
* three questions between rounds which storm is tonight, how much money do you
* have, and is it over.
*
* Zero imports beyond contracts, and no THREE: the money and the ladder of
* nights are pure data, so they're testable at fixed cost with no browser. The
* only thing that touches the screen is main.js reading these numbers.
*/
import { START_BUDGET, HARDWARE } from './contracts.js';
/**
* The ladder. Escalating, and each rung introduces one new idea rather than just
* more wind that's Lane C's authoring, not my ordering:
* 1 Sea Breeze gust 10.5, no hail the tutorial: nothing can hurt you
* 2 Southerly Buster gust 20.0, hail the change, and hail, arrive
* 3 Early Buster gust 20.0, hail same teeth, change comes EARLY
* 4 Wild Night gust 30.0, hail the one the whole repo tuned for
* 5 Ice Night gust 28.5, hail less wind, more ice
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
*/
export const NIGHTS = [
'storm_01_gentle',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_02_wildnight',
'storm_02b_icenight',
];
/**
* The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row.
*
* Shape (SPRINT8 §gate 1): pay = fee by severity + garden bonus + intact
* hardware refund collateral. Two of those needed a judgement call and both
* are the difference between a week with stakes and a week you cannot lose:
*
* - **A lost night pays a fraction of the fee, not zero and not all of it.**
* Zero is unrecoverable and reads as punishment; full pay means failure costs
* only the broken shackle, and the bank only ever climbs. DESIGN.md's warranty
* callout is the source: you turn up, you're just not getting paid for it.
* - **Hardware comes back at half.** Full refund makes gear effectively free
* the only sink left is breakage, the bank grows every night, and "broke =
* game over" becomes unreachable. Half is also the honest number: a shackle
* that rode out a gale has been fatigued, its pin worked loose, and DESIGN.md
* already says flogging unscrews unmoused pins. You can re-use it; you can't
* call it new.
*/
export const PAY = {
/**
* Client's fee for the job, scaled off the storm's worst gust.
*
* The constants are set by the shape of the week, not by taste measured by
* playing all five nights at fixed skill. A fee has to be worth roughly
* two-thirds of a good rig ($75): any smaller and one bad night is instant
* game over (at `8 + gust` a disaster night paid $13 against a $75 rig and
* ended the week on night ONE, which is a punishment, not a difficulty); any
* larger and the bank runs away and night five is trivially affordable.
* At `25 + 1.6·gust` a disaster leaves you at ~$26 alive, but rigging the
* next night on carabiners, which is the death spiral doing its job.
*/
feeFor: (gustPeak) => Math.round(25 + gustPeak * 1.6),
/** A lost night still pays this share — you turned up, it just went wrong. */
lostNightShare: 0.35,
/** Garden bonus at hp=100. Scales linearly; this is the main earner. */
gardenBonusMax: 45,
/** What surviving hardware is worth back. Fatigued, not new. */
refundShare: 0.5,
};
/**
* Below this you cannot rig four corners at all four carabiners is the floor.
*
* MEASURED, OPEN, AND FOR A PLAYTEST TO SETTLE not a guess, and written
* here because the numbers are only visible once you run five nights:
*
* 1. **A competent week runs away with the money.** Holding 4/4 at hp 70 every
* night takes the bank 80 116 167 218 285 350. By night three you
* can already afford four rated shackles ($120) and the wild night stops
* being a decision. The fee is the lever; it is deliberately NOT tuned down
* yet, because the fix might equally be that later nights should cost more
* to rig (a bigger sail, a second bed) that's Sprint 9's content question,
* not a constant I should quietly pick tonight.
* 2. **A floor-scraping week may never actually go broke.** At $20 a cheap rig
* earns back roughly what it costs, so the bank can sit just above this line
* indefinitely, losing every garden. The grade fixes what the END CARD says
* about that (see gradeFor) but not whether 'gameover' is reachable at all
* from a slow bleed rather than one disaster.
*
* I could not settle either from the harness: my own bad-player model handed
* back a fixed $30 of intact hardware no matter what it spent, which flattered
* the bottom of the curve and is exactly the class of mistake this repo has
* caught me making all project. Both need a human playing five nights.
*/
export const BROKE_BELOW = HARDWARE[0].cost * 4;
/**
* How the week is remembered, by gardens actually held.
*
* SPRINT8 gave Lane A the win wording, and this is the ruling: the end card is
* the last thing SHADES says to a player, so it does not get to lie the way the
* aftermath verdict used to. Three grades, and E's copy carries two of them
* their primary for a clean week, their own alternate for a scraped one. The
* third is mine, because E never wrote words for "you stayed solvent while the
* client's garden died four times", and until this was measured nobody knew the
* game could even produce that ending.
*
* @param {number} held gardens saved, 0..5
*/
export function gradeFor(held) {
if (held >= 5) return 'clean';
if (held >= 3) return 'scraped';
return 'solvent';
}
/** Worst gust a storm def can throw — the severity signal the fee reads. */
export function gustPeakOf(def) {
const base = Math.max(...def.baseCurve.map((p) => p[1]));
const g = def.gusts ?? {};
return base + (g.powBase ?? 0) + (g.powRamp ?? 0);
}
/**
* @typedef {object} Settlement
* @property {number} fee client's payment for the job
* @property {number} bonus garden bonus
* @property {number} refund intact hardware recovered, at half
* @property {number} collateral what your failure broke of theirs
* @property {number} pay fee + bonus + refund collateral
* @property {number} bankBefore
* @property {number} bankAfter
* @property {'continue'|'win'|'gameover'} outcome
*/
/**
* @param {object} [opts]
* @param {number} [opts.bank=START_BUDGET]
*/
export function createWeek(opts = {}) {
const startBank = opts.bank ?? START_BUDGET;
let index = 0; // 0-based into NIGHTS
let bank = startBank;
let done = false;
/** @type {Settlement[]} */
const log = [];
const week = {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
get nights() { return NIGHTS.length; },
get stormKey() { return NIGHTS[index]; },
get bank() { return bank; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },
get log() { return log; },
reset() {
index = 0; bank = startBank; done = false; log.length = 0;
return week;
},
/**
* Settle the night just played and move the ladder on.
*
* @param {object} run main.js's scoreRun() result
* @param {object} def tonight's storm def (for severity)
* @param {number} spent what prep actually cost out of the bank
* @returns {Settlement}
*/
settle(run, def, spent) {
const bankBefore = bank;
const fullFee = PAY.feeFor(gustPeakOf(def));
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
const bonus = Math.round((Math.max(0, run.hp) / 100) * PAY.gardenBonusMax);
// Only hardware still on an unbroken corner comes home, and at half.
const intact = (run.intactHardwareValue ?? 0);
const refund = Math.round(intact * PAY.refundShare);
const collateral = (run.collateral ?? []).reduce((s, c) => s + c.cost, 0);
const pay = fee + bonus + refund - collateral;
bank = Math.max(0, bankBefore - spent + pay);
const held = log.filter((n) => n.won).length + (run.win ? 1 : 0);
// Outcome is decided AFTER the money moves: you can win the last night and
// still be broke, and the week should say you finished it.
//
// Reaching night five is NOT winning, and the first draft of this said it
// was. Measured: a player who rigs $20 of carabiners every night loses
// four gardens out of five, never dips under the broke line because a
// cheap rig barely costs anything, and got handed "THE WEEK HELD —
// everything's still where you put it" over four dead gardens. Outlasting
// the week isn't surviving it. So the WORDS scale with what you actually
// held (see grade); the outcome only says whether you're still trading.
let outcome;
if (week.isFinalNight) outcome = 'win';
else if (bank < BROKE_BELOW) outcome = 'gameover';
else outcome = 'continue';
done = outcome !== 'continue';
const s = { fee, fullFee, bonus, refund, collateral, pay, spent,
bankBefore, bankAfter: bank, outcome, night: week.night, held,
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
log.push(s);
return s;
},
/** Move to the next night. No-op once the week is done. */
advance() {
if (!done && index < NIGHTS.length - 1) index++;
return week;
},
};
return week;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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');