Compare commits

...

6 Commits

Author SHA1 Message Date
m3ultra
3dee2a7c83 Merge origin/main: S14 lane integration + review fixes; TEETH becomes SPRINT16
Two same-day integrations reconciled: their SPRINT15 (post ruling, editor
iterative, honest edges) stands and runs first because it re-measures the
numbers TEETH pins; the TEETH plan moves to SPRINT16 with a fires-after-15
banner, its D gate rewired to the landed swing lawn. ROADMAP arc 0 records
the fork. THREADS keeps both sides chronologically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:34:21 +10:00
m3ultra
4a5e5bc037 Sprint 15 plan: TEETH — ledger/warranty/rep, the membrane night, site_03, six nights
ROADMAP.md is the new growth canon: five arcs from the shipped week to
DESIGN.md's landscaping business (teeth → job board/clients → slow
pressure → the season → the water table), with the earned principles up
front — every arc ends live, no mechanic without a surface, balance is
measured, one new variable per night. SPRINT15.md and the lane prompts
draw arc 1's gates; John resolved S14 gate 4 as all-three-sequenced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:10:14 +10:00
m3ultra
f179d6c25e docs: panel comment tells the truth; README wears the shipped name; THREADS review entry
main.js said panel:false in the comment and passed panel:true for two
sprints — the call was right (B's panel is the only prep UI), the
comment now says so. README retitled HARD YARDS with the SHADES codename
noted. THREADS carries the fresh-eyes review receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:36 +10:00
m3ultra
b74dfd80db rigging: the fabric bet is reachable — F cycles cloth/membrane in prep
FABRIC and setFabric() shipped in SPRINT6 and the sim honoured porosity
all along, but no key, click or HUD line ever reached them — every rig
ever played was DEFAULT_FABRIC. F toggles the two entries, the panel
shows the fabric, the splash teaches it, the blurb announces the switch.
Session-level assert pins the cycle, the $0 price, and the refusal of
unknown ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:36 +10:00
m3ultra
b7a069069d debris: delete the dead sail.nodes seam — sail._applyDebris is the one contact model
The SPRINT2-era duck-typed path (if sail.nodes → applyToSail) has been
dead since decision 5 chose option b, and was a double-apply waiting for
the day anyone exposed .nodes. The sail key on step()'s world arg is
accepted and ignored, documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:36 +10:00
m3ultra
9d6db356d7 sail: break diverged corners on the production path; enforce pond cap post-flow
A NaN corner load compares false to every rating, so a diverged corner
held forever while NaN spread node-to-node — watchDivergence only ever
guarded the tests. Non-finite load now breaks the corner instantly with
reason:'divergence', load zeroed for the HUD.

The pond per-node cap only ran on the rain-add: once rain stopped,
consolidation could stack a basin node past POND_MAX_KG_M2 unbounded.
Clamp moved post-flow, every step. Also fixes the dumpPond assert that
compared dumped to itself through a helper that returned its argument.

All three mutation-checked red before landing green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:36 +10:00
13 changed files with 626 additions and 48 deletions

View File

@ -913,3 +913,102 @@ loss.** Read docs/MANUAL.md, THREADS' last [I], then SPRINT15.md. Ports: A
> posts. With the sprint's spare room: your call on palette gaps D hit while
> authoring cold — read their gate-3.2 entry and propose what the next yard
> needs, with reasoning baked beside the numbers as always.
---
# SPRINT 16 prompts (teeth — your work follows you) — ⚠️ FIRE ONLY AFTER SPRINT 15 INTEGRATES
Same rules: own clone, own branch, rebase onto latest main FIRST
(`git fetch origin && git reset --hard origin/lane/<x>` on a clean tree).
Read **ROADMAP.md** (the growth canon — John resolved the S14/S15 branch
question: all three, sequenced, teeth first), THREADS from the last [I],
then SPRINT16.md. Ports: A 8824, B 8825, C 8826, D 8827, E 8828. Baseline:
whatever Sprint 15 landed (409/0/0 pre-15 + S15's additions — read the [I]).
The one-variable rule is law: the new site and the new storm never debut on
the same night. Numbers S15's post ruling moved get RE-MEASURED before they
get pinned — no pre-ruling number survives into a pin.
## Lane A — Sprint 16
> You are Lane A on HARD YARDS, Sprint 16. Rebase onto main, read ROADMAP.md,
> THREADS' last [I], then SPRINT16.md. Gate 1 is yours: THE LEDGER. The week
> records every committed rig (corners, hardware, what broke, what was
> repaired); a corner still broken at dawn books a WARRANTY line on the next
> morning's job sheet — hardware's shop price, off the fee AT QUOTE TIME, so
> week.js's quote==settle invariant holds by construction. Reputation is one
> number on the letterhead (job sheet AND invoice, E's contract): clean
> nights up, warranty down, collateral down harder, and it moves the callout
> fee multiplier ON THE SHEET so trust becomes visible money. Night 5's
> gardenBeyondSaving must not cost rep — a designed loss the ledger punishes
> is the verdict lying again. Aftermath and end-of-week card speak rep.
> a.test: warranty only on unrepaired breaks; quote==settle with a warranty
> line; rep untouched on designed losses; clean-week negative control. You
> also print C's hail-size and rain lines on the forecast block (their gate
> 3.3 exposes them). Do NOT build the job board — arc 2, and gate 6 aims it.
## Lane B — Sprint 16
> You are Lane B on HARD YARDS, Sprint 16. Rebase onto main, read ROADMAP.md,
> THREADS' last [I], then SPRINT16.md. Gate 2 is yours. (a) THE HEAL: after a
> divergence break (landed in the fresh-eyes review — read that [I]), non-
> finite node positions reset from the nearest finite neighbour (prev matched
> — verlet must not invent velocity) and non-finite WATER zeroes (a NaN pond
> re-poisons the heal through the weight term); assert finite within 1 s,
> mutation-proof, and the existing divergence test stays untouched. (b) THE
> RECORD: the chosen fabric prints in prep summary, invoice, and the verdict
> when it mattered — the F key exists, now the paperwork remembers the bet.
> (c) THE PIN, post-ruling numbers only: run gardenfly on site_02 (and the
> swing lawn if unpinned), pin the honest held-vs-bare separation, assert it
> like backyard_01's; if a yard can't separate at $80 that's a FINDING for
> THREADS, not a fudged pin. Pool: the >83 ms determinism bound gets a
> pinning test or a documented waiver.
## Lane C — Sprint 16
> You are Lane C on HARD YARDS, Sprint 16. Rebase onto main, read ROADMAP.md,
> THREADS' last [I], then SPRINT16.md. Gate 3 is yours. (1) STORM_06, the
> pea-hail soaker: stones under your own 2 mm-weave blocking ruling, heavy
> rain, mild wind — the first night membrane is the RIGHT answer. Cloth must
> leak fine hail all night; membrane must pond (the broom matters); the
> membrane line must be affordable at $80 and MEASURABLY better — gardenfly
> and site_audit numbers pinned in your selftest, funnel state in the header,
> post-ruling tree only. If cloth still wins, retune until the bet is real: a
> decoration night is worse than no night. (2) makeStorm(seed): a seeded
> generator inside authored envelopes, deterministic (same seed → byte-equal
> def), schema-valid across 100 seeds, storm_envelope-tested — arc 4's
> roguelite eats this; no shipped night uses it yet. (3) Expose hail stone
> size and rain rate for the forecast block (A prints them) — bands, not
> promises. The one-variable rule binds you to D: storm_06 flies over a yard
> the player already knows.
## Lane D — Sprint 16
> You are Lane D on HARD YARDS, Sprint 16. Rebase onto main, read ROADMAP.md,
> THREADS' last [I], then SPRINT16.md. Gate 4 is yours. (1) Your swing lawn
> enters the rotation: re-score site_03_swing_lawn on the post-ruling tree
> with S15's faster loop (the numbers that made it winnable-but-mean may have
> moved), fix what moved, wire its NIGHTS entry. (2) The week becomes SIX
> nights: you own the order and the two new briefs (client flavour is data —
> the corner block got a name and a reason; the swing set's kid deserves one
> too). One-variable rule: the swing lawn debuts under a storm the player has
> survived; storm_06 flies over a yard they know; never both new at once.
> Night 5's beyond-saving arc and going-broke-never-on-the-final-night must
> survive your re-ordering — a.test will tell you. (3) Cold-play all six on
> one wallet and file the verdict: is a warranty line FAIR when it lands?
> Does rep-moves-pay feel earned? Is night 6's bet legible from the forecast
> alone? Your play is gates 13's acceptance. Pool: the phantom-sail seam if
> S15 didn't close it.
## Lane E — Sprint 16
> You are Lane E on HARD YARDS, Sprint 16. Rebase onto main, read ROADMAP.md,
> THREADS' last [I], then SPRINT16.md. Gate 5 is yours: arc 2 needs yards
> this palette cannot dress. Build TWO of three, factory-built, byte-stable,
> negative-control-proven, and every anchor-bearing node passes S15's gate-1
> mechanism (a baked hint that can't reach a JSON-declared anchor is the bug
> class that cost the posts their truth): the GLASSHOUSE (collateral apex —
> priced, wrecked variant, collateral_key wired so a SECOND one placed in
> the editor still bills; nothing about it adopts as an anchor), the POOL
> KIT (pool + compliant fence ring where every fence post is honest
> tie_off:false — adoptAnchor's `?? 1` makes a silent node the best steel in
> the game, and the pool yard's whole puzzle is that the legal tie-offs are
> in the wrong places), the TRAMPOLINE + tie-down kit (typed honestly in the
> checked enum; the tie-down is prep's counter to the storm's opening
> projectile, priced like it matters). Flag D in THREADS as each lands. Axis
> trap stands: measure exported GLBs in three.js coords, look at the render
> in assetcheck, never trust a docstring about orientation.

View File

@ -1,4 +1,8 @@
# SHADES — rig it, then survive the night
# HARD YARDS — rig it, then survive the night
*(repo/product history: the project was codenamed SHADES through Sprint ~8;
the shipped game is HARD YARDS. Internal docs and the gitea repo name still
say shades — that's the same thing, not a second game.)*
Geometry/tower-defense: you're a small person rigging shade sails against
Australian storms. three.js r175 (vendored, no build step) + a zero-dep
@ -20,9 +24,12 @@ No pip, no npm, no build. Python 3.8+ and a browser.
1. `DESIGN.md` — the design canon (what the game is)
2. `PLAN3D.md` — the build canon (stack, contracts, lane model)
· `docs/MANUAL.md` — the HOW: tool map, asset/site cookbooks, earned gotchas
· `ROADMAP.md` — the growth canon (the arcs from the week to the business)
3. `THREADS.md` — the append-only lane log. **The project's memory.** Read the
last few `[I]` integrator entries to know the current state.
4. `SPRINT12.md` — the current sprint (highest-numbered SPRINTn.md wins)
4. `SPRINT15.md` — the current sprint (highest-numbered *fired* sprint wins:
SPRINT16 is pre-speced TEETH and fires only when 15 integrates — its own
header says so)
5. `LANE_PROMPTS.md` — copy-paste prompts per lane, per sprint (bottom = newest)
## The lane model (how this repo is built)

175
ROADMAP.md Normal file
View File

@ -0,0 +1,175 @@
# HARD YARDS — the growth canon (where the game goes after the week)
*Written 2026-07-18 at John's direction, following the fresh-eyes review (THREADS
[I] same date). DESIGN.md says what the game IS — it remains the design canon and
nothing here overrides it. PLAN3D.md said how to BUILD the first game — done, 14
sprints, live at partly.party/hardyards. This file says how the shipped week
becomes the landscaping business DESIGN.md describes, in arcs that each end with
something playable on the public URL. Sprints draw gates from here; when a sprint
and this file disagree, the sprint is newer — fix this file.*
**John's ruling, 2026-07-18 (this is gate 4 of SPRINT14, resolved):** all three
deferred branches — reputation/warranty, more sites, heatwaves — are wanted. Not
picked between: SEQUENCED. Teeth first, clients and yards second, slow pressure
third. "It doesn't just have to be one night… it's clearly much more than that
now."
---
## Where we are (the honest baseline)
Five 90-second nights, two shipped yards (a third — the swing lawn — authored
and inert), five storms, one wallet, ~15 minutes of campaign. Selftest 409/0/0. An editor that makes new yards cheap and scores them
through the real engines. An economy whose own comments say the balance is
unmeasured, and a scorecard that says a bare bed reads 82.6 where the best clean
rig reads 91.3 — **doing nothing scores 90% of doing everything, and that is the
single number this roadmap exists to fix first.** The sim is the strongest thing
in the repo; the thinnest thing is consequence.
## The principles (each one was earned, none is negotiable)
1. **Every arc ends live.** partly.party/hardyards after every integration, a
stranger can play it. No arc is "infrastructure for the next arc" — if it
doesn't change a night a player feels, it isn't an arc, it's a gate inside one.
2. **No mechanic without a surface.** The fabric bet shipped in SPRINT6 and no
key reached it until SPRINT15. A sim feature the player cannot reach and the
job sheet does not argue about does not exist. Every new mechanic lands with
its input, its HUD read, and its line on the paperwork, in the same sprint.
3. **Balance is measured, not reasoned.** The audit gauntlet (site_audit,
gardenfly, storm_envelope, SCORE IT) runs before a night ships, and the pinned
numbers go in THREADS. The bare-beds number is what "reasoned" balance looks
like after eight sprints of everyone being careful.
4. **Sites are data; storms are data; clients are data** (week.js proved the
third). Content arcs must not add code paths per night — they add JSON and
surfaces that read it.
5. **One new variable per night.** A new site, a new storm, and a new mechanic
never debut together — a player who loses must be able to name what beat them.
6. **The full water sim stays a trap until it isn't** (DESIGN.md's own warning).
Slices of water that reuse the pond (runoff aim, clogged inlets) are fair
game; the heightmap flow layer waits for its own PLAN document and its own
proof of fun in a bench, like the cloth had.
---
## ARC 0 — THE TRUE BASELINE (SPRINT15, already speced — the fork's gift)
*This roadmap and SPRINT15 were written in parallel forks the same day and
reconciled: S15's gates (the post rating_hint ruling, the iterative editor
score, the honest edges) re-measure the very numbers arc 1 pins, so S15 runs
FIRST and TEETH fires as SPRINT16. The fork was the process working — D's
cold-authoring pass found a shipped balance bug nobody reasoning from the
code would have caught.*
## ARC 1 — TEETH (SPRINT16, speced in full in SPRINT16.md)
*The week gets consequences. Your work follows you.*
Reputation and warranty hang off the invoice exactly as DESIGN.md drew it: a
corner of yours that blew and stayed blown books an unpaid warranty line on the
next morning's job sheet; a clean night builds the number on the letterhead; the
number moves what clients pay. The fabric bet gets its paper trail (forecast
argues hail size and rain, invoice records the cloth you chose). D's swing lawn
enters the rotation, the week becomes six nights, and one of them — C's pea-hail
soaker — is the first night where the default fabric is the wrong answer. The
bare-beds separation fight starts here and does not end until held-vs-bare is a
pinned, teeth-bearing number on every shipped site.
## ARC 2 — THE BOOK OF CLIENTS (≈ two sprints)
*From a scripted week to a job board. Variety without new engines.*
- **The job board**: mornings offer N callouts, you take one. Pay, risk, client,
and site visible before you commit — income becomes a choice, replay becomes
real, and BROKE_BELOW starts meaning something because you can chase money
into jobs you shouldn't take. The week structure (five-to-seven nights, one
wallet) survives; what changes is that no two weeks need be the same list.
- **Job types beyond "rig for tonight"** — each is a variation on engines that
already exist, none needs new sim:
- *Emergency callout* — you arrive MID-STORM at double rates to someone
else's failing rig: triage, repair, cut-away. The storm phase already does
everything this needs except spawn you at t=30 with a rig you didn't build.
- *The cowboy audit* — storm-proofing someone else's work 36 hours out: walk
the yard, find what's about to fail (frayed wire, unmoused pins, a carabiner
where a shackle should be — the inspection tells exist as rating hints and
hardware states), fix the worst of it on a budget that cannot fix all of it.
- *Warranty callout* — arc 1's ledger, now as a full night: your own past
work, revisited in weather, unpaid.
- **Client personalities as constraints** (DESIGN.md's soft-constraint idea):
"nothing attached to the house", "the pool fence is off limits", "cheapest
option and I'll sue" — data on the night, enforced by the rigging session,
argued on the job sheet. Cheap to build, huge puzzle variation.
- **Yards 46 out of the editor**: the pool yard (fence compliance + corroded
cheap hardware), the granny-flat alley (the venturi as the whole level), the
glasshouse yard (maximum collateral, E's arc-1 asset). D authors them cold,
the gauntlet prices them, separation pinned before they ship.
## ARC 3 — SLOW PRESSURE (≈ two sprints)
*DESIGN.md's other half: days, sun, and water you can see coming.*
- **Heatwave days**: the sun arcs, the shadow moves, shade-hours-delivered is
the score (coverageOver() already ground-projects; it needs a sun path and an
hourly integral). No storm — the tension is coverage over TIME, a different
brain. Slow days between violent nights is the Overcooked rhythm DESIGN.md
named, and it makes the storms land harder.
- **The seasonal sun**: the same yard needs a different rig in February than
July (sun elev/azim already live in site JSON — make them a season, not a
constant). Seasonal-adjustment callouts fall out for free.
- **Water, the honest slice**: sail runoff has to GO somewhere — one corner low
dumps the pond's drainage where you aimed it (the veggie bed: bad; the path:
fine; the neighbour's side: a bill). Reuses the pond machinery wholesale, adds
aim. Second slice: leaves clog the one drain inlet and the puddle drowns the
bed corner — the leaf layer already exists in debris.js as an ambient tell.
Both slices are legible without a flow sim, and both make yards read as
plumbing, which is the door arc 5 walks through.
- **Heat affects you**: work slows in the blaze, water breaks are real, dawn
and dusk are the smart hours — player.sim already has the shove/stagger
scaffolding for one more environmental pressure.
## ARC 4 — THE SEASON (≈ two sprints)
*The campaign clock, persistence, and the roguelite.*
- **Save the business**: localStorage persistence — wallet, rep, gear, week
number. The week loop closes into a season of weeks with a job-board economy.
- **Storm season (roguelite)**: fixed starting budget, procedurally generated
storm sequences inside authored envelopes (C's makeStorm(seed) groundwork
lands in SPRINT15), survive the season solvent. The seeded weather core was
built for this from day one.
- **Gear that persists**: hardware tiers into TOOL tiers — the petrol auger vs
the shovel (post placement time), the laser level (free grading intel), the
second trailer (carry more spares). DESIGN.md's life-changing-upgrade list.
- **Sandbox**: the storm designer sliders (dev_skyfx.html is 80% of it) plus
the editor, public. Break your own yard for fun; the chaos physics carries it.
## ARC 5 — THE WATER TABLE (the big swing — gated, not scheduled)
*Retaining walls, drainage, the heightmap that flows. DESIGN.md's second game.*
Not scheduled until it passes the same bar the cloth passed: a bench that proves
the fun before a lane builds the feature. The gate to open this arc is a
tools/water_bench/ prototype — coarse-grid flow on a heightmap, one swale tool,
one wall that can blow out, played in a probe page and felt to be a game. Arc 3's
runoff/clog slices will have taught players (and us) how much water legibility
buys at what sim cost. Everything DESIGN.md says about walls, weep holes,
surcharge and the mudslide entering the debris system waits here, intact.
---
## The standing rails (every arc, no gate needed)
- **Perf floor**: the game must hold 60 fps on the M1 Ultra and stay playable on
an Intel MacBook's integrated GPU. The review's 12-fps embedded-pane datum was
the reviewer's harness, not the game — but nobody has MEASURED a low-end
machine, and "measure, don't reason" applies to frame rate too.
- **Sound**: every new mechanic that can kill a corner gets a tell by ear
(DESIGN.md: players learn to defend by ear). The tink of a backing-out pin is
still unbuilt and is the single highest-value audio asset in the design.
- **Touch**: the notice locks phones out honestly. A touch rigging UI is a real
future (the editor's raycast placement is most of one) — parked until arc 4,
named here so nobody half-builds it sooner.
- **Deploy**: tools/deploy_hardyards.sh after every integration; the live site
is currently one sprint behind main and that gap should never exceed one.
- **The sim's debt, tracked**: node-position heal after divergence (SPRINT16
gate 2), the >83 ms frame determinism bound (documented, un-tested), membrane
pricing the day rain's drain weight moves (rigging.js's comment names it).

View File

@ -79,8 +79,17 @@ D's friction list, by owner, all seen in anger:
## Gate 4 — JOHN PLAYS, THEN CHOOSES (nine sprints)
partly.party/hardyards — five nights, three sentences in THREADS. Then the
branch, which the editor has now repriced:
**⚡ THE BRANCH HALF OF THIS GATE IS RESOLVED (2026-07-18, integration checkout):**
this sprint plan and a TEETH sprint plan were written in parallel forks the
same day, and John ruled while they were being reconciled — **all three
branches, sequenced: teeth → sites/clients → heatwaves.** ROADMAP.md (new,
the growth canon) carries the arcs; SPRINT16.md carries TEETH pre-speced,
firing when this sprint integrates. The gates above stand exactly as written
— they re-measure the numbers SPRINT16 pins, which is why 15 runs first.
**The PLAY half stands:** partly.party/hardyards — five nights, three
sentences in THREADS. The fabric key (F) is newly live; night 3's yard has a
sibling now (`site_03_swing_lawn`, inert until SPRINT16 wires it).
- **More sites** — D's `site_03_swing_lawn` is authored, scored and played,
sitting inert waiting for a NIGHTS entry. This branch is now nearly free.
- **Reputation / warranty** — DESIGN.md's consequence loop; the invoice exists

168
SPRINT16.md Normal file
View File

@ -0,0 +1,168 @@
# SPRINT 16 — TEETH: YOUR WORK FOLLOWS YOU
*⚠️ PRE-SPECED, FIRES AFTER SPRINT 15 INTEGRATES. Sprint 15 (the post ruling,
the iterative editor, the honest edges) re-measures numbers this sprint pins —
do not start these gates on a pre-15 tree, and treat every number below that
S15's gate-1 re-measure moves as "re-measure first". Written 2026-07-18 at
John's direction (ROADMAP.md — gate 4 of S14 resolved: all three branches,
sequenced, teeth first).*
*The week grows consequences — reputation on the letterhead, warranty on the
job sheet, a sixth night, the swing lawn in the rotation, and the first night
where the default fabric is the wrong answer. Baseline selftest: whatever
Sprint 15 lands (409/0/0 pre-15 plus S15's additions). Keep it green;
mutation-check anything you'd call coverage.*
**The one-variable rule is LAW this sprint (ROADMAP principle 5):** six nights,
exactly one flies the new storm, exactly one is the new site, and they are NOT
the same night. A player who loses night 6 must be able to say whether the yard
beat them or the weather did.
## Gate 1 — THE LEDGER (A owns: week.js, hud.js surfaces, main.js wiring)
Reputation and warranty, hung off the paperwork that already exists. No new
phase, no new sim — this is week-state plus three surfaces telling the truth.
1. **The work history.** Every committed rig is recorded on the week: corners,
hardware, effective ratings, what broke, what was repaired by dawn. This is
data the night already computes — the ledger just keeps it.
2. **Warranty.** A corner of YOURS that broke and was still broken at dawn books
a warranty item: the NEXT morning's job sheet carries it as a line —
"warranty — q3, shackle, no charge to the client" — and its cost (the
hardware's shop price, unpaid labour is the point) comes off that night's
fee AT QUOTE TIME. week.js's own invariant is non-negotiable: the sheet and
the invoice never diverge — the quote already carries the deduction, so
settle pays exactly what was quoted. A repair you made mid-storm is NOT a
warranty item; riding it out broken is.
3. **Reputation.** One number on the letterhead (pick the unit — stars read as
client-speak; whatever it is, it appears under the ABN on job sheet AND
invoice, E's letterhead contract). Clean night (no breaks, no collateral):
up. Warranty item: down. Collateral event: down harder. It MOVES PAY at
quote time — a trusted outfit's callout fee multiplier, printed on the
sheet, so the player watches their number become money. Start neutral;
night 5's beyond-saving garden must NOT cost rep (the flag exists — read it;
a designed loss the ledger punishes is the verdict lying again).
4. **Aftermath teaches it.** The invoice's verdict line and the end-of-week
card both speak rep: a week that ends broke-but-clean and a week that ends
rich-with-warranties are different endings and should read as such.
5. Tests, a.test: warranty fires only on unrepaired breaks (repaired = no
item); quote==settle holds with a warranty line on the sheet; rep never
moves on gardenBeyondSaving nights for garden reasons; the multiplier is on
the sheet the night it applies. Negative control: a clean week books zero
warranty items and ends rep-positive.
## Gate 2 — THE HEAL + THE PAPER TRAIL + THE PIN (B owns)
1. **Divergence heal.** The review landed the break (a NaN corner now lets go,
reason:'divergence', production path) — but the freed node then integrates
from NaN forces and the cloth renders ugly after one. Sanitize on the break:
any non-finite node position resets from its nearest finite neighbour (or
the corner's anchor), prev matched so verlet doesn't invent velocity, and
non-finite water zeroed (a NaN pond re-poisons the heal through the weight
term). Assert: poison → break → every node finite within 1 s of sim.
Mutation: heal off, assert red. The existing divergence test must keep
passing untouched — the heal is IN ADDITION to the break, not instead.
2. **Fabric on the paperwork.** The chosen fabric prints in the prep summary,
on the invoice, and in the aftermath verdict when it mattered (a membrane
night survived in cloth should say what the leak cost). The FORECAST half is
C's (gate 3.3) — yours is that the choice the player made is on the record.
3. **Pin site_02's separation — AFTER S15's re-measure.** The SCORE IT card
says NONE PINNED and calls it out. Once the post ruling has settled the
numbers, run gardenfly, pick the honest target, pin the block in site_02's
JSON, assert it like backyard_01's. If the honest answer is "this yard
cannot separate held from bare at $80", that is a FINDING about the yard —
post it in THREADS; the balance fight starts there, not at a fudged pin.
Same pass, same rule for `site_03_swing_lawn` if S15 left it unpinned.
## Gate 3 — THE MEMBRANE NIGHT + THE SEEDED STORM (C owns)
1. **storm_06 — the pea-hail soaker.** Small stones (under the 2 mm-weave
ruling's blocking threshold), heavy rain, mild wind. The night the bet
flips: cloth leaks fine hail onto the bed all night; membrane stops
everything and the mild wind means its doubled load is affordable — but it
PONDS, so the broom matters. Author it so both lines exist and the membrane
line is genuinely better: measure with gardenfly/site_audit (funnel state in
the header, per the discipline; post-ruling numbers only), pin
membrane-vs-cloth garden separation in the storm's own selftest, and make
sure a $80 wallet affords the membrane line on the night's site. If the
measured numbers say cloth still wins, retune the hail spectrum until the
bet is real — the storm exists to make the F key matter, and a decoration
night is worse than no night.
2. **makeStorm(seed) — groundwork, not a shipped night.** A seeded generator
inside authored envelopes (base curve, gust budget, direction change window,
rain/hail ranges as data), producing storm defs that pass the schema
validator and the storm_envelope harness. Deterministic from seed —
ROADMAP arc 4's roguelite eats this. Selftest: same seed → byte-equal def;
100 seeds → all schema-valid, all inside the envelope; a seed that violates
the envelope is a red test, not a shrug.
3. **The forecast argues the bet.** Hail stone size and rain rate join wind on
the job sheet's forecast block (the data is in the storm JSON; A prints
what you expose). The band-not-a-promise rule holds — forecast bands, real
values inside them.
## Gate 4 — THE SIX-NIGHT WEEK (D owns)
1. **The swing lawn enters the rotation.** Your site_03_swing_lawn landed in
S14 — authored cold, scored, played, inert. Re-score it on the post-ruling
tree with S15's faster loop (the numbers that made it winnable-but-mean may
have moved), fix what moved, then wire it into NIGHTS. If S15's edges work
added a separation field or B pinned one for it, carry it; otherwise gate
2.3 covers it.
2. **The week becomes six nights.** Wire the swing lawn and storm_06 into
NIGHTS under the one-variable rule: the new site debuts on a storm the
player has already survived; storm_06 flies over a yard they already know.
You own the order and the two new briefs (client flavour is data — the
corner block got a name and a reason; the swing set's kid deserves one
too). Night 5's beyond-saving flag and going-broke-never-on-the-final-night
must both survive re-ordering — the a.test week asserts will tell you.
3. **Cold-play the whole six**, wallet from night 1, and file the verdict:
does a warranty callout read as FAIR when it lands on your sheet? Does rep
moving pay feel earned or arbitrary? Is night 6's bet legible from the
forecast alone? Your play IS the acceptance test for gates 13.
4. Pool (yours): the phantom-sail seam if S15 didn't close it.
## Gate 5 — THE NEXT TEMPTATIONS (E owns)
Arc 2 needs yards this palette cannot dress yet. Two of these three, factory-
built, byte-stable, negative-control-proven (build the wrong one on purpose,
watch red) — and every anchor-bearing node passes the S15 gate-1 mechanism
(a baked hint that cannot reach a JSON-declared anchor is the bug class that
cost the posts their truth):
1. **The glasshouse** — the collateral apex. Priced high, wreck variant,
collateral_key wired so a SECOND one placed in the editor still bills (the
carport's five-sprint lesson). Panes that read as fragile at a glance;
nothing about it may adopt as an anchor.
2. **The pool kit** — pool + compliant fence ring. The fence posts are the
temptation: honest `tie_off:false` on every one (silence is not neutral —
adoptAnchor's `?? 1` makes an unrated node the best steel in the game),
because the pool yard's whole puzzle is that the legal tie-offs are all in
the wrong places.
3. **The trampoline + tie-down kit** — typed honestly in the checked enum;
the tie-down is the prep-phase counter to the storm's opening projectile,
priced like it matters.
Flag D in THREADS as each lands.
## Gate 6 — JOHN: PLAY, THEN AIM ARC 2
Six nights at partly.party/hardyards (deploy after integration — ROADMAP: the
live gap never exceeds one sprint). Three sentences in THREADS. Then one
ruling for arc 2's first sprint: **emergency mid-storm callout or the cowboy
audit** — which job type leads THE BOOK OF CLIENTS?
Pool, spread by ownership: whatever S15's pool leaves standing, plus:
membrane pricing the day rain's drain weight moves (B, rigging.js's comment
names the start); the >83 ms determinism bound gets a pinning test or a
documented waiver (B).
```
gate 1: ledger — warranty on the sheet, rep on the letterhead, quote==settle
holds, a designed loss costs nothing
gate 2: divergence heal (finite in 1s, mutation-proof); fabric on the record;
separations pinned post-ruling or honestly refused
gate 3: storm_06 makes membrane the right answer, measured not asserted;
makeStorm(seed) deterministic inside envelopes; forecast argues the bet
gate 4: swing lawn re-scored and wired; six nights, one-variable rule;
the whole week cold-played and judged
gate 5: two temptation assets, honest extras, negative controls, S15-clean
gate 6: John's three sentences + the arc-2 job-type ruling
```

View File

@ -6803,3 +6803,49 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
direction: John plays the week.** Nine sprints. The editor has made "more sites" nearly free and
D has a candidate yard sitting ready; reputation/warranty and heatwave are unchanged in cost.
That choice is his and the game is the best argument for making it that it has ever been.
[I] 2026-07-18 — 🔍 **FRESH-EYES REVIEW (John's ask, run by Claude in the integration
checkout) + SIX FIXES LANDED ON MAIN.** Full read of canon + code + a played night 1 +
the whole suite. Selftest **409/0/0** (406 baseline + 3 new, every new assert
mutation-checked red-then-green in one sitting). The findings, fixed:
| # | finding | fix |
|---|---|---|
| 1 | `sail.selftest.js` dumpPond assert was a TAUTOLOGY — `r_prev` returned its own argument, so `|dumped dumped| < 1e-9` could not fail no matter what dumpPond returned | asserts `dumped === pondMass-before` exactly, vacuity-guarded (`before > 1`); `r_prev` deleted. Third instance of the looks-wired-isn't disease, this time in Lane B's own suite |
| 2 | **divergence never broke on the production path**`load > rating` is false for NaN, so a diverged corner held FOREVER while NaN ate the spring network; `watchDivergence` (the throwing tripwire) is test-only and main.js never sets it | `_checkFailure` now breaks a non-finite corner instantly, `reason:'divergence'` on the event, load zeroed so the HUD reads a break, not NaN. New assert runs with watchDivergence OFF — the shipped path, poisoned node, 4/4 corners let go. Mutation: guard off → 0 breaks, red |
| 3 | pond per-node cap only enforced on the rain-add — once rain stopped, downhill consolidation could stack a basin node past `POND_MAX_KG_M2` with nothing left to trim it | clamp moved to POST-FLOW (runs every step, rain or not; over-cap water sheets off, not conserved). New assert: 2000 kg injected on the settled belly → 280 kg one step later, no tear (the no-pondDump guard proves it was the CLAMP, not the belly tear). Mutation: clamp off → 1999 kg, red |
| 4 | `debris.js` still carried the SPRINT2-era duck-typed `sail.nodes` seam + `applyToSail` — dead since decision 5 chose option b (`sail._applyDebris` reads `debris.pieces`), and a live DOUBLE-APPLY the day anyone exposed `.nodes` | deleted, with a comment saying where the real contact model lives. main.js may keep passing `sail:` — accepted and ignored, documented |
| 5 | **the fabric bet was unreachable**`FABRIC`/`setFabric()` shipped SPRINT6, the sim honoured porosity all along, rigging.js:276 calls cloth "half of the wild night's only winnable line"… and no key, click or HUD line ever reached it. Every rig ever played was DEFAULT_FABRIC | **F cycles cloth/membrane in prep.** Panel line 2 shows the fabric, help line advertises F, splash teaches it ("cloth breathes, membrane stops the rain"), blurb announced on switch. Session-level assert: cycle round-trips, $0 both ways, summary carries it, garbage refused. Verified in the running game |
| 6 | main.js said `panel:false` in the comment and passed `panel:true` in the call, two sprints running | comment now tells the truth (B's panel IS the only prep UI; hud.js never took it over). README also retitled — the game shipped as HARD YARDS, the repo still said SHADES |
**Follow-ups pushed to SPRINT15, not snuck in here:** node-position sanitation after a
divergence break (the freed corner integrates from NaN forces — break is detectable now,
cloth render after one is still ugly; B should own the heal), a job-sheet surface for the
fabric bet (the key exists, the FORECAST should argue with it — A), and membrane's
balance once rain's drain weight moves (rigging.js's own comment says where to start).
[I] 2026-07-18 — 🔀 **TWO INTEGRATIONS, ONE DAY — THE FORK, RECONCILED.** The fresh-eyes
review (my entry above) and the S14 lane integration (the [I] above it) happened in
PARALLEL checkouts the same day: origin took D's swing lawn + the palette-_v1 fix + a
SPRINT15 (posts/editor); this checkout took the six review fixes + a TEETH sprint plan,
also numbered 15. Merged with the house rules — code was disjoint (their side: editor.js;
my side: sail/rigging/debris/hud/main + suites), docs were the fork:
· **Their SPRINT15 STANDS as Sprint 15.** Its gate 1 (the post rating_hint ruling)
re-measures the exact numbers TEETH wants to pin — running TEETH first would have
pinned pre-ruling numbers, which is the S13 lesson at sprint scale.
· **TEETH is SPRINT16, pre-speced** with a fires-after-15 banner; its D gate now WIRES
the swing lawn instead of authoring it (it exists — 0b858ab), and every pin says
"post-ruling numbers only". LANE_PROMPTS carries both sets, 16 below 15, marked.
· **S15 gate 4's branch half is RESOLVED by John** (all three, sequenced — teeth →
sites/clients → heatwaves; ROADMAP.md is the new growth canon holding the arcs). The
PLAY half stands, and is better now: the F key is live and the review fixes ride along.
· THREADS conflict resolved keep-both-chronological (their S14 entries, then the review
entry — the review's "406 baseline" and "pushed to SPRINT15" read as that fork's truth;
the follow-ups it names are SPRINT16 gates now).
Selftest on the merged tree: run below this entry — the review's 3 asserts join whatever
D's landing carried. My review entry's fix #4 (debris seam deletion) and D's inert-props
finding touched DIFFERENT halves of debris/editor and merged clean; the palette fix means
the swing set now DRAWS, which makes E's S14 negative-control discipline the next thing
the editor exercises.

View File

@ -182,7 +182,8 @@ export function createDebris(o = {}) {
/**
* @param {number} dt fixed step
* @param {number} t storm time
* @param {object} [world] {player, sail} both optional, both duck-typed
* @param {object} [world] {player} optional, duck-typed. (A `sail` key is
* accepted and ignored: debris-vs-sail is sail.js's `_applyDebris`.)
*/
step(dt, t, world = {}) {
// storm JSON drives the spawns; poll the window so nothing is missed
@ -194,7 +195,6 @@ export function createDebris(o = {}) {
}
const player = world.player;
const sail = world.sail;
for (let i = pieces.length - 1; i >= 0; i--) {
const p = pieces[i];
@ -269,10 +269,11 @@ export function createDebris(o = {}) {
}
}
// --- sphere vs sail nodes: impulse ---
// Duck-typed: lights up the moment Lane B exposes nodes, silent until
// then. See THREADS — B owns sail.js, so this is the seam we agreed on.
if (sail && sail.nodes) applyToSail(p, sail);
// debris-vs-sail lives in sail.js (`_applyDebris` reads `debris.pieces`
// each step — SPRINT2 decision 5, option b). The old duck-typed seam
// here (`if (sail.nodes) applyToSail(...)`) is deleted, not dormant:
// had Lane B ever exposed `.nodes`, every hit would have been applied
// TWICE through two different contact models.
if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) {
despawn(p);
@ -292,33 +293,5 @@ export function createDebris(o = {}) {
},
};
/**
* Shove any cloth node the piece is intersecting, and lose some of the piece's
* own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we
* move position and let the integrator turn it into velocity).
*/
function applyToSail(p, sail) {
const nodes = sail.nodes;
const reach = p.r + 0.15;
const reachSq = reach * reach;
let hits = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z;
const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > reachSq || dsq < 1e-9) continue;
const d = Math.sqrt(dsq);
// push the node out to the sphere surface along the contact normal
const push = (reach - d) / d;
n.x += dx * push; n.y += dy * push; n.z += dz * push;
hits++;
}
if (hits) {
const drag = Math.min(0.5, (hits * p.mass) / 400);
p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag;
if (sail.onDebrisHit) sail.onDebrisHit(p, hits);
}
}
return debris;
}

