Compare commits
19 Commits
cd59e09368
...
3e769dbae9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e769dbae9 | ||
|
|
6fa7644aba | ||
|
|
e4436248b0 | ||
|
|
e576f5cb67 | ||
|
|
62005c8fd1 | ||
|
|
704553c82d | ||
|
|
030349d152 | ||
|
|
d3bb7c2a93 | ||
|
|
0966aebeee | ||
|
|
a398d152b1 | ||
|
|
f66c1835b4 | ||
|
|
422e0760d2 | ||
|
|
669038c82c | ||
|
|
a6ffd52209 | ||
|
|
d8af945d91 | ||
|
|
52bdcd0a5e | ||
|
|
756994d98e | ||
|
|
807c02fb9e | ||
|
|
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.
|
||||
291
THREADS.md
291
THREADS.md
@ -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.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:
|
||||
@ -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.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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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>
|
||||
252
tools/site_audit/audit.mjs
Normal file
252
tools/site_audit/audit.mjs
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* site_audit — is this site winnable, BEFORE it ships? [Lane B, SPRINT9]
|
||||
*
|
||||
* node tools/site_audit/audit.mjs # the shipped yard
|
||||
* node tools/site_audit/audit.mjs web/world/data/sites/site_02.json
|
||||
* node tools/site_audit/audit.mjs --storm storm_03_southerly
|
||||
*
|
||||
* WHY THIS EXISTS, in one number: p1 = 7.4 kN.
|
||||
*
|
||||
* In SPRINT6 the yard shipped with a bed-covering quad whose corner pulled
|
||||
* 7.4 kN against a shop whose best hardware holds 6.5. No loadout could hold it
|
||||
* at any price, so the wild night was unwinnable — and it took four lanes two
|
||||
* sprints to find out, because nothing checked the site's GEOMETRY against the
|
||||
* shop's PRICE LIST at authoring time. Geometry decides winnability. A site is a
|
||||
* balance decision disguised as art, and it should be audited the minute it's
|
||||
* drawn, not the sprint after it ships.
|
||||
*
|
||||
* What it does NOT do: score the garden. Winnability is decided before any of
|
||||
* that — by whether a quad exists that (a) covers the bed and (b) has peak
|
||||
* corner loads the budget can hold. The garden drain only decides how BADLY you
|
||||
* lose a site that was already unwinnable. That's also why this runs in node:
|
||||
* no skyfx, no document, no browser, ~20 s per site.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const SPARE_COST = 15;
|
||||
const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band
|
||||
const MIN_COVER = 0.25; // A's "shades the bed" bar
|
||||
const SETTLE_S = 12; // D's settle — a player plays through prep
|
||||
const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep
|
||||
const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
|
||||
const sitePath = argv.find((a) => !a.startsWith('--') && a !== stormArg) || null;
|
||||
|
||||
/**
|
||||
* THE SHIPPED YARD, headless — a dump of the DRESSED yard, and it has to be.
|
||||
*
|
||||
* Node cannot dress: dress() needs GLTFLoader (a bare 'three' specifier) and
|
||||
* fetch()es .glb over file://, neither of which works outside a browser. And
|
||||
* that matters far more than it sounds, because createWorld() still SUCCEEDS in
|
||||
* node — it just hands back the GRAYBOX yard:
|
||||
*
|
||||
* graybox (node) dressed (browser, what the player plays)
|
||||
* h1 x = -5.00 h1 x = -3.00 ← dress() adopts E's fascia
|
||||
* t1 (-9.00, 2.00) t1 (-9.96, 0.54) ← dress() adopts branch_anchor_01
|
||||
* t1b/t1c/t2b: ABSENT t1b/t1c/t2b: exist ← dress() adds them
|
||||
*
|
||||
* A headless tool that "reads world.js for the real yard" therefore gets the
|
||||
* house at x=±5 — the exact fictional yard that made the SPRINT6 harness report
|
||||
* the wild night unwinnable, memorialised at the top of balance.test.js. Reading
|
||||
* live code is not the same as reading truth. The dump below is the real yard;
|
||||
* live world.js, in node, is not.
|
||||
*
|
||||
* So the deal is: dumped values for what only dress() knows, and a HARD
|
||||
* CROSS-CHECK against live world.js for everything dress() never touches.
|
||||
* dress() only adopts h1..h3 / t1 / t2 and adds the branch anchors — the four
|
||||
* posts are pure world.js, so they are re-verified on every run (verifyPosts).
|
||||
* That check exists because the first draft of this file typed p4 straight from
|
||||
* world.js's `postSpecs` and missed that posts are RAKED 8° away from centre:
|
||||
* it sat 0.56 m off, and the audit dutifully reported on a post that isn't there.
|
||||
*
|
||||
* This whole apparatus is why SPRINT9 gate 2 (sites as DATA) is worth it. The
|
||||
* moment `data/sites/backyard_01.json` exists, pass it and none of this is used.
|
||||
*/
|
||||
const BACKYARD_01 = {
|
||||
name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
|
||||
dumped: true,
|
||||
bed: { x: 1, z: 2, w: 6, d: 4 },
|
||||
anchors: [
|
||||
// dress()-only: adopted from E's GLBs. Unverifiable headless.
|
||||
['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
|
||||
['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
|
||||
['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
|
||||
// pure world.js — cross-checked against live code on every run.
|
||||
['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
|
||||
['p4', 'post', -3.72, 4.00, -1.40],
|
||||
].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
|
||||
};
|
||||
|
||||
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
|
||||
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
|
||||
|
||||
/**
|
||||
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
|
||||
* The one guard-rail a hand-typed yard can actually have.
|
||||
*/
|
||||
async function verifyPosts(site) {
|
||||
const THREE = await import('../../web/world/vendor/three.module.js');
|
||||
const { createWorld } = await import('../../web/world/js/world.js');
|
||||
const stub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
|
||||
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
|
||||
const drift = [];
|
||||
for (const a of site.anchors) {
|
||||
if (!VERIFIABLE.has(a.id)) continue;
|
||||
const l = live.get(a.id);
|
||||
if (!l) { drift.push(`${a.id}: gone from world.js entirely`); continue; }
|
||||
const d = Math.hypot(a.pos.x - l.x, a.pos.y - l.y, a.pos.z - l.z);
|
||||
if (d > 0.01) drift.push(`${a.id}: dump (${a.pos.x.toFixed(2)}, ${a.pos.y.toFixed(2)}, ${a.pos.z.toFixed(2)}) ` +
|
||||
`vs world.js (${l.x.toFixed(2)}, ${l.y.toFixed(2)}, ${l.z.toFixed(2)}) — ${d.toFixed(2)} m out`);
|
||||
}
|
||||
const missing = [...live.keys()].filter((id) => !site.anchors.some((a) => a.id === id));
|
||||
if (missing.length) drift.push(`world.js has anchors this dump has never heard of: ${missing.join(', ')}`);
|
||||
return drift;
|
||||
}
|
||||
|
||||
async function loadSite(path) {
|
||||
if (!path) return BACKYARD_01;
|
||||
const j = JSON.parse(await readFile(path, 'utf8'));
|
||||
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
}));
|
||||
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors };
|
||||
}
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
function areaOf(q) {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */
|
||||
const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
const drift = await verifyPosts(site);
|
||||
if (drift.length) {
|
||||
console.log('\n✗ FAIL — the built-in yard dump no longer matches world.js:\n');
|
||||
for (const d of drift) console.log(` ${d}`);
|
||||
console.log('\n Every number this tool prints would be about a yard that does not exist.');
|
||||
console.log(' Fix the dump (posts are RAKED 8° — use the anchor pos, not postSpecs), or pass a site JSON.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` posts verified against live world.js ✓ ` +
|
||||
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
|
||||
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
|
||||
}
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
const A = anchors;
|
||||
for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++)
|
||||
for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) {
|
||||
const q = [A[a], A[b], A[c], A[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < BAND.lo || area > BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
if (!cands.length) {
|
||||
console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`);
|
||||
console.log(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check.
|
||||
//
|
||||
// This drives the wind the way main.js does, and every clause below is here
|
||||
// because the first draft skipped it and MIS-MEASURED:
|
||||
//
|
||||
// · createWind (not the raw field) + setSheltersFromTrees — trees knock a
|
||||
// hole downwind, and a quad hanging off tree anchors sits in it. main.js
|
||||
// does this at boot.
|
||||
// · the settle runs on the CALM day on a RUNNING clock, because that is what
|
||||
// wind.use() hands the rig during prep. The first draft settled on STORM
|
||||
// wind frozen at t=0 — the exact anti-pattern balance.test documents at
|
||||
// 1.94 kN vs 0.40 kN of standing load at storm entry.
|
||||
// · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and
|
||||
// was quietly folding the attach transient + settle into the storm peak.
|
||||
//
|
||||
// A tool that vets sites is worthless if it doesn't fly the storm the game
|
||||
// flies. It reported p1 at 1.1 kN with all three of those wrong.
|
||||
const trees = site.anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth: the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad }));
|
||||
const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
console.log('');
|
||||
if (!winners.length) {
|
||||
const best = rows[0];
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
(best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
|
||||
console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const w = winners[0];
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });
|
||||
@ -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]],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -19,6 +19,37 @@ export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
export const DEFAULT_TENSION = 1.0;
|
||||
|
||||
/**
|
||||
* Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a
|
||||
* purchase. Both cost $0.
|
||||
*
|
||||
* That pricing is a finding, not laziness. SPRINT6 asked me to "price the
|
||||
* difference"; I measured it instead and there is no price at which membrane is
|
||||
* a sane buy, because it is strictly dominated as an upgrade:
|
||||
*
|
||||
* what membrane buys +26% hail blocked — but ONLY on the pea-hail nights
|
||||
* (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY
|
||||
* ones; on storm_02 (1.3) and the ice night (1.4) a
|
||||
* knitted cloth already blocks 100%, because a 2 cm
|
||||
* stone cannot pass a 2 mm weave — C's ruling.
|
||||
* Plus rain, which is 0.25 of the drain against hail's
|
||||
* 5.0, i.e. ~5% of the damage.
|
||||
* what membrane costs DOUBLE the wind load (porosity halves the pressure
|
||||
* term), and it PONDS — the flat-sail killer, needing
|
||||
* the broom.
|
||||
*
|
||||
* Charge for that and you have shipped a trap. Free, it is the real forecast
|
||||
* bet the design describes: take the cloth when the night is windy (which is
|
||||
* every night with big hail), take the membrane when the hail is small and the
|
||||
* wind is mild and you want the rain off too. If Lane A ever raises rain's drain
|
||||
* weight, membrane earns a price and this comment is the place to start.
|
||||
*/
|
||||
export const FABRIC = [
|
||||
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
||||
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
||||
];
|
||||
export const DEFAULT_FABRIC = FABRIC[0];
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
const OK = { ok: true };
|
||||
@ -46,6 +77,7 @@ export class RiggingSession {
|
||||
this.budget = this._startBudget;
|
||||
this.tension = DEFAULT_TENSION;
|
||||
this.spares = 0;
|
||||
this.fabric = DEFAULT_FABRIC;
|
||||
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
||||
this.picks = [];
|
||||
return this;
|
||||
@ -107,6 +139,19 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the
|
||||
* cost is which night you're wrong on, not dollars.
|
||||
* @param {object|string} f a FABRIC entry or its id
|
||||
*/
|
||||
setFabric(f) {
|
||||
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
|
||||
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
|
||||
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
|
||||
this.fabric = pick;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
||||
setTension(v) {
|
||||
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
||||
@ -137,6 +182,12 @@ export class RiggingSession {
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
// Fabric before attach: porosity scales the wind pressure term and decides
|
||||
// whether the cloth ponds, so the sail has to know what it's made of before
|
||||
// it is built. (It's also half of the wild night's only winnable line —
|
||||
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
||||
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
||||
if (rig.setFabric) rig.setFabric(this.fabric);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
}
|
||||
|
||||
@ -147,6 +198,7 @@ export class RiggingSession {
|
||||
spent: this.spent,
|
||||
tension: this.tension,
|
||||
spares: this.spares,
|
||||
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
||||
canStart: this.canStart,
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
weakest: this.picks.length
|
||||
|
||||
@ -823,6 +823,46 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero the peak-load watermarks to the CURRENT load. `peakLoad` is otherwise
|
||||
* peak-since-attach, which silently folds the attach transient and any settle
|
||||
* into whatever you meant to measure — call this where measurement starts.
|
||||
*
|
||||
* Written for tools/site_audit, which was reporting a 12 s settle's transient
|
||||
* as a storm peak until it called this. `load` itself is untouched.
|
||||
*/
|
||||
resetPeaks() {
|
||||
for (const c of this.corners) c.peakLoad = c.load;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* RMS speed of the cloth's nodes, m/s — read straight out of verlet, since
|
||||
* position IS the state here and velocity is just (pos - prev)/dt.
|
||||
*
|
||||
* This is what "is the sail settled" actually asks. Corner LOAD cannot answer
|
||||
* it: measured on the dressed yard, a load-trend guard reads 17% on a rig with
|
||||
* NO settle and 96% on a properly settled one, because what it really sees is
|
||||
* the rig loading up when the wind changes — the better-settled the cloth, the
|
||||
* sharper that ramp. Motion inverts none of that. Under unchanged conditions:
|
||||
*
|
||||
* 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007
|
||||
*
|
||||
* an order of magnitude, and in the direction the word "settled" means. Sample
|
||||
* it over a window, not per-step: the cloth breathes and never reaches zero.
|
||||
*/
|
||||
nodeSpeed() {
|
||||
const p = this.pos, q = this.prev;
|
||||
if (!p || !q) return 0;
|
||||
let sum = 0, n = 0;
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2];
|
||||
sum += dx * dx + dy * dy + dz * dz;
|
||||
n++;
|
||||
}
|
||||
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
|
||||
}
|
||||
|
||||
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
|
||||
_checkFailure(dt) {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
@ -905,6 +945,19 @@ export class SailRig {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sail is made of. Porosity scales the wind pressure term and, since
|
||||
* rain lands on the horizontal projection of a face, an open weave sheds the
|
||||
* water it can't hold — so a porous cloth also ponds far less. Set BEFORE
|
||||
* attach(); RiggingSession.commit() does that.
|
||||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||||
*/
|
||||
setFabric(f) {
|
||||
const p = typeof f === 'number' ? f : (f && f.porosity);
|
||||
if (Number.isFinite(p)) this.porosity = clamp(p, 0, 0.9);
|
||||
return this;
|
||||
}
|
||||
|
||||
setTension(tension) {
|
||||
const was = this.tension;
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { rng } from './contracts.js';
|
||||
import { valueNoise2 } from './weather.core.js';
|
||||
import { valueNoise2 , hailBlockFor } from './weather.core.js';
|
||||
|
||||
const lerp = (a, b, k) => a + (b - a) * k;
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
@ -586,6 +586,8 @@ export function createSkyFx(o = {}) {
|
||||
// doesn't forward it, degrade to no hail rather than crashing — but it must be
|
||||
// forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
|
||||
const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
|
||||
// what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise
|
||||
let sailPorosity = 0;
|
||||
const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);
|
||||
|
||||
const audio = createAudio((wind && wind.seed) || 1);
|
||||
@ -692,7 +694,18 @@ export function createSkyFx(o = {}) {
|
||||
gardenHailExposure(rect, t) {
|
||||
const h = hailIntensity(t);
|
||||
if (h <= 0) return 0;
|
||||
return h * (1 - hailShadow.fractionOver(rect));
|
||||
// The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling
|
||||
// (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone
|
||||
// cannot pass a 2 mm weave, so porous and membrane stop the big hail
|
||||
// identically, and the ONE real difference is the finest pea hail
|
||||
// rattling through an open knit. Wired here because this is the only
|
||||
// place that knows both the stone size and what the sail is made of.
|
||||
// Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks
|
||||
// 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the
|
||||
// night membrane earns its keep. Lane B, SPRINT9 fabric landing.
|
||||
const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1;
|
||||
const block = hailBlockFor(size, sailPorosity);
|
||||
return h * (1 - hailShadow.fractionOver(rect) * block);
|
||||
},
|
||||
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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.
|
||||
|
||||
222
web/world/js/week.js
Normal file
222
web/world/js/week.js
Normal 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 |
@ -79,7 +79,14 @@ summary.textContent = report.ok
|
||||
: `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`;
|
||||
|
||||
const out = document.getElementById('out');
|
||||
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
|
||||
// Render every lane the report actually CONTAINS, rather than a hardcoded list.
|
||||
// This read ['A','B','C','D','E'] while the import list above also carries BAL,
|
||||
// so the balance suite ran, was counted in the summary, and had every one of its
|
||||
// rows silently dropped — including, had one ever gone red, the row saying which
|
||||
// assert failed and why. The merge gate was invisible in the merge gate's own
|
||||
// report. My bug: I added the BAL import and not this. Deriving the list means
|
||||
// the next joint suite can't hit it. — B
|
||||
for (const letter of [...new Set(report.results.map((r) => r.lane))]) {
|
||||
const rows = report.results.filter((r) => r.lane === letter);
|
||||
if (!rows.length) continue;
|
||||
const h = document.createElement('div');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user