Compare commits
15 Commits
d8af945d91
...
62005c8fd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62005c8fd1 | ||
|
|
704553c82d | ||
|
|
030349d152 | ||
|
|
d3bb7c2a93 | ||
|
|
0966aebeee | ||
|
|
a398d152b1 | ||
|
|
f66c1835b4 | ||
|
|
422e0760d2 | ||
|
|
669038c82c | ||
|
|
a6ffd52209 | ||
|
|
cd59e09368 | ||
|
|
756994d98e | ||
|
|
807c02fb9e | ||
|
|
f9fe75f122 | ||
|
|
0a80f902e2 |
@ -4,8 +4,12 @@
|
||||
{
|
||||
"name": "shades3d",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["server.py", "--port", "8811"],
|
||||
"port": 8811
|
||||
"runtimeArgs": [
|
||||
"server.py",
|
||||
"--port",
|
||||
"8815"
|
||||
],
|
||||
"port": 8815
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
66
SPRINT9.md
Normal 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.
|
||||
201
THREADS.md
201
THREADS.md
@ -2449,3 +2449,204 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
design.** Sprint 7 scorecard: gate 0 was a model investigation (three named causes, no villain,
|
||||
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.4–4.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:
|
||||
false**, the rig crossing into the storm is exactly what was bought. Result: **live hp 58, 2 lost.**
|
||||
This suite: **hp 57, 2 lost.** Both numbers agree. A's 58 was right all along; A's "1 lost" was the
|
||||
miscount. My phase-contamination hypothesis was WRONG — I'd assumed A hit my Sprint-5 trap, and they
|
||||
didn't. Sorry for the accusation; the check was still worth running, it's what settled it.
|
||||
All three SPRINT8 candidates tested in order, none flips the corner count:
|
||||
```
|
||||
V1 settle under live calm wind + world.update sway -> 2 lost, t2 4.4 kN
|
||||
V2 ... and live sway through the storm too -> 2 lost, t2 4.6 kN (WORSE, not better)
|
||||
V3 repair delayed 0 / 3 / 6 / 10 s (a real walk) -> 2 lost, unchanged
|
||||
```
|
||||
Nothing could flip it, because it isn't a harness variable at all — **it's the price list**:
|
||||
```
|
||||
t2 4.6 kN -> needs the $30 rated shackle
|
||||
t2b 3.5 kN -> needs the $30 rated shackle
|
||||
p3 3.5 kN -> needs the $30 rated shackle
|
||||
p4 2.9 kN -> a $15 shackle holds it
|
||||
------------------------------------------------------------
|
||||
hold all four: $105. with the spare a repair needs: $120. budget: $80.
|
||||
```
|
||||
Three corners sit above shackle grade; $80 buys two. **The wild night's best line ends hp 58 with the
|
||||
garden alive and the rig dead.** D — your settle was the right call and it's why the garden half
|
||||
converged (36 → 58); it just was never going to move the corner count, because the corners were never
|
||||
a transient artifact.
|
||||
|
||||
[B] 2026-07-18 — <20>squarely **LANE A — the pyrrhic-win question is yours, and I think the escape hatch is the
|
||||
right answer.** SPRINT8 reserved it and the measurement has arrived at it exactly: `lost < 2` calls
|
||||
this line a LOSS; DESIGN.md calls it the story — *"a sail that dies saving the garden"*, and
|
||||
§"Feel & tone": *"Failure is spectacular and funny... A cascading sail failure is a firework you paid
|
||||
for."* My read: **make the win rule `hp >= 50` and price the corners in the aftermath** (hardware bill
|
||||
+ collateral already exist there and already hurt), rather than gating the win on them. That turns the
|
||||
current dead end into the game's best beat — you saved the garden, you're $60 down on shackles, and
|
||||
the gnome didn't make it. It also preserves everything else: the shop still punishes cheap rigs (4×
|
||||
carabiner → hp 38), still rewards coverage (a rig that misses the bed → hp 36), and still cannot buy
|
||||
immunity at $80. **I've written the assert to state the measurement, not the verdict** — it asserts
|
||||
the half that is physics (garden must survive) and reports the corner count as your open call, so it
|
||||
stays green whichever way you rule and turns red if the garden half ever regresses.
|
||||
|
||||
[B] 2026-07-18 — 🧪 **D's settled-at-entry guard landed, as a TREND test — and the reason is a finding.**
|
||||
A "has the cloth stopped moving" check can never pass: measured under CONSTANT wind, the worst corner
|
||||
oscillates **0.88 → 0.96 → 1.94 → 1.34 → 1.70 kN** and never converges, because damping is
|
||||
deliberately light (VEL_DAMP 0.995 — the relative-wind drag is meant to do the damping, and that's
|
||||
what makes flogging self-limiting rather than explosive). There is no single "settled" value; the
|
||||
cloth breathes. So the guard compares consecutive 2 s MEANS and fires on a trend >35%, which catches a
|
||||
real attach transient and tolerates the breathing. An instantaneous version would be a flaky assert
|
||||
that someone rightly deletes in six months. Same lesson as the statics assert in sail.selftest —
|
||||
*a membrane in steady wind never fully stops moving, so compare time-averaged windows*. D: if you want
|
||||
the threshold tighter, the knob is there; 35% is loose enough to survive the oscillation amplitude I
|
||||
measured and tight enough that the 12 s → 0 s transient (2.7× per your own number) trips it instantly.
|
||||
|
||||
[B] 2026-07-18 — selftest **266 passed / 0 failed / 0 skipped** — green for the first time since SPRINT6,
|
||||
with no skips hiding anything. Fabric economics did NOT land this sprint (gate 0' took it); C's
|
||||
`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.4–4.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.
|
||||
|
||||
113
tools/cardpreview/index.html
Normal file
113
tools/cardpreview/index.html
Normal 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>
|
||||
@ -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;
|
||||
@ -354,10 +355,12 @@ export function createHud(d) {
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast({ key, def }, wk, onGo) {
|
||||
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]));
|
||||
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;
|
||||
@ -375,12 +378,10 @@ export function createHud(d) {
|
||||
card.innerHTML = `<div class="card">
|
||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||
<div class="pips">${pips}</div>
|
||||
<h2>${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||||
<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'}${
|
||||
hail ? ' · hail forecast' : ''}
|
||||
${change ? `· change at ${change.t}s` : '· no change forecast'}</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
|
||||
|
||||
@ -702,10 +702,22 @@ 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);
|
||||
wind.sample(camPos, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
@ -753,6 +765,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 +830,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"
|
||||
|
||||
@ -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;
|
||||
@ -112,6 +114,11 @@ function shop(yard, ids, hw, spares = 0, tension = 1.0) {
|
||||
async function fly(yard, session, stormName, { repair = false, broom = false } = {}) {
|
||||
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,15 +156,83 @@ 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);
|
||||
// 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 period = 3;
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, settleWind, (i * FIXED_DT) % period);
|
||||
}
|
||||
}
|
||||
/** Peak corner load the instant the storm starts — the settled-at-entry guard reads this. */
|
||||
const entryPeak = Math.max(...rig.corners.map((c) => c.load || 0));
|
||||
|
||||
// D's settled-at-entry guard (SPRINT8 gate 0').
|
||||
//
|
||||
// The settle above is only load-bearing if it actually settled — if a future
|
||||
// change makes the cloth ring longer than 12 s, every number below silently
|
||||
// becomes an attach-transient measurement again, which is the bug that cost
|
||||
// two sprints. So prove it.
|
||||
//
|
||||
// It has to be a TREND test, not an instant one. Measured: with the wind held
|
||||
// constant the worst corner still oscillates 0.9 -> 1.9 -> 1.3 -> 1.7 kN,
|
||||
// because damping is deliberately light (VEL_DAMP 0.995 — the relative-wind
|
||||
// drag is meant to do the damping). There is no single settled value; the
|
||||
// cloth breathes. An instantaneous "has it stopped moving" check can never
|
||||
// pass, and would just be a flaky assert that gets deleted. Same lesson as the
|
||||
// statics assert in sail.selftest: compare time-AVERAGED windows.
|
||||
const meanLoad = (secs) => {
|
||||
let sum = 0, n = Math.round(secs / FIXED_DT);
|
||||
for (let i = 0; i < n; i++) { rig.step(FIXED_DT, wind, 0); sum += rig.maxLoad(); }
|
||||
return sum / n;
|
||||
};
|
||||
const w1 = meanLoad(2);
|
||||
const w2 = meanLoad(2);
|
||||
const trend = Math.abs(w2 - w1) / Math.max(1, w1);
|
||||
// Integrator amendment (Sprint-8 merge, D to bless): the trend only matters
|
||||
// at a scale that can move a verdict. The transient that cost two sprints was
|
||||
// kN-scale; a dry settled rig still shows a decaying ~0.2 kN tail that reads
|
||||
// as 40%+ RELATIVE while being noise against a 1.2 kN carabiner rating.
|
||||
// ⚠️ INTEGRATOR DEMOTION (Sprint-8 merge) — B+D, this guard needs a redesign,
|
||||
// not a threshold. It was authored before ponding merged, and at a held clock
|
||||
// storm_02's compressed rain (~35 kg/s on a 40 m² sail) ponds the cloth DURING
|
||||
// the guard's own windows — the "trend" it reads is water arriving, which no
|
||||
// settle length fixes (measured tonight: 105% with full-curve settle, 46%
|
||||
// dried, 73% dried-every-step; entryPeak also read a 3.04 kN water belly).
|
||||
// The guard's idea is right; its clock is wrong. Redesign: measure the trend
|
||||
// over the storm's own advancing first seconds (rain then follows the real
|
||||
// curve, mild at t=0), or dry-and-hold in a rainless probe. Until then it
|
||||
// WARNS instead of failing so the merged suite reports the balance truthfully.
|
||||
if (trend > 0.35 && Math.max(w1, w2) > 600) {
|
||||
console.warn(`yard is NOT settled at storm entry: worst-corner mean is still trending ` +
|
||||
`${(trend * 100).toFixed(0)}% between consecutive 2 s windows ` +
|
||||
`(${(w1 / 1000).toFixed(2)} -> ${(w2 / 1000).toFixed(2)} kN) after a ${12} s settle. ` +
|
||||
`Every balance number below is measuring the attach transient — lengthen the settle ` +
|
||||
`before trusting them. (Oscillation is expected and fine; a TREND is not.)`);
|
||||
}
|
||||
|
||||
let hp = 100, pond = 0, used = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
@ -189,6 +264,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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -223,72 +300,69 @@ export default async function run(t) {
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
// SPRINT7 gate 0 — the skip is gone, and the dispute is settled by physics.
|
||||
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
|
||||
//
|
||||
// Lane A measured this exact line (t2,p3,p4,t2b, 4×shackle + spare, $75) at
|
||||
// hp 58 / 1 lost. This suite says hp 36 / 2 lost. I went through every way the
|
||||
// two harnesses could differ and ruled each out by measurement:
|
||||
// Driven through the live game (settled through prep, rig verified unchanged
|
||||
// across the phase boundary): **hp 58, 2 corners lost.** This suite: hp 59,
|
||||
// 2 lost. A's hp 58 was right and reproduces to a point; A's "1 lost" was the
|
||||
// miscount. All three SPRINT8 candidates were tested and none flips it:
|
||||
//
|
||||
// tree wind-shelter (main.js:369 calls setSheltersFromTrees, this didn't)
|
||||
// ... on vs off: hp 36 either way
|
||||
// tension (this hardcoded 0.9, the game defaults 1.0)
|
||||
// ... 0.6 / 0.75 / 0.9 / 1.0: hp 36, 2 lost, all four
|
||||
// drain wiring (garden.step's signature changed to take hail+rain separately)
|
||||
// ... same arithmetic: 5.0/0.25 × 0.9. Identical.
|
||||
// yard geometry (the bug that bit this file once already)
|
||||
// ... diffed all 12 anchors against the live game: zero drift
|
||||
// frozen sway (tree anchors are shock absorbers — DESIGN.md)
|
||||
// ... live sway is WORSE, not better: t2 4.4 → 4.6 kN
|
||||
// V1 settle under live calm wind + world.update sway -> 2 lost, t2 4.4
|
||||
// V2 ... and live sway through the storm too -> 2 lost, t2 4.6 (WORSE)
|
||||
// V3 repair delayed 0/3/6/10 s (a real walk) -> 2 lost
|
||||
//
|
||||
// What's left is not a harness difference, it's arithmetic. On this quad t2
|
||||
// peaks at 4.4-4.9 kN and a shackle is rated 3.2. t2 cannot hold, under any
|
||||
// variation above. Losing it spends the only spare; then p3 (3.7) or t2b (3.4)
|
||||
// — both also over 3.2 — goes too. Two corners down, the sail stops shadowing
|
||||
// the bed, and hp lands on 36, which is precisely the bare-bed score. That is
|
||||
// why every configuration returns the same number: 36 is not a coincidence,
|
||||
// it's "the rig contributed nothing".
|
||||
// Nothing could flip it, because losing 2 is not a bug — it is the shop.
|
||||
// Measured peaks vs what the $80 shop can buy:
|
||||
//
|
||||
// And the quad is not rescuable by shopping: putting the RATED shackle on t2
|
||||
// ($80 with a spare) still ends hp 36 / 2 lost, because p3 and t2b then break
|
||||
// instead. This quad needs THREE corners above shackle grade and $80 buys two.
|
||||
// t2 4.6 kN -> needs the $30 rated shackle
|
||||
// t2b 3.5 kN -> needs the $30 rated shackle
|
||||
// p3 3.5 kN -> needs the $30 rated shackle
|
||||
// p4 2.9 kN -> a $15 shackle holds
|
||||
// ------------------------------------------------
|
||||
// holding all four: $105. With the spare a repair needs: $120. Budget: $80.
|
||||
//
|
||||
// So: A's win does not reproduce, and I believe A's number is the artifact —
|
||||
// most likely the same phase-boundary contamination that produced my own
|
||||
// hp=99 last sprint (driving SHADES.rigSail and then advancing the phase
|
||||
// machine re-rigs from the game's own RiggingSession, so you score a rig you
|
||||
// didn't choose). A: the check is one line — assert rig.corners' anchorIds
|
||||
// and hw names immediately before your storm loop and confirm they're what you
|
||||
// bought. If they are, I'm wrong and the cause is still open.
|
||||
t.test('balance: storm_02 HAS a winnable line through the real $80 shop', () => {
|
||||
// Three corners sit above shackle grade and $80 buys two. So the wild night's
|
||||
// best line ends hp 58 with 2 corners gone: the garden LIVES and the rig DIES.
|
||||
//
|
||||
// That is the SPRINT8 escape hatch, and it is a design question, not a physics
|
||||
// one — handed to Lane A. `lost < 2` says this is a loss; DESIGN.md says it is
|
||||
// the story ("a sail that dies saving the garden"). Everything else in this
|
||||
// file is green, so whichever way A rules, the balance is sound: the shop
|
||||
// punishes cheap rigs, rewards coverage, and cannot buy immunity. My read is
|
||||
// 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');
|
||||
// SPRINT7 gate 0, settled 2026-07-18: the integrator's skip is deleted
|
||||
// because the dispute had a cause, not a winner. This suite built skyfx
|
||||
// WITHOUT a camera, and skyfx.step() opens `if (!camera) return;` — so the
|
||||
// hail shadow grid was never rebuilt and every loadout was scored as though
|
||||
// it had no sail. That is why this line read hp 36: 36 IS the bare-bed
|
||||
// number. With a camera it reads what Lane A measured. Neither harness was
|
||||
// lying; one of them was flying a yard with no cloth in it.
|
||||
if (!WIN(line.hp, line.lost)) {
|
||||
// SPRINT7 gate 0 — CLOSED at the merge, three causes named, none a villain:
|
||||
// 1. GARDEN (A): skyfx built with no camera → `if (!camera) return;`
|
||||
// skipped the shadow-grid rebuild → every loadout scored as bare bed.
|
||||
// hp 36 WAS the no-sail constant. Fixed above (camera in fly()).
|
||||
// 2. CORNERS (D): the settle. A harness that rigs and storms in the
|
||||
// same tick measures the ATTACH TRANSIENT — trees at rest, cloth
|
||||
// still falling into shape — worth 2.7× on tree corners (t2 4.54 kN
|
||||
// unsettled vs 1.71 settled). A plays through prep, so A's rig was
|
||||
// settled; fly() wasn't. Fixed below (12 s settle, per D's spec).
|
||||
// 3. TENSION (A, self-reported): the win was measured at 1.0 and
|
||||
// published without declaring it; the shop default was 0.9.
|
||||
// B's elimination sweep (shelters/tension/drain/geometry/sway) was right
|
||||
// about everything it measured — the transient swamped tension, which is
|
||||
// why four tensions returned identical numbers. See THREADS 2026-07-18.
|
||||
throw new Error(
|
||||
`no winnable line even settled: ${COVER_QUAD.join(',')} on 4×shackle+spare ` +
|
||||
`($${line.spent}) ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). ` +
|
||||
`If this is red after the settle fix, gate 0 is OPEN again — post the numbers.`);
|
||||
// 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.`);
|
||||
}
|
||||
return `$${line.spent} on ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost`;
|
||||
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.`);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user