View File

@ -516,6 +516,7 @@ export function createHud(d) {
['click', 'pick an anchor · again to unpick'],
['[ ]', 'tension the rig — tighter holds shape, and breaks harder'],
['S', 'buy a spare shackle'],
['F', 'choose the fabric — cloth breathes, membrane stops the rain'],
['E', 'repair a blown corner (ladder if it is up high)'],
['C', 'brace against the wind'],
['Enter', 'commit the rig and start the night'],

View File

@ -740,8 +740,13 @@ export async function boot(opts = {}) {
getSky: () => sky,
});
// Lane B's picking adapter. `panel:false` — their built-in panel is a fine
// bench, but hud.js owns the screen now, so the two shouldn't both draw.
// Lane B's picking adapter. `panel:true` is DELIBERATE and the old comment
// here ("panel:false — hud.js owns the screen") described an intent that
// never happened: hud.js never grew a prep table, so B's panel is the only
// prep UI there is. If hud.js ever takes prep over, flip this to false in
// the same commit — two panels drawing at once is the bug the old comment
// was worried about, and it was half right: the flag and the comment
// disagreed for two sprints and nobody could tell which one was the plan.
rigging = await createRiggingUI({
scene,
camera: cameraRig.object,

View File

@ -514,6 +514,15 @@ export async function createRiggingUI({
if (ev.key === '[') session.setTension(session.tension - 0.05);
else if (ev.key === ']') session.setTension(session.tension + 0.05);
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
else if (ev.key.toLowerCase() === 'f') {
// The forecast bet, finally reachable. FABRIC and setFabric() shipped in
// SPRINT6 and the sim honoured porosity all along — but no key, click or
// HUD line ever reached them, so every rig ever played was DEFAULT_FABRIC.
// Two entries, so F is a toggle; announce the blurb because the panel
// line alone doesn't say what the bet IS.
const next = FABRIC[(FABRIC.indexOf(session.fabric) + 1) % FABRIC.length];
if (say(session.setFabric(next.id)).ok) onMessage(`${next.name}${next.blurb}`);
}
else return;
ev.preventDefault();
refresh();
@ -585,14 +594,14 @@ export async function createRiggingUI({
: null;
el.textContent = [
`PREP — rig four corners $${s.budget} left`,
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
`tension ${s.tension.toFixed(2)} spare x${s.spares} ${s.fabric.name}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
'',
...rows,
...(standingLine ? ['', standingLine] : []),
'',
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
'click anchor: rig / cycle hw shift-click: remove',
'[ ] tension S spare RMB orbit',
'[ ] tension S spare F fabric RMB orbit',
].join('\n');
}

View File

@ -6,7 +6,7 @@
* js/tests/b.test.js) and node.
*/
import { RiggingSession } from './rigging.js';
import { RiggingSession, FABRIC } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
@ -344,6 +344,21 @@ test('reset() returns a used session to a fresh prep phase', () => {
return 'budget, picks, tension, spares all fresh; rig-able again';
});
test('fabric is a bet the F key can actually place (SPRINT15 — the dead mechanic)', () => {
const s = session();
assert(s.fabric.id === 'cloth', `default fabric should be cloth, got ${s.fabric.id}`);
// the exact call the F key makes: cycle to the other entry by id
const next = FABRIC[(FABRIC.indexOf(s.fabric) + 1) % FABRIC.length];
assert(s.setFabric(next.id).ok, 'cycling to membrane refused');
assert(s.fabric.id === 'membrane' && s.fabric.porosity === 0, 'membrane not applied');
assert(s.budget === START_BUDGET, 'fabric is a bet, not a purchase — budget must not move');
assert(s.summary.fabric.id === 'membrane', 'the panel reads fabric off summary and must see the change');
// wrap-around brings it home, and garbage is refused loudly
assert(s.setFabric(FABRIC[(FABRIC.indexOf(s.fabric) + 1) % FABRIC.length].id).ok && s.fabric.id === 'cloth', 'cycle should wrap back to cloth');
assert(s.setFabric('hessian').ok === false, 'unknown fabric must refuse, not default');
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
});
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {

View File

@ -542,8 +542,6 @@ export class SailRig {
const a = this._nodeArea[n];
if (a > 1e-9 && kgPerM2PerSec > 0) {
w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection
const cap = POND_MAX_KG_M2 * a;
if (w[n] > cap) w[n] = cap;
}
flow[n] = 0;
}
@ -569,7 +567,17 @@ export class SailRig {
const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt);
flow[n] -= moved; flow[best] += moved;
}
for (let n = 0; n < N2; n++) w[n] += flow[n];
// The per-node ceiling is enforced AFTER the flow step, not on the rain-add:
// the old rain-add clamp only ran while it was raining, so once the rain
// stopped, downhill consolidation could quietly stack a basin node past
// POND_MAX_KG_M2 with nothing left to trim it (the belly-tear guard below
// only bounds the gross runaway). Water over the cap sheets off the cloth —
// it leaves the sail, same as the rim spill; it is not conserved.
for (let n = 0; n < N2; n++) {
w[n] += flow[n];
const a = this._nodeArea[n];
if (a > 1e-9 && w[n] > POND_MAX_KG_M2 * a) w[n] = POND_MAX_KG_M2 * a;
}
// A rim node spills over the edge ONLY when the edge is downhill — i.e. it
// has no lower interior neighbour to send water to. A hypar's low corners
@ -916,6 +924,22 @@ export class SailRig {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
// A non-finite load can never trip the rating check below — NaN compares
// false to everything — so a diverged corner would otherwise hold FOREVER
// while NaN spreads node-to-node through the spring network. Divergence
// is a failure, not weather: break the corner now, on the production
// path, not just under the test-only watchDivergence tripwire. The event
// carries reason:'divergence' so a log can tell it from an honest blow.
if (!Number.isFinite(c.load)) {
c.broken = true;
c.overload = 0;
c.load = 0;
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
continue;
}
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {

View File

@ -197,6 +197,48 @@ test('sim stays finite through a full storm', () => {
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
});
test('divergence BREAKS on the production path — a NaN corner cannot hold forever', () => {
// watchDivergence stays OFF here on purpose: that tripwire is test-only, and
// this test is about the SHIPPED path. Before the guard in _checkFailure,
// `load > rating` was false for NaN — a diverged corner never broke, never
// recovered, and the sail froze silently while NaN ate the spring network.
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
const broke = [];
r.events.on('break', (e) => broke.push(e));
const w = makeStubWind({ stormLen: 90 });
runStorm(r, w, 2);
assert(broke.length === 0, 'rig should be healthy before the poison');
// Poison one interior node — the shape a solver blow-up leaves behind.
r.pos[(r.N + 1) * 3 + 1] = NaN;
runStorm(r, w, 2);
assert(broke.some((e) => e.reason === 'divergence'),
`NaN spread through the cloth and no corner broke by divergence — the silent freeze is back (${broke.length} breaks)`);
const b = r.corners.find((c) => c.broken);
assert(b && b.load === 0, 'a corner broken by divergence must read load 0, not NaN, for the HUD');
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
});
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
// flow could pile a basin node arbitrarily high and nothing trimmed it.
// Inject an absurd pond onto the settled belly and take one step: the
// clamp must shed it — and it must be the CLAMP doing it, not a belly
// tear, or the assert is measuring the wrong mechanism.
const r = carportRig(UNBREAKABLE);
const still = { sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }), rainMmPerHour: () => 0 };
runStorm(r, still, 5); // settle: the belly is the basin
const dumps = [];
r.events.on('pondDump', (e) => dumps.push(e));
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
r.water[belly] = 2000; // ~7x any sane per-node cap
r.step(SIM_DT, still, 5 + SIM_DT);
assert(dumps.length === 0, `the belly tore (${dumps[0]?.reason}) — reduce the injection, this test is about the clamp`);
assert(r.water[belly] < 400,
`node ${belly} still holds ${r.water[belly].toFixed(0)} kg after a step — the per-node cap is not enforced once rain stops`);
return `injected 2000 kg, one step later the node holds ${r.water[belly].toFixed(0)} kg, no tear`;
});
test('sail sags under gravity when calm', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 6);
@ -846,12 +888,17 @@ test('ponding: mass conserves until something dumps it', () => {
const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
runStorm(r, dry, 5);
assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
// The old assert here compared `dumped` to itself through a helper that
// returned its argument — a tautology that could not fail no matter what
// dumpPond returned. Say the real thing: what it reports dropping is what
// was on the cloth.
const before = r.pondMass();
assert(before > 1, `only ${before.toFixed(1)} kg left to dump — test is vacuous`);
const dumped = r.dumpPond('test');
assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped');
assert(Math.abs(dumped - before) < 1e-9, `dumpPond reported ${dumped.toFixed(1)} kg but ${before.toFixed(1)} kg was on the cloth`);
assert(r.pondMass() === 0, 'dumpPond left water behind');
return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
});
function r_prev(_r, d) { return d; }
test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